Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/gems
diff options
context:
space:
mode:
Diffstat (limited to 'gems')
-rw-r--r--gems/activerecord-gitlab/.gitignore11
-rw-r--r--gems/activerecord-gitlab/.gitlab-ci.yml4
-rw-r--r--gems/activerecord-gitlab/.rspec3
-rw-r--r--gems/activerecord-gitlab/.rubocop.yml18
-rw-r--r--gems/activerecord-gitlab/Gemfile5
-rw-r--r--gems/activerecord-gitlab/Gemfile.lock104
-rw-r--r--gems/activerecord-gitlab/README.md4
-rw-r--r--gems/activerecord-gitlab/activerecord-gitlab.gemspec28
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb11
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb43
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb21
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb49
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb25
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb17
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb19
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/rescue_from.rb39
-rw-r--r--gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb9
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb52
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb115
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb101
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb41
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb241
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb110
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb31
-rw-r--r--gems/activerecord-gitlab/spec/active_record/gitlab_patches_spec.rb7
-rw-r--r--gems/activerecord-gitlab/spec/spec_helper.rb31
-rw-r--r--gems/activerecord-gitlab/spec/support/database.rb29
-rw-r--r--gems/activerecord-gitlab/spec/support/models.rb50
-rw-r--r--gems/activerecord-gitlab/spec/support/query_recorder.rb21
-rw-r--r--gems/click_house-client/.gitlab-ci.yml4
-rw-r--r--gems/click_house-client/.rspec3
-rw-r--r--gems/click_house-client/.rubocop.yml2
-rw-r--r--gems/click_house-client/Gemfile5
-rw-r--r--gems/click_house-client/Gemfile.lock102
-rw-r--r--gems/click_house-client/README.md3
-rw-r--r--gems/click_house-client/click_house-client.gemspec24
-rw-r--r--gems/click_house-client/lib/click_house/client.rb64
-rw-r--r--gems/click_house-client/lib/click_house/client/configuration.rb68
-rw-r--r--gems/click_house-client/lib/click_house/client/database.rb32
-rw-r--r--gems/click_house-client/lib/click_house/client/formatter.rb28
-rw-r--r--gems/click_house-client/lib/click_house/client/response.rb18
-rw-r--r--gems/click_house-client/spec/click_house/client/configuration_spec.rb66
-rw-r--r--gems/click_house-client/spec/click_house/client/database_spec.rb32
-rw-r--r--gems/click_house-client/spec/click_house/client_spec.rb98
-rw-r--r--gems/click_house-client/spec/fixtures/query_result.json53
-rw-r--r--gems/click_house-client/spec/spec_helper.rb15
-rw-r--r--gems/config/rubocop.yml98
-rw-r--r--gems/gem.gitlab-ci.yml66
-rw-r--r--gems/gitlab-rspec/.gitignore11
-rw-r--r--gems/gitlab-rspec/.gitlab-ci.yml4
-rw-r--r--gems/gitlab-rspec/.rspec3
-rw-r--r--gems/gitlab-rspec/.rubocop.yml10
-rw-r--r--gems/gitlab-rspec/Gemfile5
-rw-r--r--gems/gitlab-rspec/Gemfile.lock182
-rw-r--r--gems/gitlab-rspec/gitlab-rspec.gemspec31
-rw-r--r--gems/gitlab-rspec/lib/gitlab/rspec.rb9
-rw-r--r--gems/gitlab-rspec/lib/gitlab/rspec/all.rb8
-rw-r--r--gems/gitlab-rspec/lib/gitlab/rspec/configurations/time_travel.rb32
-rw-r--r--gems/gitlab-rspec/lib/gitlab/rspec/stub_env.rb52
-rw-r--r--gems/gitlab-rspec/lib/gitlab/rspec/version.rb9
-rw-r--r--gems/gitlab-rspec/spec/gitlab/rspec/time_travel_spec.rb28
-rw-r--r--gems/gitlab-rspec/spec/spec_helper.rb15
-rw-r--r--gems/gitlab-schema-validation/.gitignore11
-rw-r--r--gems/gitlab-schema-validation/.gitlab-ci.yml4
-rw-r--r--gems/gitlab-schema-validation/.rspec3
-rw-r--r--gems/gitlab-schema-validation/.rubocop.yml8
-rw-r--r--gems/gitlab-schema-validation/Gemfile6
-rw-r--r--gems/gitlab-schema-validation/Gemfile.lock137
-rw-r--r--gems/gitlab-schema-validation/gitlab-schema-validation.gemspec32
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation.rb66
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_database_adapter.rb47
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter.rb139
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter.rb31
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter.rb50
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/inconsistency.rb67
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/pg_types.rb73
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/base.rb31
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/column.rb33
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/foreign_key.rb34
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/index.rb15
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/table.rb44
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/trigger.rb15
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/database.rb192
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/structure_sql.rb143
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/base.rb49
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_foreign_keys.rb24
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_indexes.rb24
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_tables.rb50
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_triggers.rb24
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_foreign_keys.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_indexes.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_table_columns.rb32
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_tables.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_triggers.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_foreign_keys.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_indexes.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_table_columns.rb32
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_tables.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_triggers.rb21
-rw-r--r--gems/gitlab-schema-validation/lib/gitlab/schema/validation/version.rb11
-rw-r--r--gems/gitlab-schema-validation/spec/fixtures/structure.sql108
-rw-r--r--gems/gitlab-schema-validation/spec/gitlab/schema/validation_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_database_adapter_spec.rb72
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter_spec.rb78
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter_spec.rb28
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter_spec.rb42
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/inconsistency_spec.rb96
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/column_spec.rb25
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/foreign_key_spec.rb25
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/index_spec.rb11
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/table_spec.rb45
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/trigger_spec.rb11
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/sources/structure_sql_spec.rb66
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/base_spec.rb39
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_indexes_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_tables_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_triggers_spec.rb8
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_foreign_keys_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_indexes_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_table_columns_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_tables_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_triggers_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_foreign_keys_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_indexes_spec.rb14
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_table_columns_spec.rb7
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_tables_spec.rb9
-rw-r--r--gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_triggers_spec.rb9
-rw-r--r--gems/gitlab-schema-validation/spec/spec_helper.rb18
-rw-r--r--gems/gitlab-schema-validation/spec/support/shared_examples/foreign_key_validators_shared_examples.rb46
-rw-r--r--gems/gitlab-schema-validation/spec/support/shared_examples/index_validators_shared_examples.rb32
-rw-r--r--gems/gitlab-schema-validation/spec/support/shared_examples/schema_objects_shared_examples.rb26
-rw-r--r--gems/gitlab-schema-validation/spec/support/shared_examples/table_validators_shared_examples.rb81
-rw-r--r--gems/gitlab-schema-validation/spec/support/shared_examples/trigger_validators_shared_examples.rb31
-rw-r--r--gems/gitlab-utils/.gitignore11
-rw-r--r--gems/gitlab-utils/.gitlab-ci.yml4
-rw-r--r--gems/gitlab-utils/.rspec3
-rw-r--r--gems/gitlab-utils/.rubocop.yml27
-rw-r--r--gems/gitlab-utils/Gemfile10
-rw-r--r--gems/gitlab-utils/Gemfile.lock199
-rw-r--r--gems/gitlab-utils/README.md8
-rw-r--r--gems/gitlab-utils/Rakefile12
-rw-r--r--gems/gitlab-utils/gitlab-utils.gemspec35
-rw-r--r--gems/gitlab-utils/lib/gitlab/utils.rb263
-rw-r--r--gems/gitlab-utils/lib/gitlab/utils/all.rb6
-rw-r--r--gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb147
-rw-r--r--gems/gitlab-utils/lib/gitlab/utils/version.rb9
-rw-r--r--gems/gitlab-utils/lib/gitlab/version_info.rb101
-rw-r--r--gems/gitlab-utils/spec/gitlab/utils/strong_memoize_spec.rb368
-rw-r--r--gems/gitlab-utils/spec/gitlab/utils_spec.rb479
-rw-r--r--gems/gitlab-utils/spec/gitlab/version_info_spec.rb193
-rw-r--r--gems/gitlab-utils/spec/spec_helper.rb23
-rw-r--r--gems/ipynbdiff/.gitignore3
-rw-r--r--gems/ipynbdiff/.gitlab-ci.yml4
-rw-r--r--gems/ipynbdiff/.rubocop.yml21
-rw-r--r--gems/ipynbdiff/Gemfile5
-rw-r--r--gems/ipynbdiff/Gemfile.lock139
-rw-r--r--gems/ipynbdiff/LICENSE21
-rw-r--r--gems/ipynbdiff/README.md56
-rw-r--r--gems/ipynbdiff/ipynbdiff.gemspec33
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff.rb24
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/diff.rb20
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb73
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb109
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb25
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb20
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/transformer.rb111
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/version.rb7
-rw-r--r--gems/ipynbdiff/spec/benchmark.rb68
-rw-r--r--gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb55
-rw-r--r--gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb94
-rw-r--r--gems/ipynbdiff/spec/ipynb_diff_spec.rb127
-rw-r--r--gems/ipynbdiff/spec/test_helper.rb46
-rw-r--r--gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md7
-rw-r--r--gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt7
-rw-r--r--gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb16
-rw-r--r--gems/ipynbdiff/spec/testdata/error_output/expected.md16
-rw-r--r--gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt16
-rw-r--r--gems/ipynbdiff/spec/testdata/error_output/input.ipynb32
-rw-r--r--gems/ipynbdiff/spec/testdata/from.ipynb197
-rw-r--r--gems/ipynbdiff/spec/testdata/hide_images/expected.md10
-rw-r--r--gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt10
-rw-r--r--gems/ipynbdiff/spec/testdata/hide_images/input.ipynb45
-rw-r--r--gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md11
-rw-r--r--gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt11
-rw-r--r--gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb74
-rw-r--r--gems/ipynbdiff/spec/testdata/latex_output/expected.md10
-rw-r--r--gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt10
-rw-r--r--gems/ipynbdiff/spec/testdata/latex_output/input.ipynb34
-rw-r--r--gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md9
-rw-r--r--gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt9
-rw-r--r--gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb25
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells/expected.md19
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt19
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells/input.ipynb25
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md0
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt0
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb25
-rw-r--r--gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md13
-rw-r--r--gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt13
-rw-r--r--gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb29
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code/expected.md7
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt7
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code/input.ipynb21
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb12
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb14
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb11
-rw-r--r--gems/ipynbdiff/spec/testdata/only_md/expected.md5
-rw-r--r--gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt5
-rw-r--r--gems/ipynbdiff/spec/testdata/only_md/input.ipynb21
-rw-r--r--gems/ipynbdiff/spec/testdata/only_raw/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_raw/input.ipynb15
-rw-r--r--gems/ipynbdiff/spec/testdata/percent_decorator/expected.md68
-rw-r--r--gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt68
-rw-r--r--gems/ipynbdiff/spec/testdata/single_line_md/expected.md3
-rw-r--r--gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt3
-rw-r--r--gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb17
-rw-r--r--gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md5
-rw-r--r--gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt5
-rw-r--r--gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb11
-rw-r--r--gems/ipynbdiff/spec/testdata/stream_text/expected.md9
-rw-r--r--gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt9
-rw-r--r--gems/ipynbdiff/spec/testdata/stream_text/input.ipynb27
-rw-r--r--gems/ipynbdiff/spec/testdata/svg/expected.md17
-rw-r--r--gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt17
-rw-r--r--gems/ipynbdiff/spec/testdata/svg/input.ipynb66
-rw-r--r--gems/ipynbdiff/spec/testdata/text_output/expected.md9
-rw-r--r--gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt9
-rw-r--r--gems/ipynbdiff/spec/testdata/text_output/input.ipynb31
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/expected.md14
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt14
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt14
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb49
-rw-r--r--gems/ipynbdiff/spec/testdata/to.ipynb200
-rw-r--r--gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md5
-rw-r--r--gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt5
-rw-r--r--gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb27
-rw-r--r--gems/rspec_flaky/.gitignore11
-rw-r--r--gems/rspec_flaky/.gitlab-ci.yml4
-rw-r--r--gems/rspec_flaky/.rspec3
-rw-r--r--gems/rspec_flaky/.rubocop.yml13
-rw-r--r--gems/rspec_flaky/Gemfile8
-rw-r--r--gems/rspec_flaky/Gemfile.lock126
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky.rb6
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/config.rb21
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/example.rb68
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/flaky_example.rb59
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/flaky_examples_collection.rb34
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/listener.rb70
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/report.rb59
-rw-r--r--gems/rspec_flaky/lib/rspec_flaky/version.rb7
-rw-r--r--gems/rspec_flaky/rspec_flaky.gemspec30
-rw-r--r--gems/rspec_flaky/spec/rspec_flaky/config_spec.rb99
-rw-r--r--gems/rspec_flaky/spec/rspec_flaky/example_spec.rb99
-rw-r--r--gems/rspec_flaky/spec/rspec_flaky/flaky_example_spec.rb144
-rw-r--r--gems/rspec_flaky/spec/rspec_flaky/flaky_examples_collection_spec.rb87
-rw-r--r--gems/rspec_flaky/spec/rspec_flaky/listener_spec.rb229
-rw-r--r--gems/rspec_flaky/spec/rspec_flaky/report_spec.rb141
-rw-r--r--gems/rspec_flaky/spec/spec_helper.rb17
265 files changed, 10842 insertions, 0 deletions
diff --git a/gems/activerecord-gitlab/.gitignore b/gems/activerecord-gitlab/.gitignore
new file mode 100644
index 00000000000..b04a8c840df
--- /dev/null
+++ b/gems/activerecord-gitlab/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/gems/activerecord-gitlab/.gitlab-ci.yml b/gems/activerecord-gitlab/.gitlab-ci.yml
new file mode 100644
index 00000000000..eb94904ce90
--- /dev/null
+++ b/gems/activerecord-gitlab/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "activerecord-gitlab"
diff --git a/gems/activerecord-gitlab/.rspec b/gems/activerecord-gitlab/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/activerecord-gitlab/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/activerecord-gitlab/.rubocop.yml b/gems/activerecord-gitlab/.rubocop.yml
new file mode 100644
index 00000000000..5fce096769a
--- /dev/null
+++ b/gems/activerecord-gitlab/.rubocop.yml
@@ -0,0 +1,18 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+# FIXME
+Gitlab/RSpec/AvoidSetup:
+ Enabled: false
+
+Database/EstablishConnection:
+ Enabled: false
+
+Database/MultipleDatabases:
+ Enabled: false
+
+RSpec/EnvAssignment:
+ Enabled: false
+
+RSpec/MultipleMemoizedHelpers:
+ Enabled: false
diff --git a/gems/activerecord-gitlab/Gemfile b/gems/activerecord-gitlab/Gemfile
new file mode 100644
index 00000000000..be173b205f7
--- /dev/null
+++ b/gems/activerecord-gitlab/Gemfile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gemspec
diff --git a/gems/activerecord-gitlab/Gemfile.lock b/gems/activerecord-gitlab/Gemfile.lock
new file mode 100644
index 00000000000..ba0118eb8e3
--- /dev/null
+++ b/gems/activerecord-gitlab/Gemfile.lock
@@ -0,0 +1,104 @@
+PATH
+ remote: .
+ specs:
+ activerecord-gitlab (0.2.0)
+ activerecord (>= 7)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activemodel (7.0.6)
+ activesupport (= 7.0.6)
+ activerecord (7.0.6)
+ activemodel (= 7.0.6)
+ activesupport (= 7.0.6)
+ activesupport (7.0.6)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ ast (2.4.2)
+ concurrent-ruby (1.2.2)
+ diff-lcs (1.5.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ i18n (1.13.0)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ mini_portile2 (2.8.2)
+ minitest (5.18.0)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ racc (1.7.1)
+ rack (3.0.8)
+ rainbow (3.1.1)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-core (3.12.2)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-support (3.12.0)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ sqlite3 (1.6.3)
+ mini_portile2 (~> 2.8.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activerecord-gitlab!
+ gitlab-styles (~> 10.1.0)
+ rspec (~> 3.12)
+ rubocop (~> 1.50)
+ rubocop-rspec (~> 2.22)
+ sqlite3 (~> 1.6)
+
+BUNDLED WITH
+ 2.4.14
diff --git a/gems/activerecord-gitlab/README.md b/gems/activerecord-gitlab/README.md
new file mode 100644
index 00000000000..c5b56e367b9
--- /dev/null
+++ b/gems/activerecord-gitlab/README.md
@@ -0,0 +1,4 @@
+# ActiveRecord::GitlabPatches
+
+This gem adds GitLab specific Active Record patches.
+We have patches as a separate gem to isolate complexity.
diff --git a/gems/activerecord-gitlab/activerecord-gitlab.gemspec b/gems/activerecord-gitlab/activerecord-gitlab.gemspec
new file mode 100644
index 00000000000..267938d0de3
--- /dev/null
+++ b/gems/activerecord-gitlab/activerecord-gitlab.gemspec
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require_relative "lib/active_record/gitlab_patches/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "activerecord-gitlab"
+ spec.version = ActiveRecord::GitlabPatches::Version::VERSION
+ spec.authors = ["group::tenant-scale"]
+ spec.email = ["engineering@gitlab.com"]
+
+ spec.summary = "GitLab ActiveRecord patches"
+ spec.description = "GitLab stores any patches relating to ActiveRecord here"
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/activerecord-gitlab"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.0"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir["lib/**/*.rb"]
+ spec.require_paths = ["lib"]
+
+ spec.add_runtime_dependency "activerecord", ">= 7"
+
+ spec.add_development_dependency "gitlab-styles", "~> 10.1.0"
+ spec.add_development_dependency "rspec", "~> 3.12"
+ spec.add_development_dependency "rubocop", "~> 1.50"
+ spec.add_development_dependency "rubocop-rspec", "~> 2.22"
+ spec.add_development_dependency "sqlite3", "~> 1.6"
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb
new file mode 100644
index 00000000000..257602497f0
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require "active_record"
+require_relative "gitlab_patches/version"
+require_relative "gitlab_patches/rescue_from"
+require_relative "gitlab_patches/partitioning"
+
+module ActiveRecord
+ module GitlabPatches
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb
new file mode 100644
index 00000000000..cf0bd4849e2
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require_relative "partitioning/associations/builder/association"
+require_relative "partitioning/reflection/abstract_reflection"
+require_relative "partitioning/reflection/association_reflection"
+require_relative "partitioning/reflection/macro_reflection"
+require_relative "partitioning/base"
+
+module ActiveRecord
+ module GitlabPatches
+ # This allows to filter data by a dedicated column for association and joins to ActiveRecord::Base.
+ #
+ # class ApplicationRecord < ActiveRecord::Base
+ # belongs_to :pipeline,
+ # -> (build) { where(partition_id: build.partition_id) },
+ # partition_foreign_key: :partition_id
+ #
+ # To control the join filter
+ # def self.use_partition_id_filter?
+ # Feature.enabled?(...)
+ # end
+ # end
+ module Partitioning
+ ActiveSupport.on_load(:active_record) do
+ ::ActiveRecord::Associations::Builder::Association.prepend(
+ ActiveRecord::GitlabPatches::Partitioning::Associations::Builder::Association
+ )
+ ::ActiveRecord::Reflection::AbstractReflection.prepend(
+ ActiveRecord::GitlabPatches::Partitioning::Reflection::AbstractReflection
+ )
+ ::ActiveRecord::Reflection::AssociationReflection.prepend(
+ ActiveRecord::GitlabPatches::Partitioning::Reflection::AssociationReflection
+ )
+ ::ActiveRecord::Reflection::MacroReflection.prepend(
+ ActiveRecord::GitlabPatches::Partitioning::Reflection::MacroReflection
+ )
+ ::ActiveRecord::Base.prepend(
+ ActiveRecord::GitlabPatches::Partitioning::Base
+ )
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb
new file mode 100644
index 00000000000..3c92ba91c31
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module GitlabPatches
+ module Partitioning
+ module Associations
+ module Builder
+ module Association
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def valid_options(options)
+ super + [:partition_foreign_key]
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb
new file mode 100644
index 00000000000..0c8a248b984
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+if ::ActiveRecord::VERSION::STRING >= "7.1"
+ raise 'New version of active-record detected, please remove or update this patch'
+end
+
+module ActiveRecord
+ module GitlabPatches
+ module Partitioning
+ module Base
+ extend ActiveSupport::Concern
+
+ def _query_constraints_hash
+ constraints_hash = super
+
+ return constraints_hash unless self.class.use_partition_id_filter?
+
+ if self.class.query_constraints_list.nil?
+ { @primary_key => id_in_database } # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ else
+ self.class.query_constraints_list.index_with do |column_name|
+ attribute_in_database(column_name)
+ end
+ end
+ end
+
+ class_methods do
+ def use_partition_id_filter?
+ false
+ end
+
+ def query_constraints(*columns_list)
+ raise ArgumentError, "You must specify at least one column to be used in querying" if columns_list.empty?
+
+ @query_constraints_list = columns_list.map(&:to_s)
+ end
+
+ def query_constraints_list # :nodoc:
+ @query_constraints_list ||= if base_class? || primary_key != base_class.primary_key
+ primary_key if primary_key.is_a?(Array)
+ else
+ base_class.query_constraints_list
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb
new file mode 100644
index 00000000000..7532cd120a5
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module GitlabPatches
+ module Partitioning
+ module Reflection
+ module AbstractReflection
+ extend ActiveSupport::Concern
+
+ def join_scope(table, foreign_table, foreign_klass)
+ klass_scope = super
+ return klass_scope unless respond_to?(:options)
+
+ partition_foreign_key = options[:partition_foreign_key]
+ if partition_foreign_key && klass.use_partition_id_filter?
+ klass_scope.where!(table[:partition_id].eq(foreign_table[partition_foreign_key]))
+ end
+
+ klass_scope
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb
new file mode 100644
index 00000000000..299ceaab973
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module GitlabPatches
+ module Partitioning
+ module Reflection
+ module AssociationReflection
+ def check_eager_loadable!
+ return if scope && scope.arity == 1 && options.key?(:partition_foreign_key)
+
+ super
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb
new file mode 100644
index 00000000000..7ec7da44253
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module GitlabPatches
+ module Partitioning
+ module Reflection
+ module MacroReflection
+ def scope_for(relation, owner = nil)
+ if scope.arity == 1 && owner.nil? && options.key?(:partition_foreign_key)
+ relation
+ else
+ super
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/rescue_from.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/rescue_from.rb
new file mode 100644
index 00000000000..eaa42d1523d
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/rescue_from.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module GitlabPatches
+ # This adds `rescue_from` to ActiveRecord::Base.
+ # Currently, only errors called from `ActiveRecord::Relation#exec_queries`
+ # will be handled by `rescue_from`.
+ #
+ # class ApplicationRecord < ActiveRecord::Base
+ # rescue_from MyException, with: :my_handler
+ #
+ # def my_handler(exception)
+ # Rails.logger.info exception.message
+ #
+ # raise exception
+ # end
+ # end
+ module RescueFrom
+ extend ActiveSupport::Concern
+
+ prepended do |base|
+ base.include ActiveSupport::Rescuable
+ end
+ end
+
+ module ExecQueriesRescueWithHandler
+ def exec_queries
+ super
+ rescue StandardError => e
+ klass.rescue_with_handler(e) || raise
+ end
+ end
+ end
+end
+
+ActiveSupport.on_load(:active_record) do
+ ActiveRecord::Relation.prepend(ActiveRecord::GitlabPatches::ExecQueriesRescueWithHandler)
+ ActiveRecord::Base.prepend(ActiveRecord::GitlabPatches::RescueFrom)
+end
diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb
new file mode 100644
index 00000000000..00c5f254da8
--- /dev/null
+++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module GitlabPatches
+ module Version
+ VERSION = "0.2.0"
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb
new file mode 100644
index 00000000000..900a270c0a8
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::BelongsTo', :partitioning do
+ let(:pipeline) { Pipeline.create!(partition_id: 100) }
+ let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+
+ it 'finds associated record using partition_id' do
+ find_statement = <<~SQL.squish
+ SELECT \"pipelines\".*
+ FROM \"pipelines\"
+ WHERE \"pipelines\".\"id\" = #{pipeline.id}
+ AND \"pipelines\".\"partition_id\" = #{job.partition_id}
+ LIMIT 1
+ SQL
+
+ result = QueryRecorder.log do
+ job.reset.pipeline
+ end
+
+ expect(result).to include(find_statement)
+ end
+
+ it 'builds records using partition_id' do
+ pipeline = job.build_pipeline
+
+ expect(pipeline.partition_id).to eq(job.partition_id)
+ end
+
+ it 'saves records using partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"pipelines\" (\"partition_id\") VALUES (#{job.partition_id})
+ SQL
+
+ result = QueryRecorder.log do
+ job.build_pipeline.save!
+ end
+
+ expect(result).to include(create_statement)
+ end
+
+ it 'creates records using partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"pipelines\" (\"partition_id\") VALUES (#{job.partition_id})
+ SQL
+
+ result = QueryRecorder.log do
+ job.create_pipeline!
+ end
+
+ expect(result).to include(create_statement)
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb
new file mode 100644
index 00000000000..3d6b24de998
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::HasMany', :partitioning do
+ let(:pipeline) { Pipeline.create!(partition_id: 100) }
+ let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+
+ it 'finds individual records using partition_id' do
+ find_statement = <<~SQL.squish
+ SELECT \"jobs\".*
+ FROM \"jobs\"
+ WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id}
+ AND \"jobs\".\"partition_id\" = #{pipeline.partition_id}
+ AND \"jobs\".\"id\" = #{job.id}
+ LIMIT 1
+ SQL
+
+ result = QueryRecorder.log do
+ pipeline.jobs.find(job.id)
+ end
+
+ expect(result).to include(find_statement)
+ end
+
+ it 'finds all records using partition_id' do
+ find_statement = <<~SQL.squish
+ SELECT \"jobs\".*
+ FROM \"jobs\"
+ WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id}
+ AND \"jobs\".\"partition_id\" = #{pipeline.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ pipeline.jobs.all.to_a
+ end
+
+ expect(result).to include(find_statement)
+ end
+
+ it 'jobs records using partition_id' do
+ build = pipeline.jobs.new(name: 'test job')
+
+ expect(build.pipeline_id).to eq(pipeline.id)
+ expect(build.partition_id).to eq(pipeline.partition_id)
+ end
+
+ it 'saves records using partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\", \"name\")
+ VALUES (#{pipeline.id}, #{pipeline.partition_id}, 'test job')
+ SQL
+
+ result = QueryRecorder.log do
+ build = pipeline.jobs.new(name: 'test job')
+ build.save!
+ end
+
+ expect(result).to include(create_statement)
+ end
+
+ it 'creates records using partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\", \"name\")
+ VALUES (#{pipeline.id}, #{pipeline.partition_id}, 'test job')
+ SQL
+
+ result = QueryRecorder.log do
+ pipeline.jobs.create!(name: 'test job')
+ end
+
+ expect(result).to include(create_statement)
+ end
+
+ it 'deletes_all records using partition_id' do
+ delete_statement = <<~SQL.squish
+ DELETE FROM \"jobs\"
+ WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id}
+ AND \"jobs\".\"partition_id\" = #{pipeline.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ pipeline.jobs.delete_all
+ end
+
+ expect(result).to include(delete_statement)
+ end
+
+ it 'destroy_all records using partition_id' do
+ destroy_statement = <<~SQL.squish
+ DELETE FROM \"jobs\"
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{pipeline.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ pipeline.jobs.destroy_all # rubocop: disable Cop/DestroyAll
+ end
+
+ expect(result).to include(destroy_statement)
+ end
+
+ it 'counts records using partition_id' do
+ destroy_statement = <<~SQL.squish
+ SELECT COUNT(*)
+ FROM \"jobs\"
+ WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id}
+ AND \"jobs\".\"partition_id\" = #{pipeline.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ pipeline.jobs.count
+ end
+
+ expect(result).to include(destroy_statement)
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb
new file mode 100644
index 00000000000..aeb565c6dad
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::HasOne', :partitioning do
+ let(:pipeline) { Pipeline.create!(partition_id: 100) }
+ let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+
+ it 'finds associated record using partition_id' do
+ find_statement = <<~SQL.squish
+ SELECT \"metadata\".*
+ FROM \"metadata\"
+ WHERE \"metadata\".\"job_id\" = #{job.id}
+ AND \"metadata\".\"partition_id\" = #{job.partition_id}
+ LIMIT 1
+ SQL
+
+ result = QueryRecorder.log do
+ job.reset.metadata
+ end
+
+ expect(result).to include(find_statement)
+ end
+
+ it 'builds records using partition_id' do
+ metadata = job.build_metadata
+
+ expect(metadata.job_id).to eq(job.id)
+ expect(metadata.partition_id).to eq(job.partition_id)
+ end
+
+ it 'saves records using partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"metadata\" (\"job_id\", \"partition_id\") VALUES (#{job.id}, #{job.partition_id})
+ SQL
+
+ result = QueryRecorder.log do
+ job.build_metadata.save!
+ end
+
+ expect(result).to include(create_statement)
+ end
+
+ it 'creates records using partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"metadata\" (\"job_id\", \"partition_id\") VALUES (#{job.id}, #{job.partition_id})
+ SQL
+
+ result = QueryRecorder.log do
+ job.create_metadata
+ end
+
+ expect(result).to include(create_statement)
+ end
+
+ it 'uses nested attributes on create' do
+ skip '`partitionable` will assign the `partition_id` value in this case.'
+
+ statement1 = <<~SQL.squish
+ INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\", \"name\")
+ VALUES (#{pipeline.id}, #{pipeline.partition_id}, 'test')
+ SQL
+
+ statement2 = <<~SQL.squish
+ INSERT INTO \"metadata\" (\"job_id\", \"partition_id\", \"test_flag\")
+ VALUES (#{job.id}, #{job.partition_id}, 1)
+ SQL
+
+ insert_statements = [statement1, statement2]
+
+ result = QueryRecorder.log do
+ pipeline.jobs.create!(name: 'test', metadata_attributes: { test_flag: true })
+ end
+
+ insert_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+
+ it 'uses nested attributes on update' do
+ statement1 = <<~SQL.squish
+ UPDATE \"jobs\" SET \"name\" = 'other test'
+ WHERE \"jobs\".\"id\" = #{job.id} AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ SQL
+
+ statement2 = <<~SQL.squish
+ INSERT INTO \"metadata\" (\"job_id\", \"partition_id\", \"test_flag\") VALUES (#{job.id}, #{job.partition_id}, 1)
+ SQL
+
+ update_statements = [statement1, statement2]
+
+ job.name = 'other test'
+ job.metadata_attributes = { test_flag: true }
+
+ result = QueryRecorder.log do
+ job.save!
+ end
+
+ update_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb
new file mode 100644
index 00000000000..038fae43644
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::Joins', :partitioning do
+ let!(:pipeline) { Pipeline.create!(partition_id: 100) }
+ let!(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+ let!(:metadata) { Metadata.create!(job: job, partition_id: job.partition_id) }
+
+ it 'joins using partition_id' do
+ join_statement = <<~SQL.squish
+ SELECT \"pipelines\".*
+ FROM \"pipelines\"
+ INNER JOIN \"jobs\" ON \"jobs\".\"pipeline_id\" = \"pipelines\".\"id\"
+ AND \"jobs\".\"partition_id\" = \"pipelines\".\"partition_id\"
+ WHERE \"pipelines\".\"partition_id\" = #{pipeline.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ Pipeline.where(partition_id: pipeline.partition_id).joins(:jobs).to_a
+ end
+
+ expect(result).to include(join_statement)
+ end
+
+ it 'joins other models using partition_id' do
+ join_statement = <<~SQL.squish
+ SELECT \"pipelines\".*
+ FROM \"pipelines\"
+ INNER JOIN \"jobs\" ON \"jobs\".\"pipeline_id\" = \"pipelines\".\"id\"
+ AND \"jobs\".\"partition_id\" = \"pipelines\".\"partition_id\"
+ INNER JOIN \"metadata\" ON \"metadata\".\"job_id\" = \"jobs\".\"id\"
+ AND \"metadata\".\"partition_id\" = \"jobs\".\"partition_id\"
+ WHERE \"pipelines\".\"partition_id\" = #{pipeline.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ Pipeline.where(partition_id: pipeline.partition_id).joins(jobs: :metadata).to_a
+ end
+
+ expect(result).to include(join_statement)
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb
new file mode 100644
index 00000000000..f37a563fe9e
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb
@@ -0,0 +1,241 @@
+# frozen_string_literal: true
+
+RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::Preloads', :partitioning do
+ let(:project) { Project.create! }
+
+ let!(:pipeline) { Pipeline.create!(project: project, partition_id: 100) }
+ let!(:other_pipeline) { Pipeline.create!(project: project, partition_id: 100) }
+
+ let!(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+ let!(:other_job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+
+ describe 'preload queries with single partition' do
+ it 'preloads metadata for jobs' do
+ statement1 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\" WHERE \"jobs\".\"partition_id\" = 100
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"metadata\".* FROM \"metadata\"
+ WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id})
+ SQL
+
+ preload_statements = [statement1, statement2]
+
+ result = QueryRecorder.log do
+ Job.where(partition_id: 100).preload(:metadata).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+
+ it 'preloads jobs for pipelines' do
+ statement1 = <<~SQL.squish
+ SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" = 100
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id})
+ SQL
+
+ preload_statements = [statement1, statement2]
+
+ result = QueryRecorder.log do
+ Pipeline.where(partition_id: 100).preload(:jobs).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+
+ it 'preloads jobs and metadata for pipelines' do
+ statement1 = <<~SQL.squish
+ SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" = 100
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id})
+ SQL
+
+ statement3 = <<~SQL.squish
+ SELECT \"metadata\".* FROM \"metadata\"
+ WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id})
+ SQL
+
+ preload_statements = [statement1, statement2, statement3]
+
+ result = QueryRecorder.log do
+ Pipeline.where(partition_id: 100).preload(jobs: :metadata).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+ end
+
+ describe 'preload queries with multiple partitions' do
+ let!(:recent_pipeline) { Pipeline.create!(project: project, partition_id: 200) }
+ let!(:test_job) { Job.create!(pipeline: recent_pipeline, partition_id: recent_pipeline.partition_id) }
+ let!(:deploy_job) { Job.create!(pipeline: recent_pipeline, partition_id: recent_pipeline.partition_id) }
+
+ it 'preloads metadata for jobs' do
+ statement1 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\" WHERE \"jobs\".\"partition_id\" IN (100, 200)
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"metadata\".* FROM \"metadata\"
+ WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id})
+ SQL
+
+ statement3 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\" WHERE \"jobs\".\"partition_id\" IN (100, 200)
+ SQL
+
+ preload_statements = [statement1, statement2, statement3]
+
+ result = QueryRecorder.log do
+ Job.where(partition_id: [100, 200]).preload(:metadata).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+
+ it 'preloads jobs for pipelines' do
+ statement1 = <<~SQL.squish
+ SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" IN (100, 200)
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id})
+ SQL
+
+ statement3 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 200 AND \"jobs\".\"pipeline_id\" = #{recent_pipeline.id}
+ SQL
+
+ preload_statements = [statement1, statement2, statement3]
+
+ result = QueryRecorder.log do
+ Pipeline.where(partition_id: [100, 200]).preload(:jobs).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+
+ it 'preloads jobs and metadata for pipelines' do
+ statement1 = <<~SQL.squish
+ SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" IN (100, 200)
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id})
+ SQL
+
+ statement3 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 200 AND \"jobs\".\"pipeline_id\" = #{recent_pipeline.id}
+ SQL
+
+ statement4 = <<~SQL.squish
+ SELECT \"metadata\".* FROM \"metadata\"
+ WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id})
+ SQL
+
+ statement5 = <<~SQL.squish
+ SELECT \"metadata\".* FROM \"metadata\"
+ WHERE \"metadata\".\"partition_id\" = 200 AND \"metadata\".\"job_id\" IN (#{test_job.id}, #{deploy_job.id})
+ SQL
+
+ preload_statements = [statement1, statement2, statement3, statement4, statement5]
+
+ result = QueryRecorder.log do
+ Pipeline.where(partition_id: [100, 200]).preload(jobs: :metadata).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+ end
+
+ describe 'includes queries' do
+ it 'preloads data for pipeline with multiple queries' do
+ statement1 = <<~SQL.squish
+ SELECT \"pipelines\".* FROM \"pipelines\"
+ WHERE \"pipelines\".\"project_id\" = 1 AND \"pipelines\".\"id\"
+ IN (#{pipeline.id}, #{other_pipeline.id}) AND \"pipelines\".\"partition_id\" = 100
+ SQL
+
+ statement2 = <<~SQL.squish
+ SELECT \"jobs\".* FROM \"jobs\"
+ WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id})
+ SQL
+
+ statement3 = <<~SQL.squish
+ SELECT \"metadata\".* FROM \"metadata\"
+ WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id})
+ SQL
+
+ preload_statements = [statement1, statement2, statement3]
+
+ result = QueryRecorder.log do
+ project.pipelines.includes(jobs: :metadata).where(id: [pipeline.id, other_pipeline.id], partition_id: 100).to_a
+ end
+
+ preload_statements.each do |statement|
+ expect(result).to include(statement)
+ end
+ end
+
+ it 'preloads data for pipeline with join query' do
+ preload_statement = <<~SQL.squish
+ SELECT \"pipelines\".\"id\"
+ AS t0_r0, \"pipelines\".\"project_id\"
+ AS t0_r1, \"pipelines\".\"partition_id\"
+ AS t0_r2, \"jobs\".\"id\"
+ AS t1_r0, \"jobs\".\"pipeline_id\"
+ AS t1_r1, \"jobs\".\"partition_id\"
+ AS t1_r2, \"jobs\".\"name\"
+ AS t1_r3, \"metadata\".\"id\"
+ AS t2_r0, \"metadata\".\"job_id\"
+ AS t2_r1, \"metadata\".\"partition_id\"
+ AS t2_r2, \"metadata\".\"test_flag\"
+ AS t2_r3
+ FROM \"pipelines\"
+ LEFT OUTER JOIN \"jobs\" ON \"jobs\".\"pipeline_id\" = \"pipelines\".\"id\"
+ AND \"jobs\".\"partition_id\" = \"pipelines\".\"partition_id\"
+ LEFT OUTER JOIN \"metadata\" ON \"metadata\".\"job_id\" = \"jobs\".\"id\"
+ AND \"metadata\".\"partition_id\" = \"jobs\".\"partition_id\"
+ WHERE \"pipelines\".\"project_id\" = 1
+ AND \"pipelines\".\"id\"
+ IN (#{pipeline.id}, #{other_pipeline.id})
+ AND \"pipelines\".\"partition_id\" = 100
+ SQL
+
+ result = QueryRecorder.log do
+ project
+ .pipelines
+ .includes(jobs: :metadata)
+ .references(:jobs, :metadata)
+ .where(id: [1, 2], partition_id: 100)
+ .to_a
+ end
+
+ expect(result).to include(preload_statement)
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb
new file mode 100644
index 00000000000..b035d7a6277
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::SingleModelQueries', :partitioning do
+ let(:project) { Project.create! }
+ let(:pipeline) { Pipeline.create!(project: project, partition_id: 100) }
+ let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) }
+
+ it 'creates using id and partition_id' do
+ create_statement = <<~SQL.squish
+ INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\")
+ VALUES (#{pipeline.id}, #{pipeline.partition_id})
+ SQL
+
+ result = QueryRecorder.log do
+ Job.create!(pipeline_id: pipeline.id, partition_id: pipeline.partition_id)
+ end
+
+ expect(result).to include(create_statement)
+ end
+
+ it 'finds with id and partition_id' do
+ find_statement = <<~SQL.squish
+ SELECT \"jobs\".*
+ FROM \"jobs\"
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ LIMIT 1
+ SQL
+
+ result = QueryRecorder.log do
+ Job.find_by!(id: job.id, partition_id: job.partition_id)
+ end
+
+ expect(result).to include(find_statement)
+ end
+
+ it 'saves using id and partition_id' do
+ update_statement = <<~SQL.squish
+ UPDATE \"jobs\"
+ SET \"name\" = 'test'
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ job.name = 'test'
+
+ job.save!
+ end
+
+ expect(result).to include(update_statement)
+ end
+
+ it 'updates using id and partition_id' do
+ update_statement = <<~SQL.squish
+ UPDATE \"jobs\"
+ SET \"name\" = 'test2'
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ job.update!(name: 'test2')
+ end
+
+ expect(result).to include(update_statement)
+ end
+
+ it 'deletes using id and partition_id' do
+ delete_statement = <<~SQL.squish
+ DELETE FROM \"jobs\"
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ job.delete
+ end
+
+ expect(result).to include(delete_statement)
+ end
+
+ it 'destroys using id and partition_id' do
+ destroy_statement = <<~SQL.squish
+ DELETE FROM \"jobs\"
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ job.destroy
+ end
+
+ expect(result).to include(destroy_statement)
+ end
+
+ it 'destroy_all using partition_id' do
+ destroy_statement = <<~SQL.squish
+ DELETE FROM \"jobs\"
+ WHERE \"jobs\".\"id\" = #{job.id}
+ AND \"jobs\".\"partition_id\" = #{job.partition_id}
+ SQL
+
+ result = QueryRecorder.log do
+ Job.where(id: job.id).destroy_all # rubocop: disable Cop/DestroyAll
+ end
+
+ expect(result).to include(destroy_statement)
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb
new file mode 100644
index 00000000000..c1537c3bd90
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+RSpec.describe ActiveRecord::GitlabPatches::RescueFrom, :without_sqlite3 do
+ let(:model_with_rescue_from) do
+ Class.new(ActiveRecord::Base) do
+ rescue_from ActiveRecord::ConnectionNotEstablished, with: :handle_exception
+
+ class << self
+ def handle_exception(exception); end
+ end
+ end
+ end
+
+ let(:model_without_rescue_from) do
+ Class.new(ActiveRecord::Base)
+ end
+
+ it 'triggers rescue_from' do
+ stub_const('ModelWithRescueFrom', model_with_rescue_from)
+
+ expect(model_with_rescue_from).to receive(:handle_exception)
+
+ expect { model_with_rescue_from.all.load }.not_to raise_error
+ end
+
+ it 'does not trigger rescue_from' do
+ stub_const('ModelWithoutRescueFrom', model_without_rescue_from)
+
+ expect { model_without_rescue_from.all.load }.to raise_error(ActiveRecord::ConnectionNotEstablished)
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches_spec.rb
new file mode 100644
index 00000000000..2e029a6adbf
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+RSpec.describe ActiveRecord::GitlabPatches do
+ it "has a version number" do
+ expect(described_class::Version::VERSION).not_to be nil
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/spec_helper.rb b/gems/activerecord-gitlab/spec/spec_helper.rb
new file mode 100644
index 00000000000..548295b3f2c
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/spec_helper.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+ENV["RAILS_ENV"] = "test"
+
+require "active_record/gitlab_patches"
+
+RSpec.configure do |config|
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+
+ Dir[File.expand_path("spec/support/**/*.rb")].each { |f| require f }
+
+ config.around(:all, :partitioning) do |example|
+ ActiveRecord::Base.transaction do
+ example.run
+
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ config.before(:all, :without_sqlite3) do
+ ActiveRecord::Base.remove_connection
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/support/database.rb b/gems/activerecord-gitlab/spec/support/database.rb
new file mode 100644
index 00000000000..998d945c311
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/support/database.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ config.before(:all) do
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
+ ActiveRecord::Base.logger = Logger.new('/dev/null')
+
+ ActiveRecord::Schema.define do
+ create_table :projects, force: true
+
+ create_table :pipelines, force: true do |t|
+ t.integer :project_id
+ t.integer :partition_id
+ end
+
+ create_table :jobs, force: true do |t|
+ t.integer :pipeline_id
+ t.integer :partition_id
+ t.string :name
+ end
+
+ create_table :metadata, force: true do |t|
+ t.integer :job_id
+ t.integer :partition_id
+ t.boolean :test_flag, default: false
+ end
+ end
+ end
+end
diff --git a/gems/activerecord-gitlab/spec/support/models.rb b/gems/activerecord-gitlab/spec/support/models.rb
new file mode 100644
index 00000000000..c0017656ea8
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/support/models.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class PartitionedRecord < ActiveRecord::Base
+ self.abstract_class = true
+
+ def self.use_partition_id_filter?
+ true
+ end
+
+ alias_method :reset, :reload
+end
+
+class Project < ActiveRecord::Base
+ has_many :pipelines
+end
+
+class Pipeline < PartitionedRecord
+ belongs_to :project
+ query_constraints :id, :partition_id
+
+ has_many :jobs,
+ ->(pipeline) { where(partition_id: pipeline.partition_id) },
+ partition_foreign_key: :partition_id,
+ dependent: :destroy
+end
+
+class Job < PartitionedRecord
+ query_constraints :id, :partition_id
+
+ belongs_to :pipeline,
+ ->(build) { where(partition_id: build.partition_id) },
+ partition_foreign_key: :partition_id
+
+ has_one :metadata,
+ ->(build) { where(partition_id: build.partition_id) },
+ foreign_key: :job_id,
+ partition_foreign_key: :partition_id,
+ inverse_of: :job,
+ autosave: true
+
+ accepts_nested_attributes_for :metadata
+end
+
+class Metadata < PartitionedRecord
+ self.table_name = :metadata
+ query_constraints :id, :partition_id
+
+ belongs_to :job,
+ ->(metadata) { where(partition_id: metadata.partition_id) }
+end
diff --git a/gems/activerecord-gitlab/spec/support/query_recorder.rb b/gems/activerecord-gitlab/spec/support/query_recorder.rb
new file mode 100644
index 00000000000..5129bae9240
--- /dev/null
+++ b/gems/activerecord-gitlab/spec/support/query_recorder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class QueryRecorder
+ attr_reader :log
+
+ def initialize(&block)
+ @log = []
+
+ ActiveRecord::Base.connection.unprepared_statement do
+ ActiveSupport::Notifications.subscribed(method(:callback), 'sql.active_record', &block)
+ end
+ end
+
+ def callback(_name, _start, _finish, _message_id, values)
+ @log << values[:sql]
+ end
+
+ def self.log(&block)
+ new(&block).log
+ end
+end
diff --git a/gems/click_house-client/.gitlab-ci.yml b/gems/click_house-client/.gitlab-ci.yml
new file mode 100644
index 00000000000..0384da8a893
--- /dev/null
+++ b/gems/click_house-client/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "click_house-client"
diff --git a/gems/click_house-client/.rspec b/gems/click_house-client/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/click_house-client/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/click_house-client/.rubocop.yml b/gems/click_house-client/.rubocop.yml
new file mode 100644
index 00000000000..8c670b439d3
--- /dev/null
+++ b/gems/click_house-client/.rubocop.yml
@@ -0,0 +1,2 @@
+inherit_from:
+ - ../config/rubocop.yml
diff --git a/gems/click_house-client/Gemfile b/gems/click_house-client/Gemfile
new file mode 100644
index 00000000000..be173b205f7
--- /dev/null
+++ b/gems/click_house-client/Gemfile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gemspec
diff --git a/gems/click_house-client/Gemfile.lock b/gems/click_house-client/Gemfile.lock
new file mode 100644
index 00000000000..a8e2b7ec0c6
--- /dev/null
+++ b/gems/click_house-client/Gemfile.lock
@@ -0,0 +1,102 @@
+PATH
+ remote: .
+ specs:
+ click_house-client (0.1.0)
+ activesupport (< 8)
+ addressable (~> 2.8)
+ json (~> 2.6.3)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (7.0.6)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ addressable (2.8.4)
+ public_suffix (>= 2.0.2, < 6.0)
+ ast (2.4.2)
+ concurrent-ruby (1.2.2)
+ diff-lcs (1.5.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ minitest (5.18.1)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ public_suffix (5.0.3)
+ racc (1.7.1)
+ rack (3.0.8)
+ rainbow (3.1.1)
+ rake (13.0.6)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-core (3.12.2)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-support (3.12.1)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ click_house-client!
+ gitlab-styles (~> 10.1.0)
+ rake (~> 13.0)
+ rspec (~> 3.0)
+ rubocop
+ rubocop-rspec
+
+BUNDLED WITH
+ 2.4.16
diff --git a/gems/click_house-client/README.md b/gems/click_house-client/README.md
new file mode 100644
index 00000000000..6afabcad1a0
--- /dev/null
+++ b/gems/click_house-client/README.md
@@ -0,0 +1,3 @@
+ClickHouse::Client
+
+This Gem provides a simple way to query ClickHouse databases using the HTTP interface.
diff --git a/gems/click_house-client/click_house-client.gemspec b/gems/click_house-client/click_house-client.gemspec
new file mode 100644
index 00000000000..5544065ef17
--- /dev/null
+++ b/gems/click_house-client/click_house-client.gemspec
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+Gem::Specification.new do |spec|
+ spec.name = "click_house-client"
+ spec.version = "0.1.0"
+ spec.authors = ["group::optimize"]
+ spec.email = ["engineering@gitlab.com"]
+
+ spec.summary = "GitLab's client to interact with ClickHouse"
+ spec.description = "This Gem provides a simple way to query ClickHouse databases using the HTTP interface."
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/click_house-client"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.0"
+
+ spec.add_runtime_dependency "activesupport", "< 8"
+ spec.add_runtime_dependency "addressable", "~> 2.8"
+ spec.add_runtime_dependency 'json', '~> 2.6.3'
+
+ spec.add_development_dependency 'gitlab-styles', '~> 10.1.0'
+ spec.add_development_dependency "rake", "~> 13.0"
+ spec.add_development_dependency "rspec", "~> 3.0"
+ spec.add_development_dependency 'rubocop'
+ spec.add_development_dependency 'rubocop-rspec'
+end
diff --git a/gems/click_house-client/lib/click_house/client.rb b/gems/click_house-client/lib/click_house/client.rb
new file mode 100644
index 00000000000..22c42d7be6e
--- /dev/null
+++ b/gems/click_house-client/lib/click_house/client.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'addressable'
+require 'json'
+require 'active_support/time'
+require_relative "client/database"
+require_relative "client/configuration"
+require_relative "client/formatter"
+require_relative "client/response"
+
+module ClickHouse
+ module Client
+ class << self
+ def configuration
+ @configuration ||= Configuration.new
+ end
+
+ def configure
+ yield(configuration)
+ configuration.validate!
+ end
+ end
+
+ Error = Class.new(StandardError)
+ ConfigurationError = Class.new(Error)
+ DatabaseError = Class.new(Error)
+
+ # Executes a SELECT database query
+ def self.select(query, database, configuration = self.configuration)
+ db = lookup_database(configuration, database)
+
+ response = configuration.http_post_proc.call(
+ db.uri.to_s,
+ db.headers,
+ "#{query} FORMAT JSON" # always return JSON
+ )
+
+ raise DatabaseError, response.body unless response.success?
+
+ Formatter.format(configuration.json_parser.parse(response.body))
+ end
+
+ # Executes any kinds of database query without returning any data (INSERT, DELETE)
+ def self.execute(query, database, configuration = self.configuration)
+ db = lookup_database(configuration, database)
+
+ response = configuration.http_post_proc.call(
+ db.uri.to_s,
+ db.headers,
+ query
+ )
+
+ raise DatabaseError, response.body unless response.success?
+
+ true
+ end
+
+ private_class_method def self.lookup_database(configuration, database)
+ configuration.databases[database].tap do |db|
+ raise ConfigurationError, "The database '#{database}' is not configured" unless db
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/lib/click_house/client/configuration.rb b/gems/click_house-client/lib/click_house/client/configuration.rb
new file mode 100644
index 00000000000..882b37993dc
--- /dev/null
+++ b/gems/click_house-client/lib/click_house/client/configuration.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ module Client
+ class Configuration
+ # Configuration options:
+ #
+ # *register_database* (method): registers a database, the following arguments are required:
+ # - database: database name
+ # - url: URL and port to the HTTP interface
+ # - username
+ # - password
+ # - variables (optional): configuration for the client
+ #
+ # *http_post_proc*: A callable object for invoking the HTTP request.
+ # The object must handle the following parameters: url, headers, body
+ # and return a Gitlab::ClickHouse::Client::Response object.
+ #
+ # *json_parser*: object for parsing JSON strings, it should respond to the "parse" method
+ #
+ # Example:
+ #
+ # Gitlab::ClickHouse::Client.configure do |c|
+ # c.register_database(:main,
+ # database: 'gitlab_clickhouse_test',
+ # url: 'http://localhost:8123',
+ # username: 'default',
+ # password: 'clickhouse',
+ # variables: {
+ # join_use_nulls: 1 # treat JOINs as per SQL standard
+ # }
+ # )
+ #
+ # c.http_post_proc = lambda do |url, headers, body|
+ # options = {
+ # headers: headers,
+ # body: body,
+ # allow_local_requests: false
+ # }
+ #
+ # response = Gitlab::HTTP.post(url, options)
+ # Gitlab::ClickHouse::Client::Response.new(response.body, response.code)
+ # end
+ #
+ # c.json_parser = JSON
+ # end
+ attr_accessor :http_post_proc, :json_parser
+ attr_reader :databases
+
+ def initialize
+ @databases = {}
+ @http_post_proc = nil
+ @json_parser = JSON
+ end
+
+ def register_database(name, **args)
+ raise ConfigurationError, "The database '#{name}' is already registered" if @databases.key?(name)
+
+ @databases[name] = Database.new(**args)
+ end
+
+ def validate!
+ raise ConfigurationError, "The 'http_post_proc' option is not configured" unless @http_post_proc
+ raise ConfigurationError, "The 'json_parser' option is not configured" unless @json_parser
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/lib/click_house/client/database.rb b/gems/click_house-client/lib/click_house/client/database.rb
new file mode 100644
index 00000000000..beeb2a8cbd6
--- /dev/null
+++ b/gems/click_house-client/lib/click_house/client/database.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ module Client
+ class Database
+ attr_reader :database
+
+ def initialize(database:, url:, username:, password:, variables: {})
+ @database = database
+ @url = url
+ @username = username
+ @password = password
+ @variables = variables.merge(database: database).freeze
+ end
+
+ def uri
+ @uri ||= begin
+ parsed = Addressable::URI.parse(@url)
+ parsed.query_values = @variables
+ parsed
+ end
+ end
+
+ def headers
+ @headers ||= {
+ 'X-ClickHouse-User' => @username,
+ 'X-ClickHouse-Key' => @password
+ }.freeze
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/lib/click_house/client/formatter.rb b/gems/click_house-client/lib/click_house/client/formatter.rb
new file mode 100644
index 00000000000..bb60d8db7f7
--- /dev/null
+++ b/gems/click_house-client/lib/click_house/client/formatter.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ module Client
+ class Formatter
+ DEFAULT = ->(value) { value }
+
+ TYPE_CASTERS = {
+ 'UInt64' => ->(value) { Integer(value) },
+ "DateTime64(6, 'UTC')" => ->(value) { ActiveSupport::TimeZone["UTC"].parse(value) }
+ }.freeze
+
+ def self.format(result)
+ name_type_mapping = result['meta'].each_with_object({}) do |column, hash|
+ hash[column['name']] = column['type']
+ end
+
+ result['data'].map do |row|
+ row.each_with_object({}) do |(column, value), casted_row|
+ caster = TYPE_CASTERS.fetch(name_type_mapping[column], DEFAULT)
+
+ casted_row[column] = caster.call(value)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/lib/click_house/client/response.rb b/gems/click_house-client/lib/click_house/client/response.rb
new file mode 100644
index 00000000000..898f0b0e024
--- /dev/null
+++ b/gems/click_house-client/lib/click_house/client/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ module Client
+ class Response
+ attr_reader :body
+
+ def initialize(body, http_status_code)
+ @body = body
+ @http_status_code = http_status_code
+ end
+
+ def success?
+ @http_status_code == 200
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/spec/click_house/client/configuration_spec.rb b/gems/click_house-client/spec/click_house/client/configuration_spec.rb
new file mode 100644
index 00000000000..8cbd64ca650
--- /dev/null
+++ b/gems/click_house-client/spec/click_house/client/configuration_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ClickHouse::Client::Configuration do
+ subject(:configuration) do
+ config = described_class.new
+ config.http_post_proc = -> {}
+ config.json_parser = Object
+ config
+ end
+
+ describe '#register_database' do
+ let(:database_options) do
+ {
+ database: 'test_db',
+ url: 'http://localhost:3333',
+ username: 'user',
+ password: 'pass',
+ variables: {
+ join_use_nulls: 1
+ }
+ }
+ end
+
+ it 'registers a database' do
+ configuration.register_database(:my_db, **database_options)
+
+ expect(configuration.databases.size).to eq(1)
+ database = configuration.databases[:my_db]
+
+ expect(database.uri.to_s).to eq('http://localhost:3333?database=test_db&join_use_nulls=1')
+ end
+
+ context 'when adding the same DB multiple times' do
+ it 'raises error' do
+ configuration.register_database(:my_db, **database_options)
+ expect do
+ configuration.register_database(:my_db, **database_options)
+ end.to raise_error(ClickHouse::Client::ConfigurationError, /database 'my_db' is already registered/)
+ end
+ end
+ end
+
+ describe '#validate!' do
+ context 'when `http_post_proc` option is not configured' do
+ it 'raises error' do
+ configuration.http_post_proc = nil
+
+ expect do
+ configuration.validate!
+ end.to raise_error(ClickHouse::Client::ConfigurationError, /'http_post_proc' option is not configured/)
+ end
+ end
+
+ context 'when `json_parser` option is not configured' do
+ it 'raises error' do
+ configuration.json_parser = nil
+
+ expect do
+ configuration.validate!
+ end.to raise_error(ClickHouse::Client::ConfigurationError, /'json_parser' option is not configured/)
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/spec/click_house/client/database_spec.rb b/gems/click_house-client/spec/click_house/client/database_spec.rb
new file mode 100644
index 00000000000..112b2ee12b1
--- /dev/null
+++ b/gems/click_house-client/spec/click_house/client/database_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ClickHouse::Client::Database do
+ subject(:database) do
+ described_class.new(
+ database: 'test_db',
+ url: 'http://localhost:3333',
+ username: 'user',
+ password: 'pass',
+ variables: {
+ join_use_nulls: 1
+ }
+ )
+ end
+
+ describe '#uri' do
+ it 'builds the correct URL' do
+ expect(database.uri.to_s).to eq('http://localhost:3333?database=test_db&join_use_nulls=1')
+ end
+ end
+
+ describe '#headers' do
+ it 'returns the correct headers' do
+ expect(database.headers).to eq({
+ 'X-ClickHouse-User' => 'user',
+ 'X-ClickHouse-Key' => 'pass'
+ })
+ end
+ end
+end
diff --git a/gems/click_house-client/spec/click_house/client_spec.rb b/gems/click_house-client/spec/click_house/client_spec.rb
new file mode 100644
index 00000000000..883199198ba
--- /dev/null
+++ b/gems/click_house-client/spec/click_house/client_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+RSpec.describe ClickHouse::Client do
+ describe '#select' do
+ # Assuming we have a DB table with the following schema
+ #
+ # CREATE TABLE issues (
+ # `id` UInt64,
+ # `title` String DEFAULT '',
+ # `description` Nullable(String),
+ # `created_at` DateTime64(6, 'UTC') DEFAULT now(),
+ # `updated_at` DateTime64(6, 'UTC') DEFAULT now()
+ # )
+ # ENGINE = ReplacingMergeTree(updated_at)
+ # ORDER BY (id)
+
+ let(:query_result_fixture) { File.expand_path('../fixtures/query_result.json', __dir__) }
+
+ let(:database_config) do
+ {
+ database: 'test_db',
+ url: 'http://localhost:3333',
+ username: 'user',
+ password: 'pass',
+ variables: {
+ join_use_nulls: 1
+ }
+ }
+ end
+
+ let(:configuration) do
+ ClickHouse::Client::Configuration.new.tap do |config|
+ config.register_database(:test_db, **database_config)
+ config.http_post_proc = ->(_url, _headers, _query) {
+ body = File.read(query_result_fixture)
+ ClickHouse::Client::Response.new(body, 200)
+ }
+ end
+ end
+
+ it 'parses the results and returns the data as array of hashes' do
+ result = described_class.select('SELECT * FROM issues', :test_db, configuration)
+
+ timestamp1 = ActiveSupport::TimeZone["UTC"].parse('2023-06-21 13:33:44')
+ timestamp2 = ActiveSupport::TimeZone["UTC"].parse('2023-06-21 13:33:50')
+ timestamp3 = ActiveSupport::TimeZone["UTC"].parse('2023-06-21 13:33:40')
+
+ expect(result).to eq([
+ {
+ 'id' => 2,
+ 'title' => 'Title 2',
+ 'description' => 'description',
+ 'created_at' => timestamp1,
+ 'updated_at' => timestamp1
+ },
+ {
+ 'id' => 3,
+ 'title' => 'Title 3',
+ 'description' => nil,
+ 'created_at' => timestamp2,
+ 'updated_at' => timestamp2
+ },
+ {
+ 'id' => 1,
+ 'title' => 'Title 1',
+ 'description' => 'description',
+ 'created_at' => timestamp3,
+ 'updated_at' => timestamp3
+ }
+ ])
+ end
+
+ context 'when the DB is not configured' do
+ it 'raises erro' do
+ expect do
+ described_class.select('SELECT * FROM issues', :different_db, configuration)
+ end.to raise_error(ClickHouse::Client::ConfigurationError, /not configured/)
+ end
+ end
+
+ context 'when error response is returned' do
+ let(:configuration) do
+ ClickHouse::Client::Configuration.new.tap do |config|
+ config.register_database(:test_db, **database_config)
+ config.http_post_proc = ->(_url, _headers, _query) {
+ ClickHouse::Client::Response.new('some error', 404)
+ }
+ end
+ end
+
+ it 'raises error' do
+ expect do
+ described_class.select('SELECT * FROM issues', :test_db, configuration)
+ end.to raise_error(ClickHouse::Client::DatabaseError, 'some error')
+ end
+ end
+ end
+end
diff --git a/gems/click_house-client/spec/fixtures/query_result.json b/gems/click_house-client/spec/fixtures/query_result.json
new file mode 100644
index 00000000000..872b0e5b6ed
--- /dev/null
+++ b/gems/click_house-client/spec/fixtures/query_result.json
@@ -0,0 +1,53 @@
+{
+ "meta": [
+ {
+ "name": "id",
+ "type": "UInt64"
+ },
+ {
+ "name": "title",
+ "type:": "String"
+ },
+ {
+ "name": "description",
+ "type": "Nullable(String)"
+ },
+ {
+ "name": "created_at",
+ "type": "DateTime64(6, 'UTC')"
+ },
+ {
+ "name": "updated_at",
+ "type": "DateTime64(6, 'UTC')"
+ }
+ ],
+ "data": [
+ {
+ "id": "2",
+ "title": "Title 2",
+ "description": "description",
+ "created_at": "2023-06-21 13:33:44.000000",
+ "updated_at": "2023-06-21 13:33:44.000000"
+ },
+ {
+ "id": "3",
+ "title": "Title 3",
+ "description": null,
+ "created_at": "2023-06-21 13:33:50.000000",
+ "updated_at": "2023-06-21 13:33:50.000000"
+ },
+ {
+ "id": "1",
+ "title": "Title 1",
+ "description": "description",
+ "created_at": "2023-06-21 13:33:40.000000",
+ "updated_at": "2023-06-21 13:33:40.000000"
+ }
+ ],
+ "rows": 3,
+ "statistics": {
+ "elapsed": 0.001581789,
+ "rows_read": 3,
+ "bytes_read": 172
+ }
+}
diff --git a/gems/click_house-client/spec/spec_helper.rb b/gems/click_house-client/spec/spec_helper.rb
new file mode 100644
index 00000000000..d2b5e59c9a3
--- /dev/null
+++ b/gems/click_house-client/spec/spec_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require "click_house/client"
+
+RSpec.configure do |config|
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end
diff --git a/gems/config/rubocop.yml b/gems/config/rubocop.yml
new file mode 100644
index 00000000000..a97d759b471
--- /dev/null
+++ b/gems/config/rubocop.yml
@@ -0,0 +1,98 @@
+inherit_gem:
+ gitlab-styles:
+ - rubocop-default.yml
+
+require:
+ - ../../rubocop/rubocop
+ - rubocop-rspec
+
+inherit_mode:
+ merge:
+ - Include
+ - Exclude
+ - AllowedPatterns
+
+AllCops:
+ # Target the current Ruby version. For example, "3.0" or "3.1".
+ TargetRubyVersion: <%= RUBY_VERSION[/^\d+\.\d+/, 0] %>
+ SuggestExtensions: false
+ NewCops: disable
+
+# This cop doesn't make sense in the context of gems
+CodeReuse/ActiveRecord:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Cop/PutGroupRoutesUnderScope:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Cop/PutProjectRoutesUnderScope:
+ Enabled: false
+
+Gemspec/AvoidExecutingGit:
+ Enabled: true
+
+# We disable this since we support multiple Ruby versions
+Gemspec/RequiredRubyVersion:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Gitlab/DocUrl:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Gitlab/NamespacedClass:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Gitlab/RSpec/AvoidSetup:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Graphql/AuthorizeTypes:
+ Enabled: false
+
+# This cop doesn't make sense in the context of gems
+Graphql/Descriptions:
+ Enabled: false
+
+Naming/FileName:
+ Exclude:
+ - spec/**/*.rb
+
+# This cop doesn't make sense in the context of gems
+RSpec/AvoidConditionalStatements:
+ Enabled: false
+
+RSpec/ContextWording:
+ Prefixes:
+ - 'when'
+ - 'with'
+ - 'without'
+ - 'for'
+ - 'and'
+ - 'on'
+ - 'in'
+ - 'as'
+ - 'if'
+
+# This cop doesn't make sense in the context of gems
+RSpec/MissingFeatureCategory:
+ Enabled: false
+
+# Enable once we drop 3.0 support
+Style/HashSyntax:
+ Enabled: false
+
+Style/Lambda:
+ EnforcedStyle: literal
+
+Style/RegexpLiteralMixedPreserve:
+ Enabled: true
+ SupportedStyles:
+ - slashes
+ - percent_r
+ - mixed
+ - mixed_preserve
+ EnforcedStyle: mixed_preserve
diff --git a/gems/gem.gitlab-ci.yml b/gems/gem.gitlab-ci.yml
new file mode 100644
index 00000000000..10905d8c243
--- /dev/null
+++ b/gems/gem.gitlab-ci.yml
@@ -0,0 +1,66 @@
+# The template generates jobs for gems vendored in the main GitLab project
+# under `gem_path_prefix` (defaults to `gems/`).
+#
+# Inputs
+# - `gem_name`: The name of the gem, i.e. if the gem is located at `gems/gitlab-rspec`, `gem_name` should be set to `gitlab-rspec`.
+# - `gem_path_prefix`: The prefix of the gem path, i.e. if the gem is located at `vendor/gems/gitlab-rspec`, `gem_path_prefix` should be set to `vendor/gems/`. Defaults to `gems/`.
+spec:
+ inputs:
+ gem_name:
+ gem_path_prefix:
+ default: "gems/"
+---
+workflow:
+ name: '$PIPELINE_NAME'
+ rules:
+ - if: $CI_MERGE_REQUEST_ID
+ variables:
+ PIPELINE_NAME: '[$[[inputs.gem_name]] gem] Ruby $RUBY_VERSION $CI_MERGE_REQUEST_EVENT_TYPE MR pipeline'
+
+variables:
+ BUNDLE_PATH: "vendor"
+ BUNDLE_FROZEN: "true"
+ GIT_DEPTH: "20"
+ # 'GIT_STRATEGY: clone' optimizes the pack-objects cache hit ratio
+ GIT_STRATEGY: "clone"
+ GIT_SUBMODULE_STRATEGY: "none"
+ GET_SOURCES_ATTEMPTS: "3"
+ # Default Ruby version for jobs that don't use .ruby_matrix
+ RUBY_VERSION: "3.0"
+
+default:
+ image: "ruby:${RUBY_VERSION}"
+ cache:
+ key: "$[[inputs.gem_name]]-${RUBY_VERSION}"
+ paths:
+ - "$[[inputs.gem_path_prefix]]$[[inputs.gem_name]]/vendor/ruby"
+ before_script:
+ - cd $[[inputs.gem_path_prefix]]$[[inputs.gem_name]]
+ - ruby -v # Print out ruby version for debugging
+ - bundle_version=$(grep -A 1 "BUNDLED WITH" Gemfile.lock | tail -n 1 | sed -e 's/[[:space:]]//')
+ - gem install bundler --version "$bundle_version" --no-document # Bundler is not installed with the image
+ - bundle config # Show bundler configuration
+ - bundle install --jobs=$(nproc) --retry=3
+
+.ruby_matrix:
+ parallel:
+ matrix:
+ - RUBY_VERSION: ["3.0", "3.1", "3.2"]
+
+rubocop:
+ extends: .ruby_matrix
+ rules:
+ - exists: ["$[[inputs.gem_path_prefix]]$[[inputs.gem_name]]/.rubocop.yml"]
+ script:
+ - bundle exec rubocop --config .rubocop.yml
+
+rspec:
+ extends: .ruby_matrix
+ script:
+ - bundle exec rspec
+ coverage: '/LOC \((\d+\.\d+%)\) covered.$/'
+ artifacts:
+ expire_in: 31d
+ when: always
+ paths:
+ - coverage/
diff --git a/gems/gitlab-rspec/.gitignore b/gems/gitlab-rspec/.gitignore
new file mode 100644
index 00000000000..b04a8c840df
--- /dev/null
+++ b/gems/gitlab-rspec/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/gems/gitlab-rspec/.gitlab-ci.yml b/gems/gitlab-rspec/.gitlab-ci.yml
new file mode 100644
index 00000000000..910b391a0e9
--- /dev/null
+++ b/gems/gitlab-rspec/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "gitlab-rspec"
diff --git a/gems/gitlab-rspec/.rspec b/gems/gitlab-rspec/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/gitlab-rspec/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/gitlab-rspec/.rubocop.yml b/gems/gitlab-rspec/.rubocop.yml
new file mode 100644
index 00000000000..38c0c592dad
--- /dev/null
+++ b/gems/gitlab-rspec/.rubocop.yml
@@ -0,0 +1,10 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+RSpec/InstanceVariable:
+ Exclude:
+ - spec/**/*.rb
+
+Gitlab/ChangeTimezone:
+ Exclude:
+ - spec/gitlab/rspec/time_travel_spec.rb
diff --git a/gems/gitlab-rspec/Gemfile b/gems/gitlab-rspec/Gemfile
new file mode 100644
index 00000000000..be173b205f7
--- /dev/null
+++ b/gems/gitlab-rspec/Gemfile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gemspec
diff --git a/gems/gitlab-rspec/Gemfile.lock b/gems/gitlab-rspec/Gemfile.lock
new file mode 100644
index 00000000000..dcdb4dd009e
--- /dev/null
+++ b/gems/gitlab-rspec/Gemfile.lock
@@ -0,0 +1,182 @@
+PATH
+ remote: .
+ specs:
+ gitlab-rspec (0.1.0)
+ activesupport (>= 6.1, < 7.1)
+ rspec (~> 3.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actionpack (7.0.4.3)
+ actionview (= 7.0.4.3)
+ activesupport (= 7.0.4.3)
+ rack (~> 2.0, >= 2.2.0)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
+ actionview (7.0.4.3)
+ activesupport (= 7.0.4.3)
+ builder (~> 3.1)
+ erubi (~> 1.4)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
+ activesupport (7.0.4.3)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ ast (2.4.2)
+ benchmark-malloc (0.2.0)
+ benchmark-perf (0.6.0)
+ benchmark-trend (0.4.0)
+ binding_of_caller (1.0.0)
+ debug_inspector (>= 0.0.1)
+ builder (3.2.4)
+ coderay (1.1.3)
+ concurrent-ruby (1.2.2)
+ crass (1.0.6)
+ debug_inspector (1.1.0)
+ diff-lcs (1.5.0)
+ erubi (1.12.0)
+ factory_bot (6.2.0)
+ activesupport (>= 5.0.0)
+ factory_bot_rails (6.2.0)
+ factory_bot (~> 6.2.0)
+ railties (>= 5.0.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ i18n (1.13.0)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ loofah (2.21.3)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ method_source (1.0.0)
+ mini_portile2 (2.8.2)
+ minitest (5.18.0)
+ nokogiri (1.15.2)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ parallel (1.23.0)
+ parser (3.2.2.1)
+ ast (~> 2.4.1)
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ racc (1.6.2)
+ rack (2.2.7)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rails-dom-testing (2.0.3)
+ activesupport (>= 4.2.0)
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.5.0)
+ loofah (~> 2.19, >= 2.19.1)
+ railties (7.0.4.3)
+ actionpack (= 7.0.4.3)
+ activesupport (= 7.0.4.3)
+ method_source
+ rake (>= 12.2)
+ thor (~> 1.0)
+ zeitwerk (~> 2.5)
+ rainbow (3.1.1)
+ rake (13.0.6)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-benchmark (0.6.0)
+ benchmark-malloc (~> 0.2)
+ benchmark-perf (~> 0.6)
+ benchmark-trend (~> 0.4)
+ rspec (>= 3.0)
+ rspec-core (3.12.0)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.2)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-parameterized (1.0.0)
+ rspec-parameterized-core (< 2)
+ rspec-parameterized-table_syntax (< 2)
+ rspec-parameterized-core (1.0.0)
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-parameterized-table_syntax (1.0.0)
+ binding_of_caller
+ rspec-parameterized-core (< 2)
+ rspec-rails (6.0.2)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ railties (>= 6.1)
+ rspec-core (~> 3.12)
+ rspec-expectations (~> 3.12)
+ rspec-mocks (~> 3.12)
+ rspec-support (~> 3.12)
+ rspec-support (3.12.0)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ thor (1.2.2)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+ unparser (0.6.7)
+ diff-lcs (~> 1.3)
+ parser (>= 3.2.0)
+ zeitwerk (2.6.8)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ factory_bot_rails (~> 6.2.0)
+ gitlab-rspec!
+ gitlab-styles (~> 10.1.0)
+ rspec-benchmark (~> 0.6.0)
+ rspec-parameterized (~> 1.0)
+ rspec-rails (~> 6.0.1)
+ rubocop (~> 1.50)
+ rubocop-rspec (~> 2.22)
+
+BUNDLED WITH
+ 2.4.4
diff --git a/gems/gitlab-rspec/gitlab-rspec.gemspec b/gems/gitlab-rspec/gitlab-rspec.gemspec
new file mode 100644
index 00000000000..c2c5b6c60b7
--- /dev/null
+++ b/gems/gitlab-rspec/gitlab-rspec.gemspec
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative "lib/gitlab/rspec/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "gitlab-rspec"
+ spec.version = Gitlab::Rspec::Version::VERSION
+ spec.authors = ["group::tenant-scale"]
+ spec.email = ["engineering@gitlab.com"]
+
+ spec.summary = "GitLab RSpec extensions"
+ spec.description = "A set of useful helpers to configure RSpec with various stubs and CI configs."
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-rspec"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.0"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir["lib/**/*.rb"]
+ spec.require_paths = ["lib"]
+
+ spec.add_runtime_dependency "activesupport", ">= 6.1", "< 7.1"
+ spec.add_runtime_dependency "rspec", "~> 3.0"
+
+ spec.add_development_dependency "factory_bot_rails", "~> 6.2.0"
+ spec.add_development_dependency "gitlab-styles", "~> 10.1.0"
+ spec.add_development_dependency "rspec-benchmark", "~> 0.6.0"
+ spec.add_development_dependency "rspec-parameterized", "~> 1.0"
+ spec.add_development_dependency "rspec-rails", "~> 6.0.1"
+ spec.add_development_dependency "rubocop", "~> 1.50"
+ spec.add_development_dependency "rubocop-rspec", "~> 2.22"
+end
diff --git a/gems/gitlab-rspec/lib/gitlab/rspec.rb b/gems/gitlab-rspec/lib/gitlab/rspec.rb
new file mode 100644
index 00000000000..2d076e99614
--- /dev/null
+++ b/gems/gitlab-rspec/lib/gitlab/rspec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require "rspec"
+require_relative "rspec/version"
+
+module Gitlab
+ module Rspec
+ end
+end
diff --git a/gems/gitlab-rspec/lib/gitlab/rspec/all.rb b/gems/gitlab-rspec/lib/gitlab/rspec/all.rb
new file mode 100644
index 00000000000..091d2ba0287
--- /dev/null
+++ b/gems/gitlab-rspec/lib/gitlab/rspec/all.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require_relative "../rspec"
+require_relative "stub_env"
+
+require_relative "configurations/time_travel"
+
+Gitlab::Rspec::Configurations::TimeTravel.configure!
diff --git a/gems/gitlab-rspec/lib/gitlab/rspec/configurations/time_travel.rb b/gems/gitlab-rspec/lib/gitlab/rspec/configurations/time_travel.rb
new file mode 100644
index 00000000000..b30aa1cde0d
--- /dev/null
+++ b/gems/gitlab-rspec/lib/gitlab/rspec/configurations/time_travel.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'active_support/all'
+require 'active_support/testing/time_helpers'
+
+module Gitlab
+ module Rspec
+ module Configurations
+ class TimeTravel
+ def self.configure!
+ RSpec.configure do |config|
+ config.include ActiveSupport::Testing::TimeHelpers
+
+ config.around(:example, :freeze_time) do |example|
+ freeze_time { example.run }
+ end
+
+ config.around(:example, :time_travel_to) do |example|
+ date_or_time = example.metadata[:time_travel_to]
+
+ unless date_or_time.respond_to?(:to_time) && date_or_time.to_time.present?
+ raise 'The time_travel_to RSpec metadata must have a Date or Time value.'
+ end
+
+ travel_to(date_or_time) { example.run }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-rspec/lib/gitlab/rspec/stub_env.rb b/gems/gitlab-rspec/lib/gitlab/rspec/stub_env.rb
new file mode 100644
index 00000000000..f8775d9f7b5
--- /dev/null
+++ b/gems/gitlab-rspec/lib/gitlab/rspec/stub_env.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# Inspired by https://github.com/ljkbennett/stub_env/blob/master/lib/stub_env/helpers.rb
+module StubENV
+ # Stub ENV variables
+ #
+ # You can provide either a key and value as separate params or both in a Hash format
+ #
+ # Keys and values will always be converted to String, to comply with how ENV behaves
+ #
+ # @param key_or_hash [String, Hash<String,String>]
+ # @param value [String]
+ def stub_env(key_or_hash, value = nil)
+ init_stub unless env_stubbed?
+
+ if key_or_hash.is_a? Hash
+ key_or_hash.each do |key, value|
+ add_stubbed_value(key, ensure_env_type(value))
+ end
+ else
+ add_stubbed_value key_or_hash, ensure_env_type(value)
+ end
+ end
+
+ private
+
+ STUBBED_KEY = '__STUBBED__'
+
+ def add_stubbed_value(key, value)
+ allow(ENV).to receive(:[]).with(key).and_return(value)
+ allow(ENV).to receive(:key?).with(key).and_return(true)
+ allow(ENV).to receive(:fetch).with(key).and_return(value)
+ allow(ENV).to receive(:fetch).with(key, anything) do |_, default_val|
+ value || default_val
+ end
+ end
+
+ def env_stubbed?
+ ENV.fetch(STUBBED_KEY, false)
+ end
+
+ def init_stub
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:key?).and_call_original
+ allow(ENV).to receive(:fetch).and_call_original
+ add_stubbed_value(STUBBED_KEY, true)
+ end
+
+ def ensure_env_type(value)
+ value.nil? ? value : value.to_s
+ end
+end
diff --git a/gems/gitlab-rspec/lib/gitlab/rspec/version.rb b/gems/gitlab-rspec/lib/gitlab/rspec/version.rb
new file mode 100644
index 00000000000..34c51245012
--- /dev/null
+++ b/gems/gitlab-rspec/lib/gitlab/rspec/version.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Rspec
+ module Version
+ VERSION = "0.1.0"
+ end
+ end
+end
diff --git a/gems/gitlab-rspec/spec/gitlab/rspec/time_travel_spec.rb b/gems/gitlab-rspec/spec/gitlab/rspec/time_travel_spec.rb
new file mode 100644
index 00000000000..79804a99f70
--- /dev/null
+++ b/gems/gitlab-rspec/spec/gitlab/rspec/time_travel_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+RSpec.describe 'time travel' do
+ before(:all) do
+ @original_time_zone = Time.zone
+ Time.zone = 'Eastern Time (US & Canada)'
+ end
+
+ after(:all) do
+ Time.zone = @original_time_zone
+ end
+
+ describe ':freeze_time' do
+ it 'freezes time around a spec example', :freeze_time do
+ expect { sleep 0.1 }.not_to change { Time.now.to_f }
+ end
+ end
+
+ describe ':time_travel_to' do
+ it 'time-travels to the specified date', time_travel_to: '2020-01-01' do
+ expect(Date.current).to eq(Date.new(2020, 1, 1))
+ end
+
+ it 'time-travels to the specified date & time', time_travel_to: '2020-02-02 10:30:45 -0700' do
+ expect(Time.current).to eq(Time.new(2020, 2, 2, 17, 30, 45, '+00:00'))
+ end
+ end
+end
diff --git a/gems/gitlab-rspec/spec/spec_helper.rb b/gems/gitlab-rspec/spec/spec_helper.rb
new file mode 100644
index 00000000000..c694747e094
--- /dev/null
+++ b/gems/gitlab-rspec/spec/spec_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'gitlab/rspec/all'
+
+RSpec.configure do |config|
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end
diff --git a/gems/gitlab-schema-validation/.gitignore b/gems/gitlab-schema-validation/.gitignore
new file mode 100644
index 00000000000..b04a8c840df
--- /dev/null
+++ b/gems/gitlab-schema-validation/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/gems/gitlab-schema-validation/.gitlab-ci.yml b/gems/gitlab-schema-validation/.gitlab-ci.yml
new file mode 100644
index 00000000000..03db9e02b30
--- /dev/null
+++ b/gems/gitlab-schema-validation/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "gitlab-schema-validation"
diff --git a/gems/gitlab-schema-validation/.rspec b/gems/gitlab-schema-validation/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/gitlab-schema-validation/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/gitlab-schema-validation/.rubocop.yml b/gems/gitlab-schema-validation/.rubocop.yml
new file mode 100644
index 00000000000..1dc800520ca
--- /dev/null
+++ b/gems/gitlab-schema-validation/.rubocop.yml
@@ -0,0 +1,8 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+AllCops:
+ NewCops: enable
+
+RSpec/MultipleMemoizedHelpers:
+ Max: 25
diff --git a/gems/gitlab-schema-validation/Gemfile b/gems/gitlab-schema-validation/Gemfile
new file mode 100644
index 00000000000..3fa25adbee1
--- /dev/null
+++ b/gems/gitlab-schema-validation/Gemfile
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in gitlab-schema-validation.gemspec
+gemspec
diff --git a/gems/gitlab-schema-validation/Gemfile.lock b/gems/gitlab-schema-validation/Gemfile.lock
new file mode 100644
index 00000000000..5ad804d3660
--- /dev/null
+++ b/gems/gitlab-schema-validation/Gemfile.lock
@@ -0,0 +1,137 @@
+PATH
+ remote: .
+ specs:
+ gitlab-schema-validation (0.1.0)
+ diffy
+ pg_query
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (7.0.6)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ ast (2.4.2)
+ benchmark-malloc (0.2.0)
+ benchmark-perf (0.6.0)
+ benchmark-trend (0.4.0)
+ binding_of_caller (1.0.0)
+ debug_inspector (>= 0.0.1)
+ coderay (1.1.3)
+ concurrent-ruby (1.2.2)
+ debug_inspector (1.1.0)
+ diff-lcs (1.5.0)
+ diffy (3.4.2)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ google-protobuf (3.23.3)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ method_source (1.0.0)
+ minitest (5.18.1)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ pg_query (4.2.1)
+ google-protobuf (>= 3.22.3)
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ pry (0.14.2)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ racc (1.7.1)
+ rack (3.0.8)
+ rainbow (3.1.1)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-benchmark (0.6.0)
+ benchmark-malloc (~> 0.2)
+ benchmark-perf (~> 0.6)
+ benchmark-trend (~> 0.4)
+ rspec (>= 3.0)
+ rspec-core (3.12.2)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-parameterized (1.0.0)
+ rspec-parameterized-core (< 2)
+ rspec-parameterized-table_syntax (< 2)
+ rspec-parameterized-core (1.0.0)
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-parameterized-table_syntax (1.0.0)
+ binding_of_caller
+ rspec-parameterized-core (< 2)
+ rspec-support (3.12.1)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+ unparser (0.6.8)
+ diff-lcs (~> 1.3)
+ parser (>= 3.2.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ gitlab-schema-validation!
+ gitlab-styles (~> 10.1.0)
+ pry
+ rspec (~> 3.0)
+ rspec-benchmark (~> 0.6.0)
+ rspec-parameterized (~> 1.0)
+ rubocop (~> 1.50)
+ rubocop-rspec (~> 2.22)
+
+BUNDLED WITH
+ 2.4.14
diff --git a/gems/gitlab-schema-validation/gitlab-schema-validation.gemspec b/gems/gitlab-schema-validation/gitlab-schema-validation.gemspec
new file mode 100644
index 00000000000..47ca8b65b5d
--- /dev/null
+++ b/gems/gitlab-schema-validation/gitlab-schema-validation.gemspec
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require_relative "lib/gitlab/schema/validation/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "gitlab-schema-validation"
+ spec.version = Gitlab::Schema::Validation::Version::VERSION
+ spec.authors = ["group::database"]
+ spec.email = ["engineering@gitlab.com"]
+
+ spec.summary = "Schema validation framework"
+ spec.description = "Compares the differences between a structure.sql file and a database
+ and reports the inconsistencies."
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-schema-validation"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.0"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir['lib/**/*.rb']
+ spec.require_paths = ["lib"]
+
+ spec.add_runtime_dependency "diffy"
+ spec.add_runtime_dependency "pg_query"
+
+ spec.add_development_dependency "gitlab-styles", "~> 10.1.0"
+ spec.add_development_dependency "pry"
+ spec.add_development_dependency "rspec", "~> 3.0"
+ spec.add_development_dependency "rspec-benchmark", "~> 0.6.0"
+ spec.add_development_dependency "rspec-parameterized", "~> 1.0"
+ spec.add_development_dependency "rubocop", "~> 1.50"
+ spec.add_development_dependency "rubocop-rspec", "~> 2.22"
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation.rb
new file mode 100644
index 00000000000..5211358a197
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'pg_query'
+require 'diffy'
+
+require_relative 'validation/version'
+require_relative 'validation/inconsistency'
+require_relative 'validation/pg_types'
+
+require_relative 'validation/validators/base'
+
+require_relative 'validation/validators/different_definition_indexes'
+require_relative 'validation/validators/extra_indexes'
+require_relative 'validation/validators/missing_indexes'
+
+require_relative 'validation/validators/extra_table_columns'
+require_relative 'validation/validators/missing_table_columns'
+
+require_relative 'validation/validators/different_definition_foreign_keys'
+require_relative 'validation/validators/extra_foreign_keys'
+require_relative 'validation/validators/missing_foreign_keys'
+
+require_relative 'validation/validators/different_definition_tables'
+require_relative 'validation/validators/extra_tables'
+require_relative 'validation/validators/missing_tables'
+
+require_relative 'validation/validators/different_definition_triggers'
+require_relative 'validation/validators/extra_triggers'
+require_relative 'validation/validators/missing_triggers'
+
+require_relative 'validation/sources/structure_sql'
+require_relative 'validation/sources/database'
+
+require_relative 'validation/schema_objects/base'
+require_relative 'validation/schema_objects/column'
+require_relative 'validation/schema_objects/index'
+require_relative 'validation/schema_objects/table'
+require_relative 'validation/schema_objects/trigger'
+require_relative 'validation/schema_objects/foreign_key'
+
+require_relative 'validation/adapters/column_database_adapter'
+require_relative 'validation/adapters/column_structure_sql_adapter'
+require_relative 'validation/adapters/foreign_key_database_adapter'
+require_relative 'validation/adapters/foreign_key_structure_sql_adapter'
+
+module Gitlab
+ module Schema
+ module Validation
+ class Runner
+ def initialize(structure_sql, database, validators:)
+ @structure_sql = structure_sql
+ @database = database
+ @validators = validators
+ end
+
+ def execute
+ validators.flat_map { |c| c.new(structure_sql, database).execute }
+ end
+
+ private
+
+ attr_reader :structure_sql, :database, :validators
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_database_adapter.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_database_adapter.rb
new file mode 100644
index 00000000000..8b4d07d2e79
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_database_adapter.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Adapters
+ class ColumnDatabaseAdapter
+ def initialize(query_result)
+ @query_result = query_result
+ end
+
+ def name
+ @name ||= query_result['column_name']
+ end
+
+ def table_name
+ query_result['table_name']
+ end
+
+ def data_type
+ query_result['data_type']
+ end
+
+ def default
+ return unless query_result['column_default']
+
+ return if name == 'id' || query_result['column_default'].include?('nextval')
+
+ "DEFAULT #{query_result['column_default']}"
+ end
+
+ def nullable
+ 'NOT NULL' if query_result['not_null']
+ end
+
+ def partition_key?
+ query_result['partition_key']
+ end
+
+ private
+
+ attr_reader :query_result
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter.rb
new file mode 100644
index 00000000000..62e501bf16b
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Adapters
+ UndefinedPGType = Class.new(StandardError)
+
+ class ColumnStructureSqlAdapter
+ NOT_NULL_CONSTR = :CONSTR_NOTNULL
+ DEFAULT_CONSTR = :CONSTR_DEFAULT
+
+ MAPPINGS = {
+ 't' => 'true',
+ 'f' => 'false'
+ }.freeze
+
+ attr_reader :table_name
+
+ def initialize(table_name, pg_query_stmt, partitioning_stmt)
+ @table_name = table_name
+ @pg_query_stmt = pg_query_stmt
+ @partitioning_stmt = partitioning_stmt
+ end
+
+ def name
+ @name ||= pg_query_stmt.colname
+ end
+
+ def data_type
+ type(pg_query_stmt.type_name)
+ end
+
+ def default
+ return if name == 'id'
+
+ value = parse_node(constraints.find { |node| node.constraint.contype == DEFAULT_CONSTR })
+
+ return unless value
+
+ "DEFAULT #{value}"
+ end
+
+ def nullable
+ 'NOT NULL' if constraints.any? { |node| node.constraint.contype == NOT_NULL_CONSTR }
+ end
+
+ def partition_key?
+ partition_keys.include?(name)
+ end
+
+ private
+
+ attr_reader :pg_query_stmt, :partitioning_stmt
+
+ def constraints
+ @constraints ||= pg_query_stmt.constraints
+ end
+
+ # Returns the node type
+ #
+ # pg_type:: type alias, used internally by postgres, +int4+, +int8+, +bool+, +varchar+
+ # type:: type name, like +integer+, +bigint+, +boolean+, +character varying+.
+ # array_ext:: adds the +[]+ extension for array types.
+ # precision_ext:: adds the precision, if have any, like +(255)+, +(6)+.
+ #
+ # @info +timestamp+ and +timestamptz+ have a particular case when precision is defined.
+ # In this case, the order of the statement needs to be re-arranged from
+ # timestamp without time zone(6) to timestamp(6) without a time zone.
+ def type(node)
+ pg_type = parse_node(node.names.last)
+ type = PgTypes::TYPES.fetch(pg_type).dup
+ array_ext = '[]' if node.array_bounds.any?
+ precision_ext = "(#{node.typmods.map { |typmod| parse_node(typmod) }.join(',')})" if node.typmods.any?
+
+ if %w[timestamp timestamptz].include?(pg_type)
+ type.gsub!('timestamp', ['timestamp', precision_ext].compact.join)
+ precision_ext = nil
+ end
+
+ [type, precision_ext, array_ext].compact.join
+ rescue KeyError => e
+ raise UndefinedPGType, e.message
+ end
+
+ # Parses PGQuery nodes recursively
+ #
+ # :constraint:: nodes that groups column default info
+ # :partition_elem:: node that store partition key info
+ # :func_cal:: nodes that stores functions, like +now()+
+ # :a_const:: nodes that stores constant values, like +t+, +f+, +0.0.0.0+, +255+, +1.0+
+ # :type_cast:: nodes that stores casting values, like +'name'::text+, +'0.0.0.0'::inet+
+ # else:: extract node values in the last iteration of the recursion, like +int4+, +1.0+, +now+, +255+
+ #
+ # @note boolean types types are mapped from +t+, +f+ to +true+, +false+
+ def parse_node(node)
+ return unless node
+
+ case node.node
+ when :constraint
+ parse_node(node.constraint.raw_expr)
+ when :partition_elem
+ node.partition_elem.name
+ when :func_call
+ "#{parse_node(node.func_call.funcname.first)}()"
+ when :a_const
+ parse_a_const(node.a_const)
+ when :type_cast
+ value = parse_node(node.type_cast.arg)
+ type = type(node.type_cast.type_name)
+ separator = MAPPINGS.key?(value) ? '' : "::#{type}"
+
+ [MAPPINGS.fetch(value, "'#{value}'"), separator].compact.join
+ else
+ get_value_from_key(node, key: node.node)
+ end
+ end
+
+ def parse_a_const(a_const)
+ return unless a_const
+
+ type = a_const.val
+ get_value_from_key(a_const, key: type)
+ end
+
+ def get_value_from_key(node, key:)
+ node.to_h[key].values.last
+ end
+
+ def partition_keys
+ return [] unless partitioning_stmt
+
+ @partition_keys ||= partitioning_stmt.part_params.map { |key_stmt| parse_node(key_stmt) }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter.rb
new file mode 100644
index 00000000000..ee5d5dc0ce9
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Adapters
+ class ForeignKeyDatabaseAdapter
+ def initialize(query_result)
+ @query_result = query_result
+ end
+
+ def name
+ "#{query_result['schema']}.#{query_result['foreign_key_name']}"
+ end
+
+ def table_name
+ query_result['table_name']
+ end
+
+ def statement
+ query_result['foreign_key_definition']
+ end
+
+ private
+
+ attr_reader :query_result
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter.rb
new file mode 100644
index 00000000000..730652c302d
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Adapters
+ class ForeignKeyStructureSqlAdapter
+ STATEMENT_REGEX = /\bREFERENCES\s\K\S+\K\s\(/
+ EXTRACT_REGEX = /\bFOREIGN KEY.*/
+
+ def initialize(parsed_stmt)
+ @parsed_stmt = parsed_stmt
+ end
+
+ def name
+ "#{schema_name}.#{foreign_key_name}"
+ end
+
+ def table_name
+ parsed_stmt.relation.relname
+ end
+
+ # PgQuery parses FK statements with an extra space in the referenced table column.
+ # This extra space needs to be removed.
+ #
+ # @example REFERENCES ci_pipelines (id) => REFERENCES ci_pipelines(id)
+ def statement
+ deparse_stmt[EXTRACT_REGEX].gsub(STATEMENT_REGEX, '(')
+ end
+
+ private
+
+ attr_reader :parsed_stmt
+
+ def schema_name
+ parsed_stmt.relation.schemaname
+ end
+
+ def foreign_key_name
+ parsed_stmt.cmds.first.alter_table_cmd.def.constraint.conname
+ end
+
+ def deparse_stmt
+ PgQuery.deparse_stmt(parsed_stmt)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/inconsistency.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/inconsistency.rb
new file mode 100644
index 00000000000..13799b8b9ff
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/inconsistency.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ class Inconsistency
+ def initialize(validator_class, structure_sql_object, database_object)
+ @validator_class = validator_class
+ @structure_sql_object = structure_sql_object
+ @database_object = database_object
+ end
+
+ def error_message
+ format(validator_class::ERROR_MESSAGE, object_name)
+ end
+
+ def type
+ validator_class.name
+ end
+
+ def object_type
+ object_type = structure_sql_object&.class&.name || database_object&.class&.name
+
+ object_type&.gsub('Gitlab::Schema::Validation::SchemaObjects::', '')
+ end
+
+ def table_name
+ structure_sql_object&.table_name || database_object&.table_name
+ end
+
+ def object_name
+ structure_sql_object&.name || database_object&.name
+ end
+
+ def diff
+ Diffy::Diff.new(structure_sql_statement, database_statement)
+ end
+
+ def display
+ <<~MSG
+ #{'-' * 54}
+ #{error_message}
+ Diff:
+ #{diff.to_s(:color)}
+ #{'-' * 54}
+ MSG
+ end
+
+ def structure_sql_statement
+ return unless structure_sql_object
+
+ "#{structure_sql_object.statement}\n"
+ end
+
+ def database_statement
+ return unless database_object
+
+ "#{database_object.statement}\n"
+ end
+
+ private
+
+ attr_reader :validator_class, :structure_sql_object, :database_object
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/pg_types.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/pg_types.rb
new file mode 100644
index 00000000000..335bbe94cfb
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/pg_types.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ class PgTypes
+ TYPES = {
+ 'bool' => 'boolean',
+ 'bytea' => 'bytea',
+ 'char' => '"char"',
+ 'int8' => 'bigint',
+ 'int2' => 'smallint',
+ 'int4' => 'integer',
+ 'regproc' => 'regproc',
+ 'text' => 'text',
+ 'oid' => 'oid',
+ 'tid' => 'tid',
+ 'xid' => 'xid',
+ 'cid' => 'cid',
+ 'json' => 'json',
+ 'xml' => 'xml',
+ 'pg_node_tree' => 'pg_node_tree',
+ 'pg_ndistinct' => 'pg_ndistinct',
+ 'pg_dependencies' => 'pg_dependencies',
+ 'pg_mcv_list' => 'pg_mcv_list',
+ 'xid8' => 'xid8',
+ 'path' => 'path',
+ 'polygon' => 'polygon',
+ 'float4' => 'real',
+ 'float8' => 'double precision',
+ 'circle' => 'circle',
+ 'money' => 'money',
+ 'macaddr' => 'macaddr',
+ 'inet' => 'inet',
+ 'cidr' => 'cidr',
+ 'macaddr8' => 'macaddr8',
+ 'aclitem' => 'aclitem',
+ 'bpchar' => 'character',
+ 'varchar' => 'character varying',
+ 'date' => 'date',
+ 'time' => 'time without time zone',
+ 'timestamp' => 'timestamp without time zone',
+ 'timestamptz' => 'timestamp with time zone',
+ 'interval' => 'interval',
+ 'timetz' => 'time with time zone',
+ 'bit' => 'bit',
+ 'varbit' => 'bit varying',
+ 'numeric' => 'numeric',
+ 'refcursor' => 'refcursor',
+ 'regprocedure' => 'regprocedure',
+ 'regoper' => 'regoper',
+ 'regoperator' => 'regoperator',
+ 'regclass' => 'regclass',
+ 'regcollation' => 'regcollation',
+ 'regtype' => 'regtype',
+ 'regrole' => 'regrole',
+ 'regnamespace' => 'regnamespace',
+ 'uuid' => 'uuid',
+ 'pg_lsn' => 'pg_lsn',
+ 'tsvector' => 'tsvector',
+ 'gtsvector' => 'gtsvector',
+ 'tsquery' => 'tsquery',
+ 'regconfig' => 'regconfig',
+ 'regdictionary' => 'regdictionary',
+ 'jsonb' => 'jsonb',
+ 'jsonpath' => 'jsonpath',
+ 'txid_snapshot' => 'txid_snapshot',
+ 'pg_snapshot' => 'pg_snapshot'
+ }.freeze
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/base.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/base.rb
new file mode 100644
index 00000000000..1af7a67ddb6
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/base.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module SchemaObjects
+ class Base
+ def initialize(parsed_stmt)
+ @parsed_stmt = parsed_stmt
+ end
+
+ def name
+ raise NoMethodError, "subclasses of #{self.class.name} must implement #{__method__}"
+ end
+
+ def table_name
+ parsed_stmt.relation.relname
+ end
+
+ def statement
+ @statement ||= PgQuery.deparse_stmt(parsed_stmt)
+ end
+
+ private
+
+ attr_reader :parsed_stmt
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/column.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/column.rb
new file mode 100644
index 00000000000..0b3687fdb98
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/column.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module SchemaObjects
+ class Column
+ def initialize(adapter)
+ @adapter = adapter
+ end
+
+ attr_reader :adapter
+
+ def name
+ adapter.name
+ end
+
+ def table_name
+ adapter.table_name
+ end
+
+ def partition_key?
+ adapter.partition_key?
+ end
+
+ def statement
+ [adapter.name, adapter.data_type, adapter.default, adapter.nullable].compact.join(' ')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/foreign_key.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/foreign_key.rb
new file mode 100644
index 00000000000..41e2d30029a
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/foreign_key.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module SchemaObjects
+ class ForeignKey
+ def initialize(adapter)
+ @adapter = adapter
+ end
+
+ # Foreign key name should include the schema, as the same name could be used across different schemas
+ #
+ # @example public.foreign_key_name
+ def name
+ @name ||= adapter.name
+ end
+
+ def table_name
+ @table_name ||= adapter.table_name
+ end
+
+ def statement
+ @statement ||= adapter.statement
+ end
+
+ private
+
+ attr_reader :adapter
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/index.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/index.rb
new file mode 100644
index 00000000000..9f99c6a6e6e
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/index.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module SchemaObjects
+ class Index < Base
+ def name
+ parsed_stmt.idxname
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/table.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/table.rb
new file mode 100644
index 00000000000..591131cb220
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/table.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module SchemaObjects
+ class Table
+ def initialize(name, columns)
+ @name = name
+ @columns = columns
+ end
+
+ attr_reader :name, :columns
+
+ def table_name
+ name
+ end
+
+ def statement
+ format('CREATE TABLE %s (%s)', name, columns_statement)
+ end
+
+ def fetch_column_by_name(column_name)
+ columns.find { |column| column.name == column_name }
+ end
+
+ def column_exists?(column_name)
+ column = fetch_column_by_name(column_name)
+
+ return false if column.nil?
+
+ true
+ end
+
+ private
+
+ def columns_statement
+ columns.reject(&:partition_key?).map(&:statement).join(', ')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/trigger.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/trigger.rb
new file mode 100644
index 00000000000..7903985a963
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/schema_objects/trigger.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module SchemaObjects
+ class Trigger < Base
+ def name
+ parsed_stmt.trigname
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/database.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/database.rb
new file mode 100644
index 00000000000..8505d1f149a
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/database.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Sources
+ class Database
+ STATIC_PARTITIONS_SCHEMA = 'gitlab_partitions_static'
+
+ def initialize(connection)
+ @connection = connection
+ end
+
+ def fetch_index_by_name(index_name)
+ index_map[index_name]
+ end
+
+ def fetch_trigger_by_name(trigger_name)
+ trigger_map[trigger_name]
+ end
+
+ def fetch_foreign_key_by_name(foreign_key_name)
+ foreign_key_map[foreign_key_name]
+ end
+
+ def fetch_table_by_name(table_name)
+ table_map[table_name]
+ end
+
+ def index_exists?(index_name)
+ index = index_map[index_name]
+
+ return false if index.nil?
+
+ true
+ end
+
+ def trigger_exists?(trigger_name)
+ trigger = trigger_map[trigger_name]
+
+ return false if trigger.nil?
+
+ true
+ end
+
+ def foreign_key_exists?(foreign_key_name)
+ foreign_key = fetch_foreign_key_by_name(foreign_key_name)
+
+ return false if foreign_key.nil?
+
+ true
+ end
+
+ def table_exists?(table_name)
+ table = fetch_table_by_name(table_name)
+
+ return false if table.nil?
+
+ true
+ end
+
+ def indexes
+ index_map.values
+ end
+
+ def triggers
+ trigger_map.values
+ end
+
+ def foreign_keys
+ foreign_key_map.values
+ end
+
+ def tables
+ table_map.values
+ end
+
+ private
+
+ attr_reader :connection
+
+ def schemas
+ @schemas ||= [STATIC_PARTITIONS_SCHEMA, connection.current_schema]
+ end
+
+ def trigger_map
+ @trigger_map ||=
+ fetch_triggers.transform_values! do |trigger_stmt|
+ SchemaObjects::Trigger.new(PgQuery.parse(trigger_stmt).tree.stmts.first.stmt.create_trig_stmt)
+ end
+ end
+
+ def fetch_triggers
+ # rubocop:disable Rails/SquishedSQLHeredocs
+ sql = <<~SQL
+ SELECT triggers.tgname, pg_get_triggerdef(triggers.oid)
+ FROM pg_catalog.pg_trigger triggers
+ INNER JOIN pg_catalog.pg_class rel ON triggers.tgrelid = rel.oid
+ INNER JOIN pg_catalog.pg_namespace nsp ON nsp.oid = rel.relnamespace
+ WHERE triggers.tgisinternal IS FALSE
+ AND nsp.nspname IN ($1, $2)
+ SQL
+ # rubocop:enable Rails/SquishedSQLHeredocs
+
+ connection.select_rows(sql, nil, schemas).to_h
+ end
+
+ def table_map
+ @table_map ||= fetch_tables.transform_values! do |stmt|
+ columns = stmt.map { |column| SchemaObjects::Column.new(Adapters::ColumnDatabaseAdapter.new(column)) }
+
+ SchemaObjects::Table.new(stmt.first['table_name'], columns)
+ end
+ end
+
+ def fetch_tables
+ # rubocop:disable Rails/SquishedSQLHeredocs
+ sql = <<~SQL
+ SELECT
+ table_information.relname AS table_name,
+ col_information.attname AS column_name,
+ col_information.attnotnull AS not_null,
+ col_information.attnum = ANY(pg_partitioned_table.partattrs) as partition_key,
+ format_type(col_information.atttypid, col_information.atttypmod) AS data_type,
+ pg_get_expr(col_default_information.adbin, col_default_information.adrelid) AS column_default
+ FROM pg_attribute AS col_information
+ JOIN pg_class AS table_information ON col_information.attrelid = table_information.oid
+ JOIN pg_namespace AS schema_information ON table_information.relnamespace = schema_information.oid
+ LEFT JOIN pg_partitioned_table ON pg_partitioned_table.partrelid = table_information.oid
+ LEFT JOIN pg_attrdef AS col_default_information ON col_information.attrelid = col_default_information.adrelid
+ AND col_information.attnum = col_default_information.adnum
+ WHERE NOT col_information.attisdropped
+ AND col_information.attnum > 0
+ AND table_information.relkind IN ('r', 'p')
+ AND schema_information.nspname IN ($1, $2)
+ SQL
+ # rubocop:enable Rails/SquishedSQLHeredocs
+
+ connection.exec_query(sql, nil, schemas).group_by { |row| row['table_name'] }
+ end
+
+ def fetch_indexes
+ # rubocop:disable Rails/SquishedSQLHeredocs
+ sql = <<~SQL
+ SELECT indexname, indexdef
+ FROM pg_indexes
+ WHERE indexname NOT LIKE '%_pkey' AND schemaname IN ($1, $2);
+ SQL
+ # rubocop:enable Rails/SquishedSQLHeredocs
+
+ connection.select_rows(sql, nil, schemas).to_h
+ end
+
+ def index_map
+ @index_map ||=
+ fetch_indexes.transform_values! do |index_stmt|
+ SchemaObjects::Index.new(PgQuery.parse(index_stmt).tree.stmts.first.stmt.index_stmt)
+ end
+ end
+
+ def foreign_key_map
+ @foreign_key_map ||= fetch_fks.each_with_object({}) do |stmt, result|
+ adapter = Adapters::ForeignKeyDatabaseAdapter.new(stmt)
+
+ result[adapter.name] = SchemaObjects::ForeignKey.new(adapter)
+ end
+ end
+
+ def fetch_fks
+ # rubocop:disable Rails/SquishedSQLHeredocs
+ sql = <<~SQL
+ SELECT
+ pg_namespace.nspname::text AS schema,
+ pg_class.relname::text AS table_name,
+ pg_constraint.conname AS foreign_key_name,
+ pg_get_constraintdef(pg_constraint.oid) AS foreign_key_definition
+ FROM pg_constraint
+ INNER JOIN pg_class ON pg_constraint.conrelid = pg_class.oid
+ INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
+ WHERE contype = 'f'
+ AND pg_namespace.nspname = $1
+ AND pg_constraint.conparentid = 0
+ SQL
+ # rubocop:enable Rails/SquishedSQLHeredocs
+
+ connection.exec_query(sql, nil, [connection.current_schema])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/structure_sql.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/structure_sql.rb
new file mode 100644
index 00000000000..b2e3fcd63c5
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/sources/structure_sql.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Sources
+ class StructureSql
+ DEFAULT_SCHEMA = 'public'
+
+ def initialize(structure_file_path, schema_name = DEFAULT_SCHEMA)
+ @structure_file_path = structure_file_path
+ @schema_name = schema_name
+ end
+
+ def index_exists?(index_name)
+ index = indexes.find { |index| index.name == index_name }
+
+ return false if index.nil?
+
+ true
+ end
+
+ def trigger_exists?(trigger_name)
+ trigger = triggers.find { |trigger| trigger.name == trigger_name }
+
+ return false if trigger.nil?
+
+ true
+ end
+
+ def foreign_key_exists?(foreign_key_name)
+ foreign_key = foreign_keys.find { |fk| fk.name == foreign_key_name }
+
+ return false if foreign_key.nil?
+
+ true
+ end
+
+ def table_exists?(table_name)
+ table = fetch_table_by_name(table_name)
+
+ return false if table.nil?
+
+ true
+ end
+
+ def fetch_table_by_name(table_name)
+ tables.find { |table| table.name == table_name }
+ end
+
+ def indexes
+ @indexes ||= map_with_default_schema(index_statements, SchemaObjects::Index)
+ end
+
+ def triggers
+ @triggers ||= map_with_default_schema(trigger_statements, SchemaObjects::Trigger)
+ end
+
+ def foreign_keys
+ @foreign_keys ||= foreign_key_statements.map do |stmt|
+ stmt.relation.schemaname = schema_name if stmt.relation.schemaname == ''
+
+ SchemaObjects::ForeignKey.new(Adapters::ForeignKeyStructureSqlAdapter.new(stmt))
+ end
+ end
+
+ def tables
+ @tables ||= table_statements.map do |stmt|
+ table_name = stmt.relation.relname
+ partition_stmt = stmt.partspec
+
+ columns = stmt.table_elts.select { |n| n.node == :column_def }.map do |column|
+ adapter = Adapters::ColumnStructureSqlAdapter.new(table_name, column.column_def, partition_stmt)
+ SchemaObjects::Column.new(adapter)
+ end
+
+ SchemaObjects::Table.new(table_name, columns)
+ end
+ end
+
+ private
+
+ attr_reader :structure_file_path, :schema_name
+
+ def index_statements
+ statements.filter_map { |s| s.stmt.index_stmt }
+ end
+
+ def trigger_statements
+ statements.filter_map { |s| s.stmt.create_trig_stmt }
+ end
+
+ def table_statements
+ statements.filter_map { |s| s.stmt.create_stmt }
+ end
+
+ def foreign_key_statements
+ constraint_statements(:CONSTR_FOREIGN)
+ end
+
+ # Filter constraint statement nodes
+ #
+ # @param constraint_type [Symbol] node type. One of CONSTR_PRIMARY, CONSTR_CHECK, CONSTR_EXCLUSION,
+ # CONSTR_UNIQUE or CONSTR_FOREIGN.
+ def constraint_statements(constraint_type)
+ alter_table_statements(:AT_AddConstraint).filter do |stmt|
+ stmt.cmds.first.alter_table_cmd.def.constraint.contype == constraint_type
+ end
+ end
+
+ # Filter alter table statement nodes
+ #
+ # @param subtype [Symbol] node subtype +AT_AttachPartition+, +AT_ColumnDefault+ or +AT_AddConstraint+
+ def alter_table_statements(subtype)
+ statements.filter_map do |statement|
+ node = statement.stmt.alter_table_stmt
+
+ next unless node
+
+ node if node.cmds.first.alter_table_cmd.subtype == subtype
+ end
+ end
+
+ def statements
+ @statements ||= parsed_structure_file.tree.stmts
+ end
+
+ def parsed_structure_file
+ PgQuery.parse(File.read(structure_file_path))
+ end
+
+ def map_with_default_schema(statements, validation_class)
+ statements.map do |statement|
+ statement.relation.schemaname = schema_name if statement.relation.schemaname == ''
+
+ validation_class.new(statement)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/base.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/base.rb
new file mode 100644
index 00000000000..151af4b61e6
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/base.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class Base
+ ERROR_MESSAGE = 'A schema inconsistency has been found'
+
+ def initialize(structure_sql, database)
+ @structure_sql = structure_sql
+ @database = database
+ end
+
+ def self.all_validators
+ [
+ ExtraTables,
+ ExtraTableColumns,
+ ExtraIndexes,
+ ExtraTriggers,
+ ExtraForeignKeys,
+ MissingTables,
+ MissingTableColumns,
+ MissingIndexes,
+ MissingTriggers,
+ MissingForeignKeys,
+ DifferentDefinitionTables,
+ DifferentDefinitionIndexes,
+ DifferentDefinitionTriggers,
+ DifferentDefinitionForeignKeys
+ ]
+ end
+
+ def execute
+ raise NoMethodError, "subclasses of #{self.class.name} must implement #{__method__}"
+ end
+
+ private
+
+ attr_reader :structure_sql, :database
+
+ def build_inconsistency(validator_class, structure_sql_object, database_object)
+ Inconsistency.new(validator_class, structure_sql_object, database_object)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_foreign_keys.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_foreign_keys.rb
new file mode 100644
index 00000000000..d8ea7807cc5
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_foreign_keys.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class DifferentDefinitionForeignKeys < Base
+ ERROR_MESSAGE = "The %s foreign key has a different statement between structure.sql and database"
+
+ def execute
+ structure_sql.foreign_keys.filter_map do |structure_sql_fk|
+ database_fk = database.fetch_foreign_key_by_name(structure_sql_fk.name)
+
+ next if database_fk.nil?
+ next if database_fk.statement == structure_sql_fk.statement
+
+ build_inconsistency(self.class, structure_sql_fk, database_fk)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_indexes.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_indexes.rb
new file mode 100644
index 00000000000..032e7edd5ab
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_indexes.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class DifferentDefinitionIndexes < Base
+ ERROR_MESSAGE = 'The %s index has a different statement between structure.sql and database'
+
+ def execute
+ structure_sql.indexes.filter_map do |structure_sql_index|
+ database_index = database.fetch_index_by_name(structure_sql_index.name)
+
+ next if database_index.nil?
+ next if database_index.statement == structure_sql_index.statement
+
+ build_inconsistency(self.class, structure_sql_index, database_index)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_tables.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_tables.rb
new file mode 100644
index 00000000000..f6892a76a12
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_tables.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class DifferentDefinitionTables < Base
+ ERROR_MESSAGE = "The table %s has a different column statement between structure.sql and database"
+
+ def execute
+ structure_sql.tables.filter_map do |structure_sql_table|
+ table_name = structure_sql_table.name
+ database_table = database.fetch_table_by_name(table_name)
+
+ next unless database_table
+
+ db_diffs, structure_diffs = column_diffs(database_table, structure_sql_table.columns)
+
+ if db_diffs.any?
+ build_inconsistency(self.class,
+ SchemaObjects::Table.new(table_name, db_diffs),
+ SchemaObjects::Table.new(table_name, structure_diffs))
+ end
+ end
+ end
+
+ private
+
+ def column_diffs(db_table, columns)
+ db_diffs = []
+ structure_diffs = []
+
+ columns.each do |column|
+ db_column = db_table.fetch_column_by_name(column.name)
+
+ next unless db_column
+
+ next if db_column.statement == column.statement
+
+ db_diffs << db_column
+ structure_diffs << column
+ end
+
+ [db_diffs, structure_diffs]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_triggers.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_triggers.rb
new file mode 100644
index 00000000000..eed78924b02
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/different_definition_triggers.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class DifferentDefinitionTriggers < Base
+ ERROR_MESSAGE = "The %s trigger has a different statement between structure.sql and database"
+
+ def execute
+ structure_sql.triggers.filter_map do |structure_sql_trigger|
+ database_trigger = database.fetch_trigger_by_name(structure_sql_trigger.name)
+
+ next if database_trigger.nil?
+ next if database_trigger.statement == structure_sql_trigger.statement
+
+ build_inconsistency(self.class, structure_sql_trigger, nil)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_foreign_keys.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_foreign_keys.rb
new file mode 100644
index 00000000000..81968318629
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_foreign_keys.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class ExtraForeignKeys < Base
+ ERROR_MESSAGE = "The foreign key %s is present in the database, but not in the structure.sql file"
+
+ def execute
+ database.foreign_keys.filter_map do |database_fk|
+ next if structure_sql.foreign_key_exists?(database_fk.name)
+
+ build_inconsistency(self.class, nil, database_fk)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_indexes.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_indexes.rb
new file mode 100644
index 00000000000..4b5bd7c820b
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_indexes.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class ExtraIndexes < Base
+ ERROR_MESSAGE = 'The index %s is present in the database, but not in the structure.sql file'
+
+ def execute
+ database.indexes.filter_map do |database_index|
+ next if structure_sql.index_exists?(database_index.name)
+
+ build_inconsistency(self.class, nil, database_index)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_table_columns.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_table_columns.rb
new file mode 100644
index 00000000000..517d6ae957f
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_table_columns.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class ExtraTableColumns < Base
+ ERROR_MESSAGE = "The table %s has columns present in the database, but not in the structure.sql file"
+
+ def execute
+ database.tables.filter_map do |database_table|
+ table_name = database_table.name
+ structure_sql_table = structure_sql.fetch_table_by_name(table_name)
+
+ next unless structure_sql_table
+
+ inconsistencies = database_table.columns.filter_map do |database_table_column|
+ next if structure_sql_table.column_exists?(database_table_column.name)
+
+ database_table_column
+ end
+
+ if inconsistencies.any?
+ build_inconsistency(self.class, nil, SchemaObjects::Table.new(table_name, inconsistencies))
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_tables.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_tables.rb
new file mode 100644
index 00000000000..d297464a01c
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_tables.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class ExtraTables < Base
+ ERROR_MESSAGE = "The table %s is present in the database, but not in the structure.sql file"
+
+ def execute
+ database.tables.filter_map do |database_table|
+ next if structure_sql.table_exists?(database_table.name)
+
+ build_inconsistency(self.class, nil, database_table)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_triggers.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_triggers.rb
new file mode 100644
index 00000000000..d06747989fc
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/extra_triggers.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class ExtraTriggers < Base
+ ERROR_MESSAGE = "The trigger %s is present in the database, but not in the structure.sql file"
+
+ def execute
+ database.triggers.filter_map do |database_trigger|
+ next if structure_sql.trigger_exists?(database_trigger.name)
+
+ build_inconsistency(self.class, nil, database_trigger)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_foreign_keys.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_foreign_keys.rb
new file mode 100644
index 00000000000..daebd458282
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_foreign_keys.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class MissingForeignKeys < Base
+ ERROR_MESSAGE = "The foreign key %s is missing from the database"
+
+ def execute
+ structure_sql.foreign_keys.filter_map do |structure_sql_fk|
+ next if database.foreign_key_exists?(structure_sql_fk.name)
+
+ build_inconsistency(self.class, structure_sql_fk, nil)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_indexes.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_indexes.rb
new file mode 100644
index 00000000000..655c462aeaa
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_indexes.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class MissingIndexes < Base
+ ERROR_MESSAGE = "The index %s is missing from the database"
+
+ def execute
+ structure_sql.indexes.filter_map do |structure_sql_index|
+ next if database.index_exists?(structure_sql_index.name)
+
+ build_inconsistency(self.class, structure_sql_index, nil)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_table_columns.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_table_columns.rb
new file mode 100644
index 00000000000..8b441e19654
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_table_columns.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class MissingTableColumns < Base
+ ERROR_MESSAGE = "The table %s has columns missing from the database"
+
+ def execute
+ structure_sql.tables.filter_map do |structure_sql_table|
+ table_name = structure_sql_table.name
+ database_table = database.fetch_table_by_name(table_name)
+
+ next unless database_table
+
+ inconsistencies = structure_sql_table.columns.filter_map do |structure_table_column|
+ next if database_table.column_exists?(structure_table_column.name)
+
+ structure_table_column
+ end
+
+ if inconsistencies.any?
+ build_inconsistency(self.class, nil, SchemaObjects::Table.new(table_name, inconsistencies))
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_tables.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_tables.rb
new file mode 100644
index 00000000000..facf9135dfb
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_tables.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class MissingTables < Base
+ ERROR_MESSAGE = "The table %s is missing from the database"
+
+ def execute
+ structure_sql.tables.filter_map do |structure_sql_table|
+ next if database.table_exists?(structure_sql_table.name)
+
+ build_inconsistency(self.class, structure_sql_table, nil)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_triggers.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_triggers.rb
new file mode 100644
index 00000000000..1640d4304c3
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/validators/missing_triggers.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Validators
+ class MissingTriggers < Base
+ ERROR_MESSAGE = "The trigger %s is missing from the database"
+
+ def execute
+ structure_sql.triggers.filter_map do |structure_sql_trigger|
+ next if database.trigger_exists?(structure_sql_trigger.name)
+
+ build_inconsistency(self.class, structure_sql_trigger, nil)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/lib/gitlab/schema/validation/version.rb b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/version.rb
new file mode 100644
index 00000000000..40220578c97
--- /dev/null
+++ b/gems/gitlab-schema-validation/lib/gitlab/schema/validation/version.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Schema
+ module Validation
+ module Version
+ VERSION = "0.1.0"
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/fixtures/structure.sql b/gems/gitlab-schema-validation/spec/fixtures/structure.sql
new file mode 100644
index 00000000000..421fb6c3593
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/fixtures/structure.sql
@@ -0,0 +1,108 @@
+CREATE INDEX missing_index ON events USING btree (created_at, author_id);
+
+CREATE UNIQUE INDEX wrong_index ON table_name (column_name, column_name_2);
+
+CREATE UNIQUE INDEX "index" ON achievements USING btree (namespace_id, lower(name));
+
+CREATE INDEX index_namespaces_public_groups_name_id ON namespaces USING btree (name, id) WHERE (((type)::text = 'Group'::text) AND (visibility_level = 20));
+
+CREATE UNIQUE INDEX index_on_deploy_keys_id_and_type_and_public ON keys USING btree (id, type) WHERE (public = true);
+
+CREATE INDEX index_users_on_public_email_excluding_null_and_empty ON users USING btree (public_email) WHERE (((public_email)::text <> ''::text) AND (public_email IS NOT NULL));
+
+CREATE TABLE test_table (
+ id bigint NOT NULL,
+ integer_column integer,
+ integer_with_default_column integer DEFAULT 1,
+ smallint_column smallint,
+ smallint_with_default_column smallint DEFAULT 0 NOT NULL,
+ numeric_column numeric NOT NULL,
+ numeric_with_default_column numeric DEFAULT 1.0 NOT NULL,
+ boolean_colum boolean,
+ boolean_with_default_colum boolean DEFAULT true NOT NULL,
+ double_precision_column double precision,
+ double_precision_with_default_column double precision DEFAULT 1.0,
+ varying_column character varying,
+ varying_with_default_column character varying DEFAULT 'DEFAULT'::character varying NOT NULL,
+ varying_with_limit_column character varying(255),
+ varying_with_limit_and_default_column character varying(255) DEFAULT 'DEFAULT'::character varying,
+ text_column text NOT NULL,
+ text_with_default_column text DEFAULT ''::text NOT NULL,
+ array_column character varying(255)[] NOT NULL,
+ array_with_default_column character varying(255)[] DEFAULT '{one,two}'::character varying[] NOT NULL,
+ jsonb_column jsonb,
+ jsonb_with_default_column jsonb DEFAULT '[]'::jsonb NOT NULL,
+ timestamptz_column timestamp with time zone,
+ timestamptz_with_default_column timestamp(6) with time zone DEFAULT now(),
+ timestamp_column timestamp(6) without time zone NOT NULL,
+ timestamp_with_default_column timestamp(6) without time zone DEFAULT '2022-01-23 00:00:00+00'::timestamp without time zone NOT NULL,
+ date_column date,
+ date_with_default_column date DEFAULT '2023-04-05',
+ inet_column inet NOT NULL,
+ inet_with_default_column inet DEFAULT '0.0.0.0'::inet NOT NULL,
+ macaddr_column macaddr,
+ macaddr_with_default_column macaddr DEFAULT '00-00-00-00-00-000'::macaddr NOT NULL,
+ uuid_column uuid NOT NULL,
+ uuid_with_default_column uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
+ bytea_column bytea,
+ bytea_with_default_column bytea DEFAULT '\xDEADBEEF'::bytea,
+ unmapped_column_type anyarray,
+ partition_key bigint DEFAULT 1 NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL
+) PARTITION BY HASH (partition_key, created_at);
+
+CREATE TABLE ci_project_mirrors (
+ id bigint NOT NULL,
+ project_id integer NOT NULL,
+ namespace_id integer NOT NULL
+);
+
+CREATE TABLE wrong_table (
+ id bigint NOT NULL,
+ description character varying(255) NOT NULL
+);
+
+CREATE TABLE extra_table_columns (
+ id bigint NOT NULL,
+ name character varying(255) NOT NULL
+);
+
+CREATE TABLE missing_table (
+ id bigint NOT NULL,
+ description text NOT NULL
+);
+
+CREATE TABLE missing_table_columns (
+ id bigint NOT NULL,
+ email character varying(255) NOT NULL
+);
+
+CREATE TABLE operations_user_lists (
+ id bigint NOT NULL,
+ project_id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ iid integer NOT NULL,
+ name character varying(255) NOT NULL,
+ user_xids text DEFAULT ''::text NOT NULL
+);
+
+CREATE TRIGGER trigger AFTER INSERT ON public.t1 FOR EACH ROW EXECUTE FUNCTION t1();
+
+CREATE TRIGGER wrong_trigger BEFORE UPDATE ON public.t2 FOR EACH ROW EXECUTE FUNCTION my_function();
+
+CREATE TRIGGER missing_trigger_1 BEFORE INSERT OR UPDATE ON public.t3 FOR EACH ROW EXECUTE FUNCTION t3();
+
+CREATE TRIGGER projects_loose_fk_trigger AFTER DELETE ON projects REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION insert_into_loose_foreign_keys_deleted_records();
+
+ALTER TABLE web_hooks
+ ADD CONSTRAINT web_hooks_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY issues
+ ADD CONSTRAINT wrong_definition_fk FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL;
+
+ALTER TABLE ONLY issues
+ ADD CONSTRAINT missing_fk FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL;
+
+ALTER TABLE ONLY bulk_import_configurations
+ ADD CONSTRAINT fk_rails_536b96bff1 FOREIGN KEY (bulk_import_id) REFERENCES bulk_imports(id) ON DELETE CASCADE;
diff --git a/gems/gitlab-schema-validation/spec/gitlab/schema/validation_spec.rb b/gems/gitlab-schema-validation/spec/gitlab/schema/validation_spec.rb
new file mode 100644
index 00000000000..f4a06abab48
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/gitlab/schema/validation_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+RSpec.describe Gitlab::Schema::Validation do
+ it "has a version number" do
+ expect(Gitlab::Schema::Validation::Version::VERSION).not_to be_nil
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_database_adapter_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_database_adapter_spec.rb
new file mode 100644
index 00000000000..ce16d8468b5
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_database_adapter_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Adapters::ColumnDatabaseAdapter, feature_category: :database do
+ subject(:adapter) { described_class.new(db_result) }
+
+ let(:column_name) { 'email' }
+ let(:column_default) { "'no-reply@gitlab.com'::character varying" }
+ let(:not_null) { true }
+ let(:partition_key) { false }
+ let(:db_result) do
+ {
+ 'table_name' => 'projects',
+ 'column_name' => column_name,
+ 'data_type' => 'character varying',
+ 'column_default' => column_default,
+ 'not_null' => not_null,
+ 'partition_key' => partition_key
+ }
+ end
+
+ describe '#name' do
+ it { expect(adapter.name).to eq('email') }
+ end
+
+ describe '#table_name' do
+ it { expect(adapter.table_name).to eq('projects') }
+ end
+
+ describe '#data_type' do
+ it { expect(adapter.data_type).to eq('character varying') }
+ end
+
+ describe '#default' do
+ context "when there's no default value in the column" do
+ let(:column_default) { nil }
+
+ it { expect(adapter.default).to be_nil }
+ end
+
+ context 'when the column name is id' do
+ let(:column_name) { 'id' }
+
+ it { expect(adapter.default).to be_nil }
+ end
+
+ context 'when the column default includes nextval' do
+ let(:column_default) { "nextval('my_seq'::regclass)" }
+
+ it { expect(adapter.default).to be_nil }
+ end
+
+ it { expect(adapter.default).to eq("DEFAULT 'no-reply@gitlab.com'::character varying") }
+ end
+
+ describe '#nullable' do
+ context 'when column is not null' do
+ it { expect(adapter.nullable).to eq('NOT NULL') }
+ end
+
+ context 'when column is nullable' do
+ let(:not_null) { false }
+
+ it { expect(adapter.nullable).to be_nil }
+ end
+ end
+
+ describe '#partition_key?' do
+ it { expect(adapter.partition_key?).to be(false) }
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter_spec.rb
new file mode 100644
index 00000000000..ae0d635e8ca
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/column_structure_sql_adapter_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Adapters::ColumnStructureSqlAdapter, feature_category: :database do
+ subject(:adapter) { described_class.new(table_name, column_def, partition_stmt) }
+
+ let(:table_name) { 'test_table' }
+ let(:file_path) { 'spec/fixtures/structure.sql' }
+ let(:table_stmts) { PgQuery.parse(File.read(file_path)).tree.stmts.filter_map { |s| s.stmt.create_stmt } }
+ let(:table) { table_stmts.find { |table| table.relation.relname == table_name } }
+ let(:partition_stmt) { table.partspec }
+ let(:column_stmts) { table.table_elts }
+ let(:column_def) { column_stmts.find { |col| col.column_def.colname == column_name }.column_def }
+
+ where(:column_name, :data_type, :default_value, :nullable, :partition_key) do
+ [
+ ['id', 'bigint', nil, 'NOT NULL', false],
+ ['integer_column', 'integer', nil, nil, false],
+ ['integer_with_default_column', 'integer', 'DEFAULT 1', nil, false],
+ ['smallint_with_default_column', 'smallint', 'DEFAULT 0', 'NOT NULL', false],
+ ['double_precision_with_default_column', 'double precision', 'DEFAULT 1.0', nil, false],
+ ['numeric_with_default_column', 'numeric', 'DEFAULT 1.0', 'NOT NULL', false],
+ ['boolean_with_default_colum', 'boolean', 'DEFAULT true', 'NOT NULL', false],
+ ['varying_with_default_column', 'character varying', "DEFAULT 'DEFAULT'::character varying", 'NOT NULL', false],
+ ['varying_with_limit_and_default_column', 'character varying(255)', "DEFAULT 'DEFAULT'::character varying",
+ nil, false],
+ ['text_with_default_column', 'text', "DEFAULT ''::text", 'NOT NULL', false],
+ ['array_with_default_column', 'character varying(255)[]', "DEFAULT '{one,two}'::character varying[]",
+ 'NOT NULL', false],
+ ['jsonb_with_default_column', 'jsonb', "DEFAULT '[]'::jsonb", 'NOT NULL', false],
+ ['timestamptz_with_default_column', 'timestamp(6) with time zone', 'DEFAULT now()', nil, false],
+ ['timestamp_with_default_column', 'timestamp(6) without time zone',
+ "DEFAULT '2022-01-23 00:00:00+00'::timestamp without time zone", 'NOT NULL', false],
+ ['date_with_default_column', 'date', 'DEFAULT 2023-04-05', nil, false],
+ ['inet_with_default_column', 'inet', "DEFAULT '0.0.0.0'::inet", 'NOT NULL', false],
+ ['macaddr_with_default_column', 'macaddr', "DEFAULT '00-00-00-00-00-000'::macaddr", 'NOT NULL', false],
+ ['uuid_with_default_column', 'uuid', "DEFAULT '00000000-0000-0000-0000-000000000000'::uuid", 'NOT NULL', false],
+ ['partition_key', 'bigint', 'DEFAULT 1', 'NOT NULL', true],
+ ['created_at', 'timestamp with time zone', 'DEFAULT now()', 'NOT NULL', true]
+ ]
+ end
+
+ with_them do
+ describe '#name' do
+ it { expect(adapter.name).to eq(column_name) }
+ end
+
+ describe '#table_name' do
+ it { expect(adapter.table_name).to eq(table_name) }
+ end
+
+ describe '#data_type' do
+ it { expect(adapter.data_type).to eq(data_type) }
+ end
+
+ describe '#nullable' do
+ it { expect(adapter.nullable).to eq(nullable) }
+ end
+
+ describe '#default' do
+ it { expect(adapter.default).to eq(default_value) }
+ end
+
+ describe '#partition_key?' do
+ it { expect(adapter.partition_key?).to eq(partition_key) }
+ end
+ end
+
+ context 'when the data type is not mapped' do
+ let(:column_name) { 'unmapped_column_type' }
+ let(:error_class) { Gitlab::Schema::Validation::Adapters::UndefinedPGType }
+
+ describe '#data_type' do
+ it { expect { adapter.data_type }.to raise_error(error_class) }
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter_spec.rb
new file mode 100644
index 00000000000..52689c0f0ec
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_database_adapter_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Adapters::ForeignKeyDatabaseAdapter, feature_category: :database do
+ subject(:adapter) { described_class.new(query_result) }
+
+ let(:query_result) do
+ {
+ 'schema' => 'public',
+ 'foreign_key_name' => 'fk_2e88fb7ce9',
+ 'table_name' => 'members',
+ 'foreign_key_definition' => 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE'
+ }
+ end
+
+ describe '#name' do
+ it { expect(adapter.name).to eq('public.fk_2e88fb7ce9') }
+ end
+
+ describe '#table_name' do
+ it { expect(adapter.table_name).to eq('members') }
+ end
+
+ describe '#statement' do
+ it { expect(adapter.statement).to eq('FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE') }
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter_spec.rb
new file mode 100644
index 00000000000..001786b9fbe
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/adapters/foreign_key_structure_sql_adapter_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Adapters::ForeignKeyStructureSqlAdapter, feature_category: :database do
+ subject(:adapter) { described_class.new(stmt) }
+
+ let(:stmt) { PgQuery.parse(sql).tree.stmts.first.stmt.alter_table_stmt }
+
+ where(:sql, :name, :table_name, :statement) do
+ [
+ [
+ 'ALTER TABLE ONLY public.issues ADD CONSTRAINT fk_05f1e72feb FOREIGN KEY (author_id) REFERENCES users (id) ' \
+ 'ON DELETE SET NULL',
+ 'public.fk_05f1e72feb',
+ 'issues',
+ 'FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL'
+ ],
+ [
+ 'ALTER TABLE public.import_failures ADD CONSTRAINT fk_9a9b9ba21c FOREIGN KEY (user_id) REFERENCES users(id) ' \
+ 'ON DELETE CASCADE',
+ 'public.fk_9a9b9ba21c',
+ 'import_failures',
+ 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE'
+ ]
+ ]
+ end
+
+ with_them do
+ describe '#name' do
+ it { expect(adapter.name).to eq(name) }
+ end
+
+ describe '#table_name' do
+ it { expect(adapter.table_name).to eq(table_name) }
+ end
+
+ describe '#statement' do
+ it { expect(adapter.statement).to eq(statement) }
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/inconsistency_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/inconsistency_spec.rb
new file mode 100644
index 00000000000..268bb4556e3
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/inconsistency_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Inconsistency do
+ let(:validator) { Gitlab::Schema::Validation::Validators::DifferentDefinitionIndexes }
+
+ let(:database_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' }
+ let(:structure_sql_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (id)' }
+
+ let(:structure_stmt) { PgQuery.parse(structure_sql_statement).tree.stmts.first.stmt.index_stmt }
+ let(:database_stmt) { PgQuery.parse(database_statement).tree.stmts.first.stmt.index_stmt }
+
+ let(:structure_sql_object) { Gitlab::Schema::Validation::SchemaObjects::Index.new(structure_stmt) }
+ let(:database_object) { Gitlab::Schema::Validation::SchemaObjects::Index.new(database_stmt) }
+
+ subject(:inconsistency) { described_class.new(validator, structure_sql_object, database_object) }
+
+ describe '#object_name' do
+ it 'returns the index name' do
+ expect(inconsistency.object_name).to eq('index_name')
+ end
+ end
+
+ describe '#diff' do
+ it 'returns a diff between the structure.sql and the database' do
+ expect(inconsistency.diff).to be_a(Diffy::Diff)
+ expect(inconsistency.diff.string1).to eq("#{structure_sql_statement}\n")
+ expect(inconsistency.diff.string2).to eq("#{database_statement}\n")
+ end
+ end
+
+ describe '#error_message' do
+ it 'returns the error message' do
+ stub_const "#{validator}::ERROR_MESSAGE", 'error message %s'
+
+ expect(inconsistency.error_message).to eq('error message index_name')
+ end
+ end
+
+ describe '#type' do
+ it 'returns the type of the validator' do
+ expect(inconsistency.type).to eq('Gitlab::Schema::Validation::Validators::DifferentDefinitionIndexes')
+ end
+ end
+
+ describe '#table_name' do
+ it 'returns the table name' do
+ expect(inconsistency.table_name).to eq('achievements')
+ end
+ end
+
+ describe '#object_type' do
+ it 'returns the structure sql object type' do
+ expect(inconsistency.object_type).to eq('Index')
+ end
+
+ context 'when the structure sql object is not available' do
+ subject(:inconsistency) { described_class.new(validator, nil, database_object) }
+
+ it 'returns the database object type' do
+ expect(inconsistency.object_type).to eq('Index')
+ end
+ end
+ end
+
+ describe '#structure_sql_statement' do
+ it 'returns structure sql statement' do
+ expect(inconsistency.structure_sql_statement).to eq("#{structure_sql_statement}\n")
+ end
+ end
+
+ describe '#database_statement' do
+ it 'returns database statement' do
+ expect(inconsistency.database_statement).to eq("#{database_statement}\n")
+ end
+ end
+
+ describe '#display' do
+ let(:expected_output) do
+ <<~MSG
+ ------------------------------------------------------
+ The index_name index has a different statement between structure.sql and database
+ Diff:
+ \e[31m-CREATE INDEX index_name ON public.achievements USING btree (id)\e[0m
+ \e[32m+CREATE INDEX index_name ON public.achievements USING btree (namespace_id)\e[0m
+
+ ------------------------------------------------------
+ MSG
+ end
+
+ it 'prints the inconsistency message' do
+ expect(inconsistency.display).to eql(expected_output)
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/column_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/column_spec.rb
new file mode 100644
index 00000000000..c002903e765
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/column_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::SchemaObjects::Column, feature_category: :database do
+ subject(:column) { described_class.new(adapter) }
+
+ let(:database_adapter) { 'Gitlab::Schema::Validation::Adapters::ColumnDatabaseAdapter' }
+ let(:adapter) do
+ instance_double(database_adapter, name: 'id', table_name: 'projects',
+ data_type: 'bigint', default: nil, nullable: 'NOT NULL')
+ end
+
+ describe '#name' do
+ it { expect(column.name).to eq('id') }
+ end
+
+ describe '#table_name' do
+ it { expect(column.table_name).to eq('projects') }
+ end
+
+ describe '#statement' do
+ it { expect(column.statement).to eq('id bigint NOT NULL') }
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/foreign_key_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/foreign_key_spec.rb
new file mode 100644
index 00000000000..bfe337b6e7c
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/foreign_key_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::SchemaObjects::ForeignKey, feature_category: :database do
+ subject(:foreign_key) { described_class.new(adapter) }
+
+ let(:database_adapter) { 'Gitlab::Schema::Validation::Adapters::ForeignKeyDatabaseAdapter' }
+ let(:adapter) do
+ instance_double(database_adapter, name: 'public.fk_1d37cddf91', table_name: 'vulnerabilities',
+ statement: 'FOREIGN KEY (epic_id) REFERENCES epics(id) ON DELETE SET NULL')
+ end
+
+ describe '#name' do
+ it { expect(foreign_key.name).to eq('public.fk_1d37cddf91') }
+ end
+
+ describe '#table_name' do
+ it { expect(foreign_key.table_name).to eq('vulnerabilities') }
+ end
+
+ describe '#statement' do
+ it { expect(foreign_key.statement).to eq('FOREIGN KEY (epic_id) REFERENCES epics(id) ON DELETE SET NULL') }
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/index_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/index_spec.rb
new file mode 100644
index 00000000000..dfef440d99e
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/index_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::SchemaObjects::Index, feature_category: :database do
+ let(:statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' }
+ let(:name) { 'index_name' }
+ let(:table_name) { 'achievements' }
+
+ include_examples 'schema objects assertions for', 'index_stmt'
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/table_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/table_spec.rb
new file mode 100644
index 00000000000..87555c88edf
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/table_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::SchemaObjects::Table, feature_category: :database do
+ subject(:table) { described_class.new(name, columns) }
+
+ let(:name) { 'my_table' }
+ let(:column_class) { 'Gitlab::Schema::Validation::SchemaObjects::Column' }
+ let(:columns) do
+ [
+ instance_double(column_class, name: 'id', statement: 'id bigint NOT NULL', partition_key?: false),
+ instance_double(column_class, name: 'col', statement: 'col text', partition_key?: false),
+ instance_double(column_class, name: 'partition', statement: 'partition integer DEFAULT 1', partition_key?: true)
+ ]
+ end
+
+ describe '#name' do
+ it { expect(table.name).to eq('my_table') }
+ end
+
+ describe '#table_name' do
+ it { expect(table.table_name).to eq('my_table') }
+ end
+
+ describe '#statement' do
+ it { expect(table.statement).to eq('CREATE TABLE my_table (id bigint NOT NULL, col text)') }
+
+ it 'ignores the partition column' do
+ expect(table.statement).not_to include('partition integer DEFAULT 1')
+ end
+ end
+
+ describe '#fetch_column_by_name' do
+ it { expect(table.fetch_column_by_name('col')).not_to be_nil }
+
+ it { expect(table.fetch_column_by_name('invalid')).to be_nil }
+ end
+
+ describe '#column_exists?' do
+ it { expect(table.column_exists?('col')).to be(true) }
+
+ it { expect(table.column_exists?('invalid')).to be(false) }
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/trigger_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/trigger_spec.rb
new file mode 100644
index 00000000000..b6d0ba38ebb
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/schema_objects/trigger_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::SchemaObjects::Trigger, feature_category: :database do
+ let(:statement) { 'CREATE TRIGGER my_trigger BEFORE INSERT ON todos FOR EACH ROW EXECUTE FUNCTION trigger()' }
+ let(:name) { 'my_trigger' }
+ let(:table_name) { 'todos' }
+
+ include_examples 'schema objects assertions for', 'create_trig_stmt'
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/sources/structure_sql_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/sources/structure_sql_spec.rb
new file mode 100644
index 00000000000..7d4a23b1619
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/sources/structure_sql_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'structure sql schema assertions for' do |object_exists_method, all_objects_method|
+ subject(:structure_sql) { described_class.new(structure_file_path, schema_name) }
+
+ let(:structure_file_path) { 'spec/fixtures/structure.sql' }
+ let(:schema_name) { 'public' }
+
+ describe "##{object_exists_method}" do
+ it 'returns true when schema object exists' do
+ expect(structure_sql.public_send(object_exists_method, valid_schema_object_name)).to be_truthy
+ end
+
+ it 'returns false when schema object does not exists' do
+ expect(structure_sql.public_send(object_exists_method, 'invalid-object-name')).to be_falsey
+ end
+ end
+
+ describe "##{all_objects_method}" do
+ it 'returns all the schema objects' do
+ schema_objects = structure_sql.public_send(all_objects_method)
+
+ expect(schema_objects).to all(be_a(schema_object))
+ expect(schema_objects.map(&:name)).to eq(expected_objects)
+ end
+ end
+end
+
+RSpec.describe Gitlab::Schema::Validation::Sources::StructureSql, feature_category: :database do
+ let(:structure_file_path) { 'spec/fixtures/structure.sql' }
+ let(:schema_name) { 'public' }
+
+ subject(:structure_sql) { described_class.new(structure_file_path, schema_name) }
+
+ context 'when having indexes' do
+ let(:schema_object) { Gitlab::Schema::Validation::SchemaObjects::Index }
+ let(:valid_schema_object_name) { 'index' }
+ let(:expected_objects) do
+ %w[missing_index wrong_index index index_namespaces_public_groups_name_id
+ index_on_deploy_keys_id_and_type_and_public index_users_on_public_email_excluding_null_and_empty]
+ end
+
+ include_examples 'structure sql schema assertions for', 'index_exists?', 'indexes'
+ end
+
+ context 'when having triggers' do
+ let(:schema_object) { Gitlab::Schema::Validation::SchemaObjects::Trigger }
+ let(:valid_schema_object_name) { 'trigger' }
+ let(:expected_objects) { %w[trigger wrong_trigger missing_trigger_1 projects_loose_fk_trigger] }
+
+ include_examples 'structure sql schema assertions for', 'trigger_exists?', 'triggers'
+ end
+
+ context 'when having tables' do
+ let(:schema_object) { Gitlab::Schema::Validation::SchemaObjects::Table }
+ let(:valid_schema_object_name) { 'test_table' }
+ let(:expected_objects) do
+ %w[test_table ci_project_mirrors wrong_table extra_table_columns missing_table missing_table_columns
+ operations_user_lists]
+ end
+
+ include_examples 'structure sql schema assertions for', 'table_exists?', 'tables'
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/base_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/base_spec.rb
new file mode 100644
index 00000000000..50be1f1b373
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/base_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::Base, feature_category: :database do
+ describe '#execute' do
+ let(:structure_sql) { instance_double(Gitlab::Schema::Validation::Sources::StructureSql) }
+ let(:database) { instance_double(Gitlab::Schema::Validation::Sources::Database) }
+
+ subject(:inconsistencies) { described_class.new(structure_sql, database).execute }
+
+ describe '.all_validators' do
+ subject(:all_validators) { described_class.all_validators }
+
+ it 'returns an array of all validators' do
+ expect(all_validators).to eq([
+ Gitlab::Schema::Validation::Validators::ExtraTables,
+ Gitlab::Schema::Validation::Validators::ExtraTableColumns,
+ Gitlab::Schema::Validation::Validators::ExtraIndexes,
+ Gitlab::Schema::Validation::Validators::ExtraTriggers,
+ Gitlab::Schema::Validation::Validators::ExtraForeignKeys,
+ Gitlab::Schema::Validation::Validators::MissingTables,
+ Gitlab::Schema::Validation::Validators::MissingTableColumns,
+ Gitlab::Schema::Validation::Validators::MissingIndexes,
+ Gitlab::Schema::Validation::Validators::MissingTriggers,
+ Gitlab::Schema::Validation::Validators::MissingForeignKeys,
+ Gitlab::Schema::Validation::Validators::DifferentDefinitionTables,
+ Gitlab::Schema::Validation::Validators::DifferentDefinitionIndexes,
+ Gitlab::Schema::Validation::Validators::DifferentDefinitionTriggers,
+ Gitlab::Schema::Validation::Validators::DifferentDefinitionForeignKeys
+ ])
+ end
+ end
+
+ it 'raises an exception' do
+ expect { inconsistencies }.to raise_error(NoMethodError)
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_indexes_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_indexes_spec.rb
new file mode 100644
index 00000000000..c1795a56063
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_indexes_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::DifferentDefinitionIndexes do
+ include_examples 'index validators', described_class, ['wrong_index']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_tables_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_tables_spec.rb
new file mode 100644
index 00000000000..d1c9169a59a
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_tables_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::DifferentDefinitionTables, feature_category: :database do
+ include_examples 'table validators', described_class, ['wrong_table']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_triggers_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_triggers_spec.rb
new file mode 100644
index 00000000000..a2597e3e55e
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/different_definition_triggers_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::DifferentDefinitionTriggers,
+ feature_category: :database do
+ include_examples 'trigger validators', described_class, ['wrong_trigger']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_foreign_keys_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_foreign_keys_spec.rb
new file mode 100644
index 00000000000..499f2578fd2
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_foreign_keys_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::ExtraForeignKeys, feature_category: :database do
+ include_examples 'foreign key validators', described_class, ['public.extra_fk']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_indexes_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_indexes_spec.rb
new file mode 100644
index 00000000000..01498444ba4
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_indexes_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::ExtraIndexes, feature_category: :database do
+ include_examples 'index validators', described_class, ['extra_index']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_table_columns_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_table_columns_spec.rb
new file mode 100644
index 00000000000..f5d06e9941a
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_table_columns_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::ExtraTableColumns, feature_category: :database do
+ include_examples 'table validators', described_class, ['extra_table_columns']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_tables_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_tables_spec.rb
new file mode 100644
index 00000000000..15c52fe4719
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_tables_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::ExtraTables, feature_category: :database do
+ include_examples 'table validators', described_class, ['extra_table']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_triggers_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_triggers_spec.rb
new file mode 100644
index 00000000000..97126aebf05
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/extra_triggers_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::ExtraTriggers, feature_category: :database do
+ include_examples 'trigger validators', described_class, ['extra_trigger']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_foreign_keys_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_foreign_keys_spec.rb
new file mode 100644
index 00000000000..6682c3f623d
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_foreign_keys_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::MissingForeignKeys, feature_category: :database do
+ include_examples 'foreign key validators', described_class, %w[public.fk_rails_536b96bff1 public.missing_fk]
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_indexes_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_indexes_spec.rb
new file mode 100644
index 00000000000..c1cb9a2416b
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_indexes_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::MissingIndexes, feature_category: :database do
+ missing_indexes = %w[
+ missing_index
+ index_namespaces_public_groups_name_id
+ index_on_deploy_keys_id_and_type_and_public
+ index_users_on_public_email_excluding_null_and_empty
+ ]
+
+ include_examples 'index validators', described_class, missing_indexes
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_table_columns_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_table_columns_spec.rb
new file mode 100644
index 00000000000..3866bdce071
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_table_columns_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::MissingTableColumns, feature_category: :database do
+ include_examples 'table validators', described_class, ['missing_table_columns']
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_tables_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_tables_spec.rb
new file mode 100644
index 00000000000..8a73d67ab7d
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_tables_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::MissingTables, feature_category: :database do
+ missing_tables = %w[ci_project_mirrors missing_table operations_user_lists test_table]
+
+ include_examples 'table validators', described_class, missing_tables
+end
diff --git a/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_triggers_spec.rb b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_triggers_spec.rb
new file mode 100644
index 00000000000..82b9b034503
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/lib/gitlab/schema/validation/validators/missing_triggers_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Schema::Validation::Validators::MissingTriggers, feature_category: :database do
+ missing_triggers = %w[missing_trigger_1 projects_loose_fk_trigger]
+
+ include_examples 'trigger validators', described_class, missing_triggers
+end
diff --git a/gems/gitlab-schema-validation/spec/spec_helper.rb b/gems/gitlab-schema-validation/spec/spec_helper.rb
new file mode 100644
index 00000000000..c11c5021e3b
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/spec_helper.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "gitlab/schema/validation"
+require 'rspec-parameterized'
+
+RSpec.configure do |config|
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ Dir['./spec/support/**/*.rb'].each { |f| require f }
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/support/shared_examples/foreign_key_validators_shared_examples.rb b/gems/gitlab-schema-validation/spec/support/shared_examples/foreign_key_validators_shared_examples.rb
new file mode 100644
index 00000000000..1f33c8bd760
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/support/shared_examples/foreign_key_validators_shared_examples.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'foreign key validators' do |validator, expected_result|
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ let(:structure_file_path) { 'spec/fixtures/structure.sql' }
+ let(:structure_file) { Gitlab::Schema::Validation::Sources::StructureSql.new(structure_file_path, schema) }
+ let(:inconsistency_type) { validator.to_s }
+ let(:database_name) { 'main' }
+ let(:schema) { 'public' }
+ # rubocop:disable RSpec/VerifiedDoubleReference
+ let(:connection) { instance_double('connection', exec_query: database_query, current_schema: 'public') }
+ # rubocop:enable RSpec/VerifiedDoubleReference
+
+ let(:database) { Gitlab::Schema::Validation::Sources::Database.new(connection) }
+
+ let(:database_query) do
+ [
+ {
+ 'schema' => schema,
+ 'table_name' => 'web_hooks',
+ 'foreign_key_name' => 'web_hooks_project_id_fkey',
+ 'foreign_key_definition' => 'FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE'
+ },
+ {
+ 'schema' => schema,
+ 'table_name' => 'issues',
+ 'foreign_key_name' => 'wrong_definition_fk',
+ 'foreign_key_definition' => 'FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE'
+ },
+ {
+ 'schema' => schema,
+ 'table_name' => 'projects',
+ 'foreign_key_name' => 'extra_fk',
+ 'foreign_key_definition' => 'FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE'
+ }
+ ]
+ end
+
+ it 'returns trigger inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/support/shared_examples/index_validators_shared_examples.rb b/gems/gitlab-schema-validation/spec/support/shared_examples/index_validators_shared_examples.rb
new file mode 100644
index 00000000000..cc20c0dc765
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/support/shared_examples/index_validators_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'index validators' do |validator, expected_result|
+ let(:structure_file_path) { 'spec/fixtures/structure.sql' }
+ let(:database_indexes) do
+ [
+ ['wrong_index', 'CREATE UNIQUE INDEX wrong_index ON public.table_name (column_name)'],
+ ['extra_index', 'CREATE INDEX extra_index ON public.table_name (column_name)'],
+ ['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))']
+ ]
+ end
+
+ let(:inconsistency_type) { validator.name }
+
+ # rubocop:disable RSpec/VerifiedDoubleReference
+ let(:connection) { instance_double('connection', select_rows: database_indexes, current_schema: 'public') }
+ # rubocop:enable RSpec/VerifiedDoubleReference
+
+ let(:schema) { 'public' }
+
+ let(:database) { Gitlab::Schema::Validation::Sources::Database.new(connection) }
+ let(:structure_file) { Gitlab::Schema::Validation::Sources::StructureSql.new(structure_file_path, schema) }
+
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ it 'returns index inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/support/shared_examples/schema_objects_shared_examples.rb b/gems/gitlab-schema-validation/spec/support/shared_examples/schema_objects_shared_examples.rb
new file mode 100644
index 00000000000..994b30b0941
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/support/shared_examples/schema_objects_shared_examples.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'schema objects assertions for' do |stmt_name|
+ let(:stmt) { PgQuery.parse(statement).tree.stmts.first.stmt }
+ let(:schema_object) { described_class.new(stmt.public_send(stmt_name)) }
+
+ describe '#name' do
+ it 'returns schema object name' do
+ expect(schema_object.name).to eq(name)
+ end
+ end
+
+ describe '#statement' do
+ it 'returns schema object statement' do
+ expect(schema_object.statement).to eq(statement)
+ end
+ end
+
+ describe '#table_name' do
+ it 'returns schema object table_name' do
+ expect(schema_object.table_name).to eq(table_name)
+ end
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/support/shared_examples/table_validators_shared_examples.rb b/gems/gitlab-schema-validation/spec/support/shared_examples/table_validators_shared_examples.rb
new file mode 100644
index 00000000000..d2a51a9b202
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/support/shared_examples/table_validators_shared_examples.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples "table validators" do |validator, expected_result|
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ let(:structure_file_path) { 'spec/fixtures/structure.sql' }
+ let(:inconsistency_type) { validator.to_s }
+ # rubocop:disable RSpec/VerifiedDoubleReference
+ let(:connection) { instance_double('connection', exec_query: database_tables, current_schema: 'public') }
+ # rubocop:enable RSpec/VerifiedDoubleReference
+ let(:schema) { 'public' }
+ let(:database) { Gitlab::Schema::Validation::Sources::Database.new(connection) }
+ let(:structure_file) { Gitlab::Schema::Validation::Sources::StructureSql.new(structure_file_path, schema) }
+ let(:database_tables) do
+ [
+ {
+ 'table_name' => 'wrong_table',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'integer',
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'wrong_table',
+ 'column_name' => 'description',
+ 'not_null' => true,
+ 'data_type' => 'character varying',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'extra_table',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'integer',
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'extra_table',
+ 'column_name' => 'email',
+ 'not_null' => true,
+ 'data_type' => 'character varying',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'extra_table_columns',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'bigint',
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'extra_table_columns',
+ 'column_name' => 'name',
+ 'not_null' => true,
+ 'data_type' => 'character varying(255)',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'extra_table_columns',
+ 'column_name' => 'extra_column',
+ 'not_null' => true,
+ 'data_type' => 'character varying(255)',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'missing_table_columns',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'bigint',
+ 'column_default' => 'NOT NULL'
+ }
+ ]
+ end
+
+ it 'returns table inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/gems/gitlab-schema-validation/spec/support/shared_examples/trigger_validators_shared_examples.rb b/gems/gitlab-schema-validation/spec/support/shared_examples/trigger_validators_shared_examples.rb
new file mode 100644
index 00000000000..45ed87082bb
--- /dev/null
+++ b/gems/gitlab-schema-validation/spec/support/shared_examples/trigger_validators_shared_examples.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'trigger validators' do |validator, expected_result|
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ let(:structure_file_path) { 'spec/fixtures/structure.sql' }
+ let(:structure_file) { Gitlab::Schema::Validation::Sources::StructureSql.new(structure_file_path, schema) }
+ let(:inconsistency_type) { validator.to_s }
+ let(:database_name) { 'main' }
+ let(:schema) { 'public' }
+ let(:database) { Gitlab::Schema::Validation::Sources::Database.new(connection) }
+
+ # rubocop:disable RSpec/VerifiedDoubleReference
+ let(:connection) { instance_double('connection', select_rows: database_triggers, current_schema: 'public') }
+ # rubocop:enable RSpec/VerifiedDoubleReference
+
+ let(:database_triggers) do
+ [
+ ['trigger', 'CREATE TRIGGER trigger AFTER INSERT ON public.t1 FOR EACH ROW EXECUTE FUNCTION t1()'],
+ ['wrong_trigger', 'CREATE TRIGGER wrong_trigger BEFORE UPDATE ON public.t2 FOR EACH ROW EXECUTE FUNCTION t2()'],
+ ['extra_trigger', 'CREATE TRIGGER extra_trigger BEFORE INSERT ON public.t4 FOR EACH ROW EXECUTE FUNCTION t4()']
+ ]
+ end
+
+ it 'returns trigger inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/gems/gitlab-utils/.gitignore b/gems/gitlab-utils/.gitignore
new file mode 100644
index 00000000000..b04a8c840df
--- /dev/null
+++ b/gems/gitlab-utils/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/gems/gitlab-utils/.gitlab-ci.yml b/gems/gitlab-utils/.gitlab-ci.yml
new file mode 100644
index 00000000000..a9e984d4785
--- /dev/null
+++ b/gems/gitlab-utils/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "gitlab-utils"
diff --git a/gems/gitlab-utils/.rspec b/gems/gitlab-utils/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/gitlab-utils/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/gitlab-utils/.rubocop.yml b/gems/gitlab-utils/.rubocop.yml
new file mode 100644
index 00000000000..eeafd850c9b
--- /dev/null
+++ b/gems/gitlab-utils/.rubocop.yml
@@ -0,0 +1,27 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+RSpec/InstanceVariable:
+ Exclude:
+ - spec/**/*.rb
+
+Lint/BinaryOperatorWithIdenticalOperands:
+ Exclude:
+ - spec/**/*.rb
+
+# We use EnforcedStyle of comparison here due to it being better
+# performing code as seen in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36221#note_375659681
+Style/NumericPredicate:
+ EnforcedStyle: comparison
+
+# FIXME: When enabled, there's a spec failure in ee/spec/requests/api/graphql/mutations/merge_requests/update_approval_rule_spec.rb:51,
+# due to the `default_value` of `remove_hidden_groups` set to `[]`, most probably instead of `false`, in ee/app/graphql/mutations/merge_requests/update_approval_rule.rb.
+# The problem is that `Object#=~` exists (even though it's deprecated), hence calling it on an `Array` doesn't blow up, but `Array#match?` doesn't exist.
+Performance/RegexpMatch:
+ Exclude:
+ - lib/gitlab/utils.rb
+
+Rails/OutputSafety:
+ Details: grace period
+ Exclude:
+ - 'lib/gitlab/utils.rb'
diff --git a/gems/gitlab-utils/Gemfile b/gems/gitlab-utils/Gemfile
new file mode 100644
index 00000000000..2c7228c874c
--- /dev/null
+++ b/gems/gitlab-utils/Gemfile
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in gitlab-utils.gemspec
+gemspec
+
+group :development, :test do
+ gem 'gitlab-rspec', path: '../gitlab-rspec'
+end
diff --git a/gems/gitlab-utils/Gemfile.lock b/gems/gitlab-utils/Gemfile.lock
new file mode 100644
index 00000000000..971d90ce146
--- /dev/null
+++ b/gems/gitlab-utils/Gemfile.lock
@@ -0,0 +1,199 @@
+PATH
+ remote: ../gitlab-rspec
+ specs:
+ gitlab-rspec (0.1.0)
+ activesupport (>= 6.1, < 7.1)
+ rspec (~> 3.0)
+
+PATH
+ remote: .
+ specs:
+ gitlab-utils (0.1.0)
+ actionview (>= 6.1.7.2)
+ activesupport (>= 6.1.7.2)
+ addressable (~> 2.8)
+ nokogiri (~> 1.15.2)
+ rake (~> 13.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actionpack (7.0.5)
+ actionview (= 7.0.5)
+ activesupport (= 7.0.5)
+ rack (~> 2.0, >= 2.2.4)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
+ actionview (7.0.5)
+ activesupport (= 7.0.5)
+ builder (~> 3.1)
+ erubi (~> 1.4)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
+ activesupport (7.0.5)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ addressable (2.8.1)
+ public_suffix (>= 2.0.2, < 6.0)
+ ast (2.4.2)
+ benchmark-malloc (0.2.0)
+ benchmark-perf (0.6.0)
+ benchmark-trend (0.4.0)
+ binding_of_caller (1.0.0)
+ debug_inspector (>= 0.0.1)
+ builder (3.2.4)
+ coderay (1.1.3)
+ concurrent-ruby (1.2.2)
+ crass (1.0.6)
+ debug_inspector (1.1.0)
+ diff-lcs (1.5.0)
+ erubi (1.12.0)
+ factory_bot (6.2.1)
+ activesupport (>= 5.0.0)
+ factory_bot_rails (6.2.0)
+ factory_bot (~> 6.2.0)
+ railties (>= 5.0.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ loofah (2.21.3)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ method_source (1.0.0)
+ mini_portile2 (2.8.2)
+ minitest (5.18.1)
+ nokogiri (1.15.2)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ public_suffix (5.0.0)
+ racc (1.7.1)
+ rack (2.2.7)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rails-dom-testing (2.0.3)
+ activesupport (>= 4.2.0)
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
+ railties (7.0.5)
+ actionpack (= 7.0.5)
+ activesupport (= 7.0.5)
+ method_source
+ rake (>= 12.2)
+ thor (~> 1.0)
+ zeitwerk (~> 2.5)
+ rainbow (3.1.1)
+ rake (13.0.6)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-benchmark (0.6.0)
+ benchmark-malloc (~> 0.2)
+ benchmark-perf (~> 0.6)
+ benchmark-trend (~> 0.4)
+ rspec (>= 3.0)
+ rspec-core (3.12.0)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.2)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-parameterized (1.0.0)
+ rspec-parameterized-core (< 2)
+ rspec-parameterized-table_syntax (< 2)
+ rspec-parameterized-core (1.0.0)
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-parameterized-table_syntax (1.0.0)
+ binding_of_caller
+ rspec-parameterized-core (< 2)
+ rspec-rails (6.0.1)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ railties (>= 6.1)
+ rspec-core (~> 3.11)
+ rspec-expectations (~> 3.11)
+ rspec-mocks (~> 3.11)
+ rspec-support (~> 3.11)
+ rspec-support (3.12.0)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ thor (1.2.2)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+ unparser (0.6.7)
+ diff-lcs (~> 1.3)
+ parser (>= 3.2.0)
+ zeitwerk (2.6.8)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ factory_bot_rails (~> 6.2.0)
+ gitlab-rspec!
+ gitlab-styles (~> 10.1.0)
+ gitlab-utils!
+ rspec (~> 3.12)
+ rspec-benchmark (~> 0.6.0)
+ rspec-parameterized (~> 1.0)
+ rspec-rails (~> 6.0.1)
+ rubocop (~> 1.50)
+ rubocop-rspec (~> 2.22)
+
+BUNDLED WITH
+ 2.4.4
diff --git a/gems/gitlab-utils/README.md b/gems/gitlab-utils/README.md
new file mode 100644
index 00000000000..f7c7d83888b
--- /dev/null
+++ b/gems/gitlab-utils/README.md
@@ -0,0 +1,8 @@
+# Gitlab::Utils
+
+This Gem contains all code that is not dependent on application code
+or business logic and provides a generic functions like:
+
+- safe parsing of YAML
+- version comparisions
+- `strong_memoize`
diff --git a/gems/gitlab-utils/Rakefile b/gems/gitlab-utils/Rakefile
new file mode 100644
index 00000000000..cca71754493
--- /dev/null
+++ b/gems/gitlab-utils/Rakefile
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require "bundler/gem_tasks"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+require "rubocop/rake_task"
+
+RuboCop::RakeTask.new
+
+task default: %i[spec rubocop]
diff --git a/gems/gitlab-utils/gitlab-utils.gemspec b/gems/gitlab-utils/gitlab-utils.gemspec
new file mode 100644
index 00000000000..d5f6deb7fe6
--- /dev/null
+++ b/gems/gitlab-utils/gitlab-utils.gemspec
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require_relative "lib/gitlab/utils/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "gitlab-utils"
+ spec.version = Gitlab::Utils::Version::VERSION
+ spec.authors = ["group::tenant scale"]
+ spec.email = ["engineering@gitlab.com"]
+
+ spec.summary = "GitLab common helper methods"
+ spec.description = "A set of useful helpers methods to perform various conversions and checks."
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-utils"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.0"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir["lib/**/*.rb"]
+ spec.require_paths = ["lib"]
+
+ spec.add_runtime_dependency "actionview", ">= 6.1.7.2"
+ spec.add_runtime_dependency "activesupport", ">= 6.1.7.2"
+ spec.add_runtime_dependency "addressable", "~> 2.8"
+ spec.add_runtime_dependency "nokogiri", "~> 1.15.2"
+ spec.add_runtime_dependency "rake", "~> 13.0"
+
+ spec.add_development_dependency "factory_bot_rails", "~> 6.2.0"
+ spec.add_development_dependency "gitlab-styles", "~> 10.1.0"
+ spec.add_development_dependency "rspec", "~> 3.12"
+ spec.add_development_dependency "rspec-benchmark", "~> 0.6.0"
+ spec.add_development_dependency "rspec-parameterized", "~> 1.0"
+ spec.add_development_dependency "rspec-rails", "~> 6.0.1"
+ spec.add_development_dependency "rubocop", "~> 1.50"
+ spec.add_development_dependency "rubocop-rspec", "~> 2.22"
+end
diff --git a/gems/gitlab-utils/lib/gitlab/utils.rb b/gems/gitlab-utils/lib/gitlab/utils.rb
new file mode 100644
index 00000000000..4e08ee8fcaf
--- /dev/null
+++ b/gems/gitlab-utils/lib/gitlab/utils.rb
@@ -0,0 +1,263 @@
+# frozen_string_literal: true
+
+require "addressable/uri"
+require "active_support/all"
+require "action_view"
+
+module Gitlab
+ module Utils
+ extend self
+ DoubleEncodingError = Class.new(StandardError)
+
+ def allowlisted?(absolute_path, allowlist)
+ path = absolute_path.downcase
+
+ allowlist.map(&:downcase).any? do |allowed_path|
+ path.start_with?(allowed_path)
+ end
+ end
+
+ def decode_path(encoded_path)
+ decoded = CGI.unescape(encoded_path)
+ if decoded != CGI.unescape(decoded) # rubocop:disable Style/IfUnlessModifier
+ raise DoubleEncodingError, "path #{encoded_path} is not allowed"
+ end
+
+ decoded
+ end
+
+ def force_utf8(str)
+ str.dup.force_encoding(Encoding::UTF_8)
+ end
+
+ def ensure_utf8_size(str, bytes:)
+ raise ArgumentError, 'Empty string provided!' if str.empty?
+ raise ArgumentError, 'Negative string size provided!' if bytes < 0
+
+ truncated = str.each_char.each_with_object(+'') do |char, object|
+ if object.bytesize + char.bytesize > bytes # rubocop:disable Style/GuardClause
+ break object
+ else
+ object.concat(char)
+ end
+ end
+
+ truncated + ('0' * (bytes - truncated.bytesize))
+ end
+
+ # Append path to host, making sure there's one single / in between
+ def append_path(host, path)
+ "#{host.to_s.sub(%r{\/+$}, '')}/#{remove_leading_slashes(path)}" # rubocop:disable Style/RedundantRegexpEscape
+ end
+
+ def remove_leading_slashes(str)
+ str.to_s.sub(%r{^/+}, '')
+ end
+
+ # A slugified version of the string, suitable for inclusion in URLs and
+ # domain names. Rules:
+ #
+ # * Lowercased
+ # * Anything not matching [a-z0-9-] is replaced with a -
+ # * Maximum length is 63 bytes
+ # * First/Last Character is not a hyphen
+ def slugify(str)
+ str.downcase
+ .gsub(/[^a-z0-9]/, '-')[0..62]
+ .gsub(/(\A-+|-+\z)/, '')
+ end
+
+ # Converts newlines into HTML line break elements
+ def nlbr(str)
+ ActionView::Base.full_sanitizer.sanitize(+str, tags: []).gsub(/\r?\n/, '<br>').html_safe
+ end
+
+ def remove_line_breaks(str)
+ str.gsub(/\r?\n/, '')
+ end
+
+ def to_boolean(value, default: nil)
+ value = value.to_s if [0, 1].include?(value)
+
+ return value if [true, false].include?(value)
+ return true if value =~ /^(true|t|yes|y|1|on)$/i
+ return false if value =~ /^(false|f|no|n|0|off)$/i
+
+ default
+ end
+
+ def boolean_to_yes_no(bool)
+ if bool
+ 'Yes'
+ else
+ 'No'
+ end
+ end
+
+ # Behaves like `which` on Linux machines: given PATH, try to resolve the given
+ # executable name to an absolute path, or return nil.
+ #
+ # which('ruby') #=> /usr/bin/ruby
+ def which(filename)
+ ENV['PATH']&.split(File::PATH_SEPARATOR)&.each do |path|
+ full_path = File.join(path, filename)
+ return full_path if File.executable?(full_path)
+ end
+
+ nil
+ end
+
+ def try_megabytes_to_bytes(size)
+ Integer(size).megabytes
+ rescue ArgumentError
+ size
+ end
+
+ def bytes_to_megabytes(bytes)
+ bytes.to_f / Numeric::MEGABYTE
+ end
+
+ def ms_to_round_sec(ms)
+ (ms.to_f / 1000).round(6)
+ end
+
+ # Used in EE
+ # Accepts either an Array or a String and returns an array
+ def ensure_array_from_string(string_or_array)
+ return string_or_array if string_or_array.is_a?(Array)
+
+ string_or_array.split(',').map(&:strip)
+ end
+
+ def deep_indifferent_access(data)
+ case data
+ when Array
+ data.map { |item| deep_indifferent_access(item) }
+ when Hash
+ data.with_indifferent_access
+ else
+ data
+ end
+ end
+
+ def deep_symbolized_access(data)
+ case data
+ when Array
+ data.map { |item| deep_symbolized_access(item) }
+ when Hash
+ data.deep_symbolize_keys
+ else
+ data
+ end
+ end
+
+ def string_to_ip_object(str)
+ return unless str
+
+ IPAddr.new(str)
+ rescue IPAddr::InvalidAddressError
+ end
+
+ # A safe alternative to String#downcase!
+ #
+ # This will make copies of frozen strings but downcase unfrozen
+ # strings in place, reducing allocations.
+ def safe_downcase!(str)
+ if str.frozen?
+ str.downcase
+ else
+ str.downcase! || str
+ end
+ end
+
+ # Converts a string to an Addressable::URI object.
+ # If the string is not a valid URI, it returns nil.
+ # Param uri_string should be a String object.
+ # This method returns an Addressable::URI object or nil.
+ def parse_url(uri_string)
+ Addressable::URI.parse(uri_string)
+ rescue Addressable::URI::InvalidURIError, TypeError
+ end
+
+ def add_url_parameters(url, params)
+ uri = parse_url(url.to_s)
+ uri.query_values = uri.query_values.to_h.merge(params.to_h.stringify_keys)
+ uri.query_values = nil if uri.query_values.empty?
+ uri.to_s
+ end
+
+ def removes_sensitive_data_from_url(uri_string)
+ uri = parse_url(uri_string)
+
+ return unless uri
+ return uri_string unless uri.fragment
+
+ stripped_params = CGI.parse(uri.fragment)
+ if stripped_params['access_token']
+ stripped_params['access_token'] = 'filtered'
+ filtered_query = Addressable::URI.new
+ filtered_query.query_values = stripped_params
+
+ uri.fragment = filtered_query.query
+ end
+
+ uri.to_s
+ end
+
+ # Invert a hash, collecting all keys that map to a given value in an array.
+ #
+ # Unlike `Hash#invert`, where the last encountered pair wins, and which has the
+ # type `Hash[k, v] => Hash[v, k]`, `multiple_key_invert` does not lose any
+ # information, has the type `Hash[k, v] => Hash[v, Array[k]]`, and the original
+ # hash can always be reconstructed.
+ #
+ # example:
+ #
+ # multiple_key_invert({ a: 1, b: 2, c: 1 })
+ # # => { 1 => [:a, :c], 2 => [:b] }
+ #
+ def multiple_key_invert(hash)
+ hash.flat_map { |k, v| Array.wrap(v).zip([k].cycle) }
+ .group_by(&:first)
+ .transform_values { |kvs| kvs.map(&:last) }
+ end
+
+ # This sort is stable (see https://en.wikipedia.org/wiki/Sorting_algorithm#Stability)
+ # contrary to the bare Ruby sort_by method. Using just sort_by leads to
+ # instability across different platforms (e.g., x86_64-linux and x86_64-darwin18)
+ # which in turn leads to different sorting results for the equal elements across
+ # these platforms.
+ # This method uses a list item's original index position to break ties.
+ def stable_sort_by(list)
+ list.sort_by.with_index { |x, idx| [yield(x), idx] }
+ end
+
+ # Check for valid brackets (`[` and `]`) in a string using this aspects:
+ # * open brackets count == closed brackets count
+ # * (optionally) reject nested brackets via `allow_nested: false`
+ # * open / close brackets coherence, eg. ][[] -> invalid
+ def valid_brackets?(string = '', allow_nested: true)
+ # remove everything except brackets
+ brackets = string.remove(/[^\[\]]/)
+
+ return true if brackets.empty?
+ # balanced counts check
+ return false if brackets.size.odd?
+
+ unless allow_nested
+ # nested brackets check
+ return false if brackets.include?('[[') || brackets.include?(']]') # rubocop:disable Style/SoleNestedConditional
+ end
+
+ # open / close brackets coherence check
+ untrimmed = brackets
+ loop do
+ trimmed = untrimmed.gsub('[]', '')
+ return true if trimmed.empty?
+ return false if trimmed == untrimmed
+
+ untrimmed = trimmed
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-utils/lib/gitlab/utils/all.rb b/gems/gitlab-utils/lib/gitlab/utils/all.rb
new file mode 100644
index 00000000000..200a21aad88
--- /dev/null
+++ b/gems/gitlab-utils/lib/gitlab/utils/all.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require_relative "../utils"
+require_relative "../version_info"
+require_relative "version"
+require_relative "strong_memoize"
diff --git a/gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb b/gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb
new file mode 100644
index 00000000000..2b3841b8f09
--- /dev/null
+++ b/gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Utils
+ module StrongMemoize
+ # Instead of writing patterns like this:
+ #
+ # def trigger_from_token
+ # return @trigger if defined?(@trigger)
+ #
+ # @trigger = Ci::Trigger.find_by_token(params[:token].to_s)
+ # end
+ #
+ # We could write it like:
+ #
+ # include Gitlab::Utils::StrongMemoize
+ #
+ # def trigger_from_token
+ # Ci::Trigger.find_by_token(params[:token].to_s)
+ # end
+ # strong_memoize_attr :trigger_from_token
+ #
+ # def enabled?
+ # Feature.enabled?(:some_feature)
+ # end
+ # strong_memoize_attr :enabled?
+ #
+ def strong_memoize(name)
+ key = ivar(name)
+
+ if instance_variable_defined?(key)
+ instance_variable_get(key)
+ else
+ instance_variable_set(key, yield)
+ end
+ end
+
+ # Works the same way as "strong_memoize" but takes
+ # a second argument - expire_in. This allows invalidate
+ # the data after specified number of seconds
+ def strong_memoize_with_expiration(name, expire_in)
+ key = ivar(name)
+ expiration_key = "#{key}_expired_at"
+
+ if instance_variable_defined?(expiration_key)
+ expire_at = instance_variable_get(expiration_key)
+ clear_memoization(name) if Time.current > expire_at
+ end
+
+ if instance_variable_defined?(key)
+ instance_variable_get(key)
+ else
+ value = instance_variable_set(key, yield)
+ instance_variable_set(expiration_key, Time.current + expire_in)
+ value
+ end
+ end
+
+ def strong_memoize_with(name, *args)
+ container = strong_memoize(name) { {} }
+
+ if container.key?(args)
+ container[args]
+ else
+ container[args] = yield
+ end
+ end
+
+ def strong_memoized?(name)
+ key = ivar(StrongMemoize.normalize_key(name))
+ instance_variable_defined?(key)
+ end
+
+ def clear_memoization(name)
+ key = ivar(StrongMemoize.normalize_key(name))
+ remove_instance_variable(key) if instance_variable_defined?(key)
+ end
+
+ module StrongMemoizeClassMethods
+ def strong_memoize_attr(method_name)
+ member_name = StrongMemoize.normalize_key(method_name)
+
+ StrongMemoize.send(:do_strong_memoize, self, method_name, member_name) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ def self.included(base)
+ base.singleton_class.prepend(StrongMemoizeClassMethods)
+ end
+
+ private
+
+ # Convert `"name"`/`:name` into `:@name`
+ #
+ # Depending on a type ensure that there's a single memory allocation
+ def ivar(name)
+ case name
+ when Symbol
+ name.to_s.prepend("@").to_sym
+ when String
+ :"@#{name}"
+ else
+ raise ArgumentError, "Invalid type of '#{name}'"
+ end
+ end
+
+ class << self
+ def normalize_key(key)
+ return key unless key.end_with?('!', '?')
+
+ # Replace invalid chars like `!` and `?` with allowed Unicode codeparts.
+ key.to_s.tr('!?', "\uFF01\uFF1F")
+ end
+
+ private
+
+ def do_strong_memoize(klass, method_name, member_name)
+ method = klass.instance_method(method_name)
+
+ unless method.arity == 0
+ raise <<~ERROR
+ Using `strong_memoize_attr` on methods with parameters is not supported.
+
+ Use `strong_memoize_with` instead.
+ See https://docs.gitlab.com/ee/development/utilities.html#strongmemoize
+ ERROR
+ end
+
+ # Methods defined within a class method are already public by default, so we don't need to
+ # explicitly make them public.
+ scope = %i[private protected].find do |scope|
+ klass.send("#{scope}_instance_methods") # rubocop:disable GitlabSecurity/PublicSend
+ .include? method_name
+ end
+
+ klass.define_method(method_name) do |&block|
+ strong_memoize(member_name) do
+ method.bind_call(self, &block)
+ end
+ end
+
+ klass.send(scope, method_name) if scope # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-utils/lib/gitlab/utils/version.rb b/gems/gitlab-utils/lib/gitlab/utils/version.rb
new file mode 100644
index 00000000000..a9afe5bf845
--- /dev/null
+++ b/gems/gitlab-utils/lib/gitlab/utils/version.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Utils
+ module Version
+ VERSION = "0.1.0"
+ end
+ end
+end
diff --git a/gems/gitlab-utils/lib/gitlab/version_info.rb b/gems/gitlab-utils/lib/gitlab/version_info.rb
new file mode 100644
index 00000000000..00a9b4ddc6e
--- /dev/null
+++ b/gems/gitlab-utils/lib/gitlab/version_info.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class VersionInfo
+ include Comparable
+
+ attr_reader :major, :minor, :patch
+
+ VERSION_REGEX = /(\d+)\.(\d+)\.(\d+)/
+ # To mitigate ReDoS, limit the length of the version string we're
+ # willing to check
+ MAX_VERSION_LENGTH = 128
+
+ def self.parse(str, parse_suffix: false)
+ return str if str.is_a?(self)
+
+ if str && str.length <= MAX_VERSION_LENGTH
+ match = str.match(VERSION_REGEX)
+ if match
+ return VersionInfo.new(match[1].to_i, match[2].to_i, match[3].to_i, parse_suffix ? match.post_match : nil)
+ end
+ end
+
+ VersionInfo.new
+ end
+
+ def initialize(major = 0, minor = 0, patch = 0, suffix = nil) # rubocop:disable Metrics/ParameterLists
+ @major = major
+ @minor = minor
+ @patch = patch
+ @suffix_s = suffix.to_s
+ end
+
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def <=>(other)
+ return unless other.is_a? VersionInfo
+ return unless valid? && other.valid?
+
+ if other.major < @major
+ 1
+ elsif @major < other.major
+ -1
+ elsif other.minor < @minor
+ 1
+ elsif @minor < other.minor
+ -1
+ elsif other.patch < @patch
+ 1
+ elsif @patch < other.patch
+ -1
+ elsif @suffix_s.empty? && other.suffix.present?
+ 1
+ elsif other.suffix.empty? && @suffix_s.present?
+ -1
+ else
+ suffix <=> other.suffix
+ end
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
+
+ def to_s
+ if valid?
+ "%d.%d.%d%s" % [@major, @minor, @patch, @suffix_s] # rubocop:disable Style/FormatString
+ else
+ 'Unknown'
+ end
+ end
+
+ def to_json(*_args)
+ { major: @major, minor: @minor, patch: @patch }.to_json
+ end
+
+ def suffix
+ @suffix ||= @suffix_s.strip.gsub('-', '.pre.').scan(/\d+|[a-z]+/i).map do |s|
+ /^\d+$/.match?(s) ? s.to_i : s
+ end.freeze
+ end
+
+ def valid?
+ @major >= 0 && @minor >= 0 && @patch >= 0 && @major + @minor + @patch > 0
+ end
+
+ def hash
+ [self.class, to_s].hash
+ end
+
+ def eql?(other)
+ (self <=> other) == 0
+ end
+
+ def same_minor_version?(other)
+ @major == other.major && @minor == other.minor
+ end
+
+ def without_patch
+ self.class.new(@major, @minor, 0)
+ end
+ end
+end
diff --git a/gems/gitlab-utils/spec/gitlab/utils/strong_memoize_spec.rb b/gems/gitlab-utils/spec/gitlab/utils/strong_memoize_spec.rb
new file mode 100644
index 00000000000..f23a12ca6a2
--- /dev/null
+++ b/gems/gitlab-utils/spec/gitlab/utils/strong_memoize_spec.rb
@@ -0,0 +1,368 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'active_support/testing/time_helpers'
+
+RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :shared do
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:klass) do
+ strong_memoize_class = described_class
+
+ Struct.new(:value) do
+ include strong_memoize_class
+
+ def self.method_added_list
+ @method_added_list ||= []
+ end
+
+ def self.method_added(name)
+ method_added_list << name
+ end
+
+ def method_name
+ strong_memoize(:method_name) do # rubocop:disable Gitlab/StrongMemoizeAttr
+ trace << value
+ value
+ end
+ end
+
+ def method_name_with_expiration
+ strong_memoize_with_expiration(:method_name_with_expiration, 1) do
+ trace << value
+ value
+ end
+ end
+
+ def method_name_attr
+ trace << value
+ value
+ end
+ strong_memoize_attr :method_name_attr
+
+ def enabled?
+ trace << value
+ value
+ end
+ strong_memoize_attr :enabled?
+
+ def method_name_with_args(*args)
+ strong_memoize_with(:method_name_with_args, args) do
+ trace << [value, args]
+ value
+ end
+ end
+
+ def trace
+ @trace ||= []
+ end
+
+ protected
+
+ def private_method; end
+ private :private_method
+ strong_memoize_attr :private_method
+
+ public
+
+ def protected_method; end
+ protected :protected_method
+ strong_memoize_attr :protected_method
+
+ private
+
+ def public_method; end
+ public :public_method
+ strong_memoize_attr :public_method
+ end
+ end
+
+ subject(:object) { klass.new(value) }
+
+ shared_examples 'caching the value' do
+ let(:member_name) { described_class.normalize_key(method_name) }
+
+ it 'only calls the block once' do
+ value0 = object.public_send(method_name)
+ value1 = object.public_send(method_name)
+
+ expect(value0).to eq(value)
+ expect(value1).to eq(value)
+ expect(object.trace).to contain_exactly(value)
+ end
+
+ it 'returns and defines the instance variable for the exact value' do
+ returned_value = object.public_send(method_name)
+ memoized_value = object.instance_variable_get(:"@#{member_name}")
+
+ expect(returned_value).to eql(value)
+ expect(memoized_value).to eql(value)
+ end
+ end
+
+ describe '#strong_memoize' do
+ [nil, false, true, 'value', 0, [0]].each do |value|
+ context "with value #{value}" do
+ let(:value) { value }
+ let(:method_name) { :method_name }
+
+ it_behaves_like 'caching the value'
+
+ it 'raises exception for invalid type as key' do
+ expect { object.strong_memoize(10) { 20 } }.to raise_error(/Invalid type of '10'/)
+ end
+
+ it 'raises exception for invalid characters in key' do
+ expect { object.strong_memoize(:enabled?) { 20 } }
+ .to raise_error(/is not allowed as an instance variable name/)
+ end
+ end
+ end
+
+ context "with memory allocation", type: :benchmark do
+ let(:value) { 'aaa' }
+
+ before do
+ object.method_name # warmup
+ end
+
+ [:method_name, "method_name"].each do |argument|
+ context "when argument is a #{argument.class}" do
+ it 'does allocate exactly one string when fetching value' do
+ expect do
+ object.strong_memoize(argument) { 10 }
+ end.to perform_allocation(1)
+ end
+
+ it 'does allocate exactly one string when storing value' do
+ object.clear_memoization(:method_name) # clear to force set
+
+ expect do
+ object.strong_memoize(argument) { 10 }
+ end.to perform_allocation(1)
+ end
+ end
+ end
+ end
+ end
+
+ describe '#strong_memoize_with_expiration' do
+ [nil, false, true, 'value', 0, [0]].each do |value|
+ context "with value #{value}" do
+ let(:value) { value }
+ let(:method_name) { :method_name_with_expiration }
+
+ it_behaves_like 'caching the value'
+
+ it 'raises exception for invalid type as key' do
+ expect { object.strong_memoize_with_expiration(10, 1) { 20 } }.to raise_error(/Invalid type of '10'/)
+ end
+
+ it 'raises exception for invalid characters in key' do
+ expect { object.strong_memoize_with_expiration(:enabled?, 1) { 20 } }
+ .to raise_error(/is not allowed as an instance variable name/)
+ end
+ end
+ end
+
+ context 'with value memoization test' do
+ let(:value) { 'value' }
+
+ it 'caches the value for specified number of seconds' do
+ object.method_name_with_expiration
+ object.method_name_with_expiration
+
+ expect(object.trace.count).to eq(1)
+
+ travel_to(Time.current + 2.seconds) do
+ object.method_name_with_expiration
+
+ expect(object.trace.count).to eq(2)
+ end
+ end
+ end
+ end
+
+ describe '#strong_memoize_with' do
+ [nil, false, true, 'value', 0, [0]].each do |value|
+ context "with value #{value}" do
+ let(:value) { value }
+
+ it 'only calls the block once' do
+ value0 = object.method_name_with_args(1)
+ value1 = object.method_name_with_args(1)
+ value2 = object.method_name_with_args([2, 3])
+ value3 = object.method_name_with_args([2, 3])
+
+ expect(value0).to eq(value)
+ expect(value1).to eq(value)
+ expect(value2).to eq(value)
+ expect(value3).to eq(value)
+
+ expect(object.trace).to contain_exactly([value, [1]], [value, [[2, 3]]])
+ end
+
+ it 'returns and defines the instance variable for the exact value' do
+ returned_value = object.method_name_with_args(1, 2, 3)
+ memoized_value = object.instance_variable_get(:@method_name_with_args)
+
+ expect(returned_value).to eql(value)
+ expect(memoized_value).to eql({ [[1, 2, 3]] => value })
+ end
+ end
+ end
+ end
+
+ describe '#strong_memoized?' do
+ shared_examples 'memoization check' do |method_name|
+ context "when method is :#{method_name}" do
+ let(:value) { :anything }
+
+ subject { object.strong_memoized?(method_name) }
+
+ it 'returns false if the value is uncached' do
+ expect(subject).to be(false)
+ end
+
+ it 'returns true if the value is cached' do
+ object.public_send(method_name)
+
+ expect(subject).to be(true)
+ end
+ end
+ end
+
+ it_behaves_like 'memoization check', :method_name
+ it_behaves_like 'memoization check', :enabled?
+ end
+
+ describe '#clear_memoization' do
+ shared_examples 'clearing memoization' do |method_name|
+ let(:member_name) { described_class.normalize_key(method_name) }
+ let(:value) { 'mepmep' }
+
+ it 'removes the instance variable' do
+ object.public_send(method_name)
+
+ object.clear_memoization(method_name)
+
+ expect(object.instance_variable_defined?(:"@#{member_name}")).to be(false)
+ end
+ end
+
+ it_behaves_like 'clearing memoization', :method_name
+ it_behaves_like 'clearing memoization', :enabled?
+ end
+
+ describe '.strong_memoize_attr' do
+ [nil, false, true, 'value', 0, [0]].each do |value|
+ context "with value '#{value}'" do
+ let(:value) { value }
+
+ context 'with memoized after method definition' do
+ let(:method_name) { :method_name_attr }
+
+ it_behaves_like 'caching the value'
+
+ it 'calls the existing .method_added' do
+ expect(klass.method_added_list).to include(:method_name_attr)
+ end
+
+ it 'retains method arity' do
+ expect(klass.instance_method(method_name).arity).to eq(0)
+ end
+ end
+ end
+ end
+
+ describe 'method visibility' do
+ it 'sets private visibility' do
+ expect(klass.private_instance_methods).to include(:private_method)
+ expect(klass.protected_instance_methods).not_to include(:private_method)
+ expect(klass.public_instance_methods).not_to include(:private_method)
+ end
+
+ it 'sets protected visibility' do
+ expect(klass.private_instance_methods).not_to include(:protected_method)
+ expect(klass.protected_instance_methods).to include(:protected_method)
+ expect(klass.public_instance_methods).not_to include(:protected_method)
+ end
+
+ it 'sets public visibility' do
+ expect(klass.private_instance_methods).not_to include(:public_method)
+ expect(klass.protected_instance_methods).not_to include(:public_method)
+ expect(klass.public_instance_methods).to include(:public_method)
+ end
+ end
+
+ context "when method doesn't exist" do
+ let(:klass) do
+ strong_memoize_class = described_class
+
+ Struct.new(:value) do
+ include strong_memoize_class
+ end
+ end
+
+ subject { klass.strong_memoize_attr(:nonexistent_method) }
+
+ it 'fails when strong-memoizing a nonexistent method' do
+ expect { subject }.to raise_error(NameError, /undefined method `nonexistent_method' for class/)
+ end
+ end
+
+ context 'when memoized method has parameters' do
+ it 'raises an error' do
+ expected_message = /Using `strong_memoize_attr` on methods with parameters is not supported/
+
+ expect do
+ strong_memoize_class = described_class
+
+ Class.new do
+ include strong_memoize_class
+
+ def method_with_parameters(params); end
+ strong_memoize_attr :method_with_parameters
+ end
+ end.to raise_error(RuntimeError, expected_message)
+ end
+ end
+ end
+
+ describe '.normalize_key' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { described_class.normalize_key(input) }
+
+ where(:input, :output, :valid) do
+ :key | :key | true
+ "key" | "key" | true
+ :key? | "key?" | true
+ "key?" | "key?" | true
+ :key! | "key!" | true
+ "key!" | "key!" | true
+ # invalid cases caught elsewhere
+ :"ke?y" | :"ke?y" | false
+ "ke?y" | "ke?y" | false
+ :"ke!y" | :"ke!y" | false
+ "ke!y" | "ke!y" | false
+ end
+
+ with_them do
+ let(:ivar) { "@#{output}" }
+
+ it { is_expected.to eq(output) }
+
+ if params[:valid]
+ it 'is a valid ivar name' do
+ expect { instance_variable_defined?(ivar) }.not_to raise_error
+ end
+ else
+ it 'raises a NameError error' do
+ expect { instance_variable_defined?(ivar) }
+ .to raise_error(NameError, /not allowed as an instance/)
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-utils/spec/gitlab/utils_spec.rb b/gems/gitlab-utils/spec/gitlab/utils_spec.rb
new file mode 100644
index 00000000000..53593190eea
--- /dev/null
+++ b/gems/gitlab-utils/spec/gitlab/utils_spec.rb
@@ -0,0 +1,479 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Utils, feature_category: :shared do
+ using RSpec::Parameterized::TableSyntax
+ include StubENV
+
+ delegate :to_boolean, :boolean_to_yes_no, :slugify, :which,
+ :ensure_array_from_string, :bytes_to_megabytes,
+ :append_path, :remove_leading_slashes, :allowlisted?,
+ :decode_path, :ms_to_round_sec, to: :described_class
+
+ describe '.allowlisted?' do
+ let(:allowed_paths) { ['/home/foo', '/foo/bar', '/etc/passwd'] }
+
+ it 'returns true if path is allowed' do
+ expect(allowlisted?('/foo/bar', allowed_paths)).to be(true)
+ end
+
+ it 'returns false if path is not allowed' do
+ expect(allowlisted?('/test/test', allowed_paths)).to be(false)
+ end
+ end
+
+ describe '.decode_path' do
+ it 'returns path unencoded for singled-encoded paths' do
+ expect(decode_path('%2Fhome%2Fbar%3Fasd%3Dqwe')).to eq('/home/bar?asd=qwe')
+ end
+
+ it 'returns path when it is unencoded' do
+ expect(decode_path('/home/bar?asd=qwe')).to eq('/home/bar?asd=qwe')
+ end
+
+ [
+ '..%252F..%252F..%252Fetc%252Fpasswd',
+ '%25252Fresult%25252Fchosennickname%25253D%252522jj%252522'
+ ].each do |multiple_encoded_path|
+ it 'raises an exception when the path is multiple-encoded' do
+ expect { decode_path(multiple_encoded_path) }.to raise_error(/path #{multiple_encoded_path} is not allowed/)
+ end
+ end
+ end
+
+ describe '.slugify' do
+ {
+ 'TEST' => 'test',
+ 'project_with_underscores' => 'project-with-underscores',
+ 'namespace/project' => 'namespace-project',
+ 'a' * 70 => 'a' * 63,
+ 'test_trailing_' => 'test-trailing'
+ }.each do |original, expected|
+ it "slugifies #{original} to #{expected}" do
+ expect(slugify(original)).to eq(expected)
+ end
+ end
+ end
+
+ describe '.ms_to_round_sec' do
+ where(:original, :expected) do
+ 1999.8999 | 1.9999
+ 12384 | 12.384
+ 333 | 0.333
+ 1333.33333333 | 1.333333
+ end
+
+ with_them do
+ it "returns rounded seconds" do
+ expect(ms_to_round_sec(original)).to eq(expected)
+ end
+ end
+ end
+
+ describe '.nlbr' do
+ it 'replaces new lines with <br>' do
+ expect(described_class.nlbr("<b>hello</b>\n<i>world</i>")).to eq("hello<br>world")
+ end
+ end
+
+ describe '.remove_line_breaks' do
+ where(:original, :expected) do
+ "foo\nbar\nbaz" | "foobarbaz"
+ "foo\r\nbar\r\nbaz" | "foobarbaz"
+ "foobar" | "foobar"
+ end
+
+ with_them do
+ it "replace line breaks with an empty string" do
+ expect(described_class.remove_line_breaks(original)).to eq(expected)
+ end
+ end
+ end
+
+ describe '.to_boolean' do
+ it 'accepts booleans' do
+ expect(to_boolean(true)).to be(true)
+ expect(to_boolean(false)).to be(false)
+ end
+
+ it 'converts a valid value to a boolean' do
+ expect(to_boolean(true)).to be(true)
+ expect(to_boolean('true')).to be(true)
+ expect(to_boolean('YeS')).to be(true)
+ expect(to_boolean('t')).to be(true)
+ expect(to_boolean('1')).to be(true)
+ expect(to_boolean(1)).to be(true)
+ expect(to_boolean('ON')).to be(true)
+
+ expect(to_boolean('FaLse')).to be(false)
+ expect(to_boolean('F')).to be(false)
+ expect(to_boolean('NO')).to be(false)
+ expect(to_boolean('n')).to be(false)
+ expect(to_boolean('0')).to be(false)
+ expect(to_boolean(0)).to be(false)
+ expect(to_boolean('oFF')).to be(false)
+ end
+
+ it 'converts an invalid value to nil' do
+ expect(to_boolean('fals')).to be_nil
+ expect(to_boolean('yeah')).to be_nil
+ expect(to_boolean('')).to be_nil
+ expect(to_boolean(nil)).to be_nil
+ end
+
+ it 'accepts a default value, and does not return it when a valid value is given' do
+ expect(to_boolean(true, default: false)).to be(true)
+ expect(to_boolean('true', default: false)).to be(true)
+ expect(to_boolean('YeS', default: false)).to be(true)
+ expect(to_boolean('t', default: false)).to be(true)
+ expect(to_boolean('1', default: 'any value')).to be(true)
+ expect(to_boolean('ON', default: 42)).to be(true)
+
+ expect(to_boolean('FaLse', default: true)).to be(false)
+ expect(to_boolean('F', default: true)).to be(false)
+ expect(to_boolean('NO', default: true)).to be(false)
+ expect(to_boolean('n', default: true)).to be(false)
+ expect(to_boolean('0', default: 'any value')).to be(false)
+ expect(to_boolean('oFF', default: 42)).to be(false)
+ end
+
+ it 'accepts a default value, and returns it when an invalid value is given' do
+ expect(to_boolean('fals', default: true)).to eq(true)
+ expect(to_boolean('yeah', default: false)).to eq(false)
+ expect(to_boolean('', default: 'any value')).to eq('any value')
+ expect(to_boolean(nil, default: 42)).to eq(42)
+ end
+ end
+
+ describe '.boolean_to_yes_no' do
+ it 'converts booleans to Yes or No' do
+ expect(boolean_to_yes_no(true)).to eq('Yes')
+ expect(boolean_to_yes_no(false)).to eq('No')
+ end
+ end
+
+ describe '.which' do
+ before do
+ stub_env('PATH', '/sbin:/usr/bin:/home/joe/bin')
+ end
+
+ it 'finds the full path to an executable binary in order of appearance' do
+ expect(File).to receive(:executable?).with('/sbin/tool').ordered.and_return(false)
+ expect(File).to receive(:executable?).with('/usr/bin/tool').ordered.and_return(true)
+ expect(File).not_to receive(:executable?).with('/home/joe/bin/tool')
+
+ expect(which('tool')).to eq('/usr/bin/tool')
+ end
+ end
+
+ describe '.ensure_array_from_string' do
+ it 'returns the same array if given one' do
+ arr = ['a', 4, true, { test: 1 }]
+
+ expect(ensure_array_from_string(arr)).to eq(arr)
+ end
+
+ it 'turns comma-separated strings into arrays' do
+ str = 'seven, eight, 9, 10'
+
+ expect(ensure_array_from_string(str)).to eq(%w[seven eight 9 10])
+ end
+ end
+
+ describe '.bytes_to_megabytes' do
+ it 'converts bytes to megabytes' do
+ bytes = 1.megabyte
+
+ expect(bytes_to_megabytes(bytes)).to eq(1)
+ end
+ end
+
+ describe '.append_path' do
+ where(:host, :path, :result) do
+ 'http://test/' | '/foo/bar' | 'http://test/foo/bar'
+ 'http://test/' | '//foo/bar' | 'http://test/foo/bar'
+ 'http://test//' | '/foo/bar' | 'http://test/foo/bar'
+ 'http://test' | 'foo/bar' | 'http://test/foo/bar'
+ 'http://test//' | '' | 'http://test/'
+ 'http://test//' | nil | 'http://test/'
+ '' | '/foo/bar' | '/foo/bar'
+ nil | '/foo/bar' | '/foo/bar'
+ end
+
+ with_them do
+ it 'makes sure there is only one slash as path separator' do
+ expect(append_path(host, path)).to eq(result)
+ end
+ end
+ end
+
+ describe '.remove_leading_slashes' do
+ where(:str, :result) do
+ '/foo/bar' | 'foo/bar'
+ '//foo/bar' | 'foo/bar'
+ '/foo/bar/' | 'foo/bar/'
+ 'foo/bar' | 'foo/bar'
+ '' | ''
+ nil | ''
+ end
+
+ with_them do
+ it 'removes leading slashes' do
+ expect(remove_leading_slashes(str)).to eq(result)
+ end
+ end
+ end
+
+ describe '.ensure_utf8_size' do
+ context 'with string is has less bytes than expected' do
+ it 'backfills string with null characters' do
+ transformed = described_class.ensure_utf8_size('a' * 10, bytes: 32)
+
+ expect(transformed.bytesize).to eq 32
+ expect(transformed).to eq(('a' * 10) + ('0' * 22))
+ end
+ end
+
+ context 'with string size is exactly the one that is expected' do
+ it 'returns original value' do
+ transformed = described_class.ensure_utf8_size('a' * 32, bytes: 32)
+
+ expect(transformed).to eq 'a' * 32
+ expect(transformed.bytesize).to eq 32
+ end
+ end
+
+ context 'when string contains a few multi-byte UTF characters' do
+ it 'backfills string with null characters' do
+ transformed = described_class.ensure_utf8_size('❤' * 6, bytes: 32)
+
+ expect(transformed).to eq '❤❤❤❤❤❤' + ('0' * 14) # rubocop:disable Style/StringConcatenation
+ expect(transformed.bytesize).to eq 32
+ end
+ end
+
+ context 'when string has multiple multi-byte UTF chars exceeding 32 bytes' do
+ it 'truncates string to 32 characters and backfills it if needed' do
+ transformed = described_class.ensure_utf8_size('❤' * 18, bytes: 32)
+
+ expect(transformed).to eq(('❤' * 10) + ('0' * 2))
+ expect(transformed.bytesize).to eq 32
+ end
+ end
+ end
+
+ describe '.deep_indifferent_access' do
+ let(:hash) do
+ { "variables" => [{ "key" => "VAR1", "value" => "VALUE2" }] }
+ end
+
+ subject { described_class.deep_indifferent_access(hash) }
+
+ it 'allows to access hash keys with symbols' do
+ expect(subject[:variables]).to be_a(Array)
+ end
+
+ it 'allows to access array keys with symbols' do
+ expect(subject[:variables].first[:key]).to eq('VAR1')
+ end
+ end
+
+ describe '.deep_symbolized_access' do
+ let(:hash) do
+ { "variables" => [{ "key" => "VAR1", "value" => "VALUE2" }] }
+ end
+
+ subject { described_class.deep_symbolized_access(hash) }
+
+ it 'allows to access hash keys with symbols' do
+ expect(subject[:variables]).to be_a(Array)
+ end
+
+ it 'allows to access array keys with symbols' do
+ expect(subject[:variables].first[:key]).to eq('VAR1')
+ end
+ end
+
+ describe '.try_megabytes_to_bytes' do
+ context 'when the size can be converted to megabytes' do
+ it 'returns the size in megabytes' do
+ size = described_class.try_megabytes_to_bytes(1)
+
+ expect(size).to eq(1.megabytes)
+ end
+ end
+
+ context 'when the size can not be converted to megabytes' do
+ it 'returns the input size' do
+ size = described_class.try_megabytes_to_bytes('foo')
+
+ expect(size).to eq('foo')
+ end
+ end
+ end
+
+ describe '.string_to_ip_object' do
+ it 'returns nil when string is nil' do
+ expect(described_class.string_to_ip_object(nil)).to eq(nil)
+ end
+
+ it 'returns nil when string is invalid IP' do
+ expect(described_class.string_to_ip_object('invalid ip')).to eq(nil)
+ expect(described_class.string_to_ip_object('')).to eq(nil)
+ end
+
+ it 'returns IP object when string is valid IP' do
+ expect(described_class.string_to_ip_object('192.168.1.1')).to eq(IPAddr.new('192.168.1.1'))
+ expect(described_class.string_to_ip_object('::ffff:a9fe:a864')).to eq(IPAddr.new('::ffff:a9fe:a864'))
+ expect(described_class.string_to_ip_object('[::ffff:a9fe:a864]')).to eq(IPAddr.new('::ffff:a9fe:a864'))
+ expect(described_class.string_to_ip_object('127.0.0.0/28')).to eq(IPAddr.new('127.0.0.0/28'))
+ expect(described_class.string_to_ip_object('1:0:0:0:0:0:0:0/124')).to eq(IPAddr.new('1:0:0:0:0:0:0:0/124'))
+ end
+ end
+
+ describe ".safe_downcase!" do
+ where(:str, :result) do
+ "test" | "test"
+ "Test" | "test"
+ "test" | "test"
+ "Test" | "test"
+ end
+
+ with_them do
+ it "downcases the string" do
+ expect(described_class.safe_downcase!(str)).to eq(result)
+ end
+ end
+ end
+
+ describe '.parse_url' do
+ it 'returns Addressable::URI object' do
+ expect(described_class.parse_url('http://gitlab.com')).to be_instance_of(Addressable::URI)
+ end
+
+ it 'returns nil when URI cannot be parsed' do
+ expect(described_class.parse_url('://gitlab.com')).to be nil
+ end
+
+ it 'returns nil with invalid parameter' do
+ expect(described_class.parse_url(1)).to be nil
+ end
+ end
+
+ describe '.add_url_parameters' do
+ subject { described_class.add_url_parameters(url, params) }
+
+ where(:url, :params, :expected_url) do
+ nil | nil | ''
+ nil | { b: 3, a: 2 } | '?a=2&b=3'
+ 'https://gitlab.com' | nil | 'https://gitlab.com'
+ 'https://gitlab.com' | { b: 3, a: 2 } | 'https://gitlab.com?a=2&b=3'
+ 'https://gitlab.com?a=1#foo' | { b: 3, 'a' => 2 } | 'https://gitlab.com?a=2&b=3#foo'
+ 'https://gitlab.com?a=1#foo' | [[:b, 3], [:a, 2]] | 'https://gitlab.com?a=2&b=3#foo'
+ end
+
+ with_them do
+ it { is_expected.to eq(expected_url) }
+ end
+ end
+
+ describe '.removes_sensitive_data_from_url' do
+ it 'returns string object' do
+ expect(described_class.removes_sensitive_data_from_url('http://gitlab.com')).to be_instance_of(String)
+ end
+
+ it 'returns nil when URI cannot be parsed' do
+ expect(described_class.removes_sensitive_data_from_url('://gitlab.com')).to be nil
+ end
+
+ it 'returns nil with invalid parameter' do
+ expect(described_class.removes_sensitive_data_from_url(1)).to be nil
+ end
+
+ it 'returns string with filtered access_token param' do
+ expect(described_class.removes_sensitive_data_from_url('http://gitlab.com/auth.html#access_token=secret_token'))
+ .to eq('http://gitlab.com/auth.html#access_token=filtered')
+ end
+
+ it 'returns string with filtered access_token param but other params preserved' do
+ expect(described_class.removes_sensitive_data_from_url('http://gitlab.com/auth.html#access_token=secret_token&token_type=Bearer&state=test'))
+ .to include('&token_type=Bearer', '&state=test')
+ end
+ end
+
+ describe 'multiple_key_invert' do
+ it 'invert keys with array values' do
+ hash = {
+ dast: [:vulnerabilities_count, :scanned_resources_count],
+ sast: [:vulnerabilities_count]
+ }
+ expect(described_class.multiple_key_invert(hash)).to eq({
+ vulnerabilities_count: [:dast, :sast],
+ scanned_resources_count: [:dast]
+ })
+ end
+ end
+
+ describe '.stable_sort_by' do
+ subject(:sorted_list) { described_class.stable_sort_by(list) { |obj| obj[:priority] } }
+
+ context 'when items have the same priority' do
+ let(:list) do
+ [
+ { name: 'obj 1', priority: 1 },
+ { name: 'obj 2', priority: 1 },
+ { name: 'obj 3', priority: 1 }
+ ]
+ end
+
+ it 'does not change order in cases of ties' do
+ expect(sorted_list).to eq(list)
+ end
+ end
+
+ context 'when items have different priorities' do
+ let(:list) do
+ [
+ { name: 'obj 1', priority: 2 },
+ { name: 'obj 2', priority: 1 },
+ { name: 'obj 3', priority: 3 }
+ ]
+ end
+
+ it 'sorts items like the regular sort_by' do
+ expect(sorted_list).to eq(
+ [
+ { name: 'obj 2', priority: 1 },
+ { name: 'obj 1', priority: 2 },
+ { name: 'obj 3', priority: 3 }
+ ])
+ end
+ end
+ end
+
+ describe '.valid_brackets?' do
+ where(:input, :allow_nested, :valid) do
+ 'no brackets' | true | true
+ 'no brackets' | false | true
+ 'user[avatar]' | true | true
+ 'user[avatar]' | false | true
+ 'user[avatar][friends]' | true | true
+ 'user[avatar][friends]' | false | true
+ 'user[avatar[image[url]]]' | true | true
+ 'user[avatar[image[url]]]' | false | false
+ 'user[avatar[]friends]' | true | true
+ 'user[avatar[]friends]' | false | false
+ 'user[avatar]]' | true | false
+ 'user[avatar]]' | false | false
+ 'user][avatar]]' | true | false
+ 'user][avatar]]' | false | false
+ 'user[avatar' | true | false
+ 'user[avatar' | false | false
+ end
+
+ with_them do
+ it { expect(described_class.valid_brackets?(input, allow_nested: allow_nested)).to eq(valid) }
+ end
+ end
+end
diff --git a/gems/gitlab-utils/spec/gitlab/version_info_spec.rb b/gems/gitlab-utils/spec/gitlab/version_info_spec.rb
new file mode 100644
index 00000000000..2b5f6bcb4c1
--- /dev/null
+++ b/gems/gitlab-utils/spec/gitlab/version_info_spec.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::VersionInfo, feature_category: :shared do
+ before do
+ @unknown = described_class.new
+ @v0_0_1 = described_class.new(0, 0, 1)
+ @v0_1_0 = described_class.new(0, 1, 0)
+ @v1_0_0 = described_class.new(1, 0, 0)
+ @v1_0_1 = described_class.new(1, 0, 1)
+ @v1_0_1_b1 = described_class.new(1, 0, 1, '-b1')
+ @v1_0_1_rc1 = described_class.new(1, 0, 1, '-rc1')
+ @v1_0_1_rc2 = described_class.new(1, 0, 1, '-rc2')
+ @v1_1_0 = described_class.new(1, 1, 0)
+ @v1_1_0_beta1 = described_class.new(1, 1, 0, '-beta1')
+ @v2_0_0 = described_class.new(2, 0, 0)
+ @v13_10_1_1574_89 = described_class.parse("v13.10.1~beta.1574.gf6ea9389", parse_suffix: true)
+ @v13_10_1_1575_89 = described_class.parse("v13.10.1~beta.1575.gf6ea9389", parse_suffix: true)
+ @v13_10_1_1575_90 = described_class.parse("v13.10.1~beta.1575.gf6ea9390", parse_suffix: true)
+ end
+
+ describe '>' do
+ it { expect(@v2_0_0).to be > @v1_1_0 }
+ it { expect(@v1_1_0).to be > @v1_0_1 }
+ it { expect(@v1_0_1_b1).to be > @v1_0_0 }
+ it { expect(@v1_0_1_rc1).to be > @v1_0_0 }
+ it { expect(@v1_0_1_rc1).to be > @v1_0_1_b1 }
+ it { expect(@v1_0_1_rc2).to be > @v1_0_1_rc1 }
+ it { expect(@v1_0_1).to be > @v1_0_1_rc1 }
+ it { expect(@v1_0_1).to be > @v1_0_1_rc2 }
+ it { expect(@v1_0_1).to be > @v1_0_0 }
+ it { expect(@v1_0_0).to be > @v0_1_0 }
+ it { expect(@v1_1_0_beta1).to be > @v1_0_1_rc2 }
+ it { expect(@v1_1_0).to be > @v1_1_0_beta1 }
+ it { expect(@v0_1_0).to be > @v0_0_1 }
+ end
+
+ describe '>=' do
+ it { expect(@v2_0_0).to be >= described_class.new(2, 0, 0) }
+ it { expect(@v2_0_0).to be >= @v1_1_0 }
+ it { expect(@v1_0_1_rc2).to be >= @v1_0_1_rc1 }
+ end
+
+ describe '<' do
+ it { expect(@v0_0_1).to be < @v0_1_0 }
+ it { expect(@v0_1_0).to be < @v1_0_0 }
+ it { expect(@v1_0_0).to be < @v1_0_1 }
+ it { expect(@v1_0_1).to be < @v1_1_0 }
+ it { expect(@v1_0_0).to be < @v1_0_1_rc2 }
+ it { expect(@v1_0_1_rc1).to be < @v1_0_1 }
+ it { expect(@v1_0_1_rc1).to be < @v1_0_1_rc2 }
+ it { expect(@v1_0_1_rc2).to be < @v1_0_1 }
+ it { expect(@v1_1_0).to be < @v2_0_0 }
+ it { expect(@v13_10_1_1574_89).to be < @v13_10_1_1575_89 }
+ it { expect(@v13_10_1_1575_89).to be < @v13_10_1_1575_90 }
+ end
+
+ describe '<=' do
+ it { expect(@v0_0_1).to be <= described_class.new(0, 0, 1) }
+ it { expect(@v0_0_1).to be <= @v0_1_0 }
+ it { expect(@v1_0_1_b1).to be <= @v1_0_1_rc1 }
+ it { expect(@v1_0_1_rc1).to be <= @v1_0_1_rc2 }
+ it { expect(@v1_1_0_beta1).to be <= @v1_1_0 }
+ end
+
+ describe '==' do
+ it { expect(@v0_0_1).to eq(described_class.new(0, 0, 1)) }
+ it { expect(@v0_1_0).to eq(described_class.new(0, 1, 0)) }
+ it { expect(@v1_0_0).to eq(described_class.new(1, 0, 0)) }
+ it { expect(@v1_0_1_rc1).to eq(described_class.new(1, 0, 1, '-rc1')) }
+ end
+
+ describe '!=' do
+ it { expect(@v0_0_1).not_to eq(@v0_1_0) }
+ it { expect(@v1_0_1_rc1).not_to eq(@v1_0_1_rc2) }
+ end
+
+ describe '.unknown' do
+ it { expect(@unknown).not_to be @v0_0_1 }
+ it { expect(@unknown).not_to be described_class.new }
+ it { expect { @unknown > @v0_0_1 }.to raise_error(ArgumentError) }
+ it { expect { @unknown < @v0_0_1 }.to raise_error(ArgumentError) }
+ end
+
+ describe '.parse' do
+ it { expect(described_class.parse(described_class.new(1, 0, 0))).to eq(@v1_0_0) }
+ it { expect(described_class.parse("1.0.0")).to eq(@v1_0_0) }
+ it { expect(described_class.parse("1.0.0.1")).to eq(@v1_0_0) }
+ it { expect(described_class.parse("1.0.0-ee")).to eq(@v1_0_0) }
+ it { expect(described_class.parse("1.0.0-rc1")).to eq(@v1_0_0) }
+ it { expect(described_class.parse("1.0.0-rc1-ee")).to eq(@v1_0_0) }
+ it { expect(described_class.parse("git 1.0.0b1")).to eq(@v1_0_0) }
+ it { expect(described_class.parse("git 1.0b1")).not_to be_valid }
+ it { expect(described_class.parse("1.1.#{'1' * described_class::MAX_VERSION_LENGTH}")).not_to be_valid }
+ it { expect(described_class.parse(nil)).not_to be_valid }
+
+ context 'with parse_suffix: true' do
+ let(:versions) do
+ <<-VERSIONS.lines
+ 0.0.1
+ 0.1.0
+ 1.0.0
+ 1.0.1-b1
+ 1.0.1-rc1
+ 1.0.1-rc2
+ 1.0.1
+ 1.1.0-beta1
+ 1.1.0
+ 2.0.0
+ v13.10.0-pre
+ v13.10.0-rc1
+ v13.10.0-rc2
+ v13.10.0
+ v13.10.1~beta.1574.gf6ea9389
+ v13.10.1~beta.1575.gf6ea9389
+ v13.10.1-rc1
+ v13.10.1-rc2
+ v13.10.1
+ VERSIONS
+ end
+
+ let(:parsed_versions) do
+ versions.map(&:strip).map { |version| described_class.parse(version, parse_suffix: true) }
+ end
+
+ it 'versions are returned in a correct order' do
+ expect(parsed_versions.shuffle.sort).to eq(parsed_versions)
+ end
+ end
+ end
+
+ describe '.to_s' do
+ it { expect(@v1_0_0.to_s).to eq("1.0.0") }
+ it { expect(@v1_0_1_rc1.to_s).to eq("1.0.1-rc1") }
+ it { expect(@unknown.to_s).to eq("Unknown") }
+ end
+
+ describe '.to_json' do
+ let(:correct_version) do
+ "{\"major\":1,\"minor\":0,\"patch\":1}"
+ end
+
+ let(:unknown_version) do
+ "{\"major\":0,\"minor\":0,\"patch\":0}"
+ end
+
+ it { expect(@v1_0_1.to_json).to eq(correct_version) }
+ it { expect(@v1_0_1_rc2.to_json).to eq(correct_version) }
+ it { expect(@unknown.to_json).to eq(unknown_version) }
+ end
+
+ describe '.hash' do
+ it { expect(described_class.parse("1.0.0").hash).to eq(@v1_0_0.hash) }
+ it { expect(described_class.parse("1.0.0.1").hash).to eq(@v1_0_0.hash) }
+ it { expect(described_class.parse("1.0.1b1").hash).to eq(@v1_0_1.hash) }
+ it { expect(described_class.parse("1.0.1-rc1", parse_suffix: true).hash).to eq(@v1_0_1_rc1.hash) }
+ end
+
+ describe '.eql?' do
+ it { expect(described_class.parse("1.0.0").eql?(@v1_0_0)).to be_truthy }
+ it { expect(described_class.parse("1.0.0.1").eql?(@v1_0_0)).to be_truthy }
+ it { expect(@v1_0_1_rc1.eql?(@v1_0_1_rc1)).to be_truthy }
+ it { expect(@v1_0_1_rc1.eql?(@v1_0_1_rc2)).to be_falsey }
+ it { expect(@v1_0_1_rc1.eql?(@v1_0_1)).to be_falsey }
+ it { expect(@v1_0_1.eql?(@v1_0_0)).to be_falsey }
+ it { expect(@v1_1_0.eql?(@v1_0_0)).to be_falsey }
+ it { expect(@v1_0_0.eql?(@v1_0_0)).to be_truthy }
+ it { expect([@v1_0_0, @v1_1_0, @v1_0_0, @v1_0_1_rc1, @v1_0_1_rc1].uniq).to eq [@v1_0_0, @v1_1_0, @v1_0_1_rc1] }
+ end
+
+ describe '.same_minor_version?' do
+ it { expect(@v0_1_0.same_minor_version?(@v0_0_1)).to be_falsey }
+ it { expect(@v1_0_1.same_minor_version?(@v1_0_0)).to be_truthy }
+ it { expect(@v1_0_1_rc1.same_minor_version?(@v1_0_0)).to be_truthy }
+ it { expect(@v1_0_0.same_minor_version?(@v1_0_1)).to be_truthy }
+ it { expect(@v1_1_0.same_minor_version?(@v1_0_0)).to be_falsey }
+ it { expect(@v2_0_0.same_minor_version?(@v1_0_0)).to be_falsey }
+ end
+
+ describe '.without_patch' do
+ it { expect(@v0_1_0.without_patch).to eq(@v0_1_0) }
+ it { expect(@v1_0_0.without_patch).to eq(@v1_0_0) }
+ it { expect(@v1_0_1.without_patch).to eq(@v1_0_0) }
+ it { expect(@v1_0_1_rc1.without_patch).to eq(@v1_0_0) }
+ end
+
+ describe 'MAX_VERSION_LENGTH' do
+ subject { described_class::MAX_VERSION_LENGTH }
+
+ it { is_expected.to eq(128) }
+ end
+end
diff --git a/gems/gitlab-utils/spec/spec_helper.rb b/gems/gitlab-utils/spec/spec_helper.rb
new file mode 100644
index 00000000000..5dc3859f77d
--- /dev/null
+++ b/gems/gitlab-utils/spec/spec_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'rails'
+require 'rspec/mocks'
+require 'rspec-benchmark'
+require 'rspec-parameterized'
+
+require 'gitlab/rspec/all'
+require 'gitlab/utils/all'
+
+RSpec.configure do |config|
+ config.include RSpec::Benchmark::Matchers
+
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end
diff --git a/gems/ipynbdiff/.gitignore b/gems/ipynbdiff/.gitignore
new file mode 100644
index 00000000000..fae8d1c772f
--- /dev/null
+++ b/gems/ipynbdiff/.gitignore
@@ -0,0 +1,3 @@
+*.gem
+coverage
+.bundle
diff --git a/gems/ipynbdiff/.gitlab-ci.yml b/gems/ipynbdiff/.gitlab-ci.yml
new file mode 100644
index 00000000000..de5c989a4a4
--- /dev/null
+++ b/gems/ipynbdiff/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "ipynbdiff"
diff --git a/gems/ipynbdiff/.rubocop.yml b/gems/ipynbdiff/.rubocop.yml
new file mode 100644
index 00000000000..00a3ed337f1
--- /dev/null
+++ b/gems/ipynbdiff/.rubocop.yml
@@ -0,0 +1,21 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+CodeReuse/ActiveRecord:
+ Enabled: false
+
+Gitlab/Json:
+ Enabled: false
+
+Naming/FileName:
+ Exclude:
+ - spec/**/*.rb
+ - lib/gitlab/rspec.rb
+ - lib/gitlab/rspec/all.rb
+
+Rails/Pluck:
+ Enabled: false
+
+RSpec/MultipleMemoizedHelpers:
+ Max: 6
+ AllowSubject: true
diff --git a/gems/ipynbdiff/Gemfile b/gems/ipynbdiff/Gemfile
new file mode 100644
index 00000000000..7f4f5e950d1
--- /dev/null
+++ b/gems/ipynbdiff/Gemfile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+gemspec
diff --git a/gems/ipynbdiff/Gemfile.lock b/gems/ipynbdiff/Gemfile.lock
new file mode 100644
index 00000000000..9a583bdf274
--- /dev/null
+++ b/gems/ipynbdiff/Gemfile.lock
@@ -0,0 +1,139 @@
+PATH
+ remote: .
+ specs:
+ ipynbdiff (0.4.7)
+ diffy (~> 3.4)
+ oj (~> 3.13.16)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (7.0.6)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ ast (2.4.2)
+ benchmark-memory (0.2.0)
+ memory_profiler (~> 1)
+ binding_of_caller (1.0.0)
+ debug_inspector (>= 0.0.1)
+ coderay (1.1.3)
+ concurrent-ruby (1.2.2)
+ debug_inspector (1.1.0)
+ diff-lcs (1.5.0)
+ diffy (3.4.2)
+ docile (1.4.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ memory_profiler (1.0.0)
+ method_source (1.0.0)
+ minitest (5.18.1)
+ oj (3.13.23)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ pry (0.14.1)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ racc (1.7.1)
+ rack (3.0.8)
+ rainbow (3.1.1)
+ rake (13.0.6)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.11.0)
+ rspec-core (~> 3.11.0)
+ rspec-expectations (~> 3.11.0)
+ rspec-mocks (~> 3.11.0)
+ rspec-core (3.11.0)
+ rspec-support (~> 3.11.0)
+ rspec-expectations (3.11.0)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.11.0)
+ rspec-mocks (3.11.1)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.11.0)
+ rspec-parameterized (1.0.0)
+ rspec-parameterized-core (< 2)
+ rspec-parameterized-table_syntax (< 2)
+ rspec-parameterized-core (1.0.0)
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-parameterized-table_syntax (1.0.0)
+ binding_of_caller
+ rspec-parameterized-core (< 2)
+ rspec-support (3.11.0)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ simplecov (0.22.0)
+ docile (~> 1.1)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-html (0.12.3)
+ simplecov_json_formatter (0.1.4)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+ unparser (0.6.8)
+ diff-lcs (~> 1.3)
+ parser (>= 3.2.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ benchmark-memory (~> 0.2.0)
+ bundler (~> 2.2)
+ gitlab-styles (~> 10.1.0)
+ ipynbdiff!
+ pry (~> 0.14)
+ rake (~> 13.0)
+ rspec (~> 3.10)
+ rspec-parameterized (~> 1.0)
+ simplecov (~> 0.22.0)
+
+BUNDLED WITH
+ 2.3.16
diff --git a/gems/ipynbdiff/LICENSE b/gems/ipynbdiff/LICENSE
new file mode 100644
index 00000000000..e6de2f90864
--- /dev/null
+++ b/gems/ipynbdiff/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016-2021 GitLab B.V.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/gems/ipynbdiff/README.md b/gems/ipynbdiff/README.md
new file mode 100644
index 00000000000..f046f678a4d
--- /dev/null
+++ b/gems/ipynbdiff/README.md
@@ -0,0 +1,56 @@
+# IpynbDiff: Better diff for Jupyter Notebooks
+
+This is a simple diff tool that cleans up Jupyter notebooks, transforming each [notebook](example/1/from.ipynb)
+into a [readable markdown file](example/1/from_html.md), keeping the output of cells, and running the
+diff after. Markdowns are generated using an opinionated Jupyter to Markdown conversion. This means
+that the entire file is readable on the diff.
+
+The result are diffs that are much easier to read:
+
+| Diff | IpynbDiff |
+| ----------------------------------- | ----------------------------------------------------- |
+| [Diff text](example/diff.txt) | [IpynbDiff text](example/ipynbdiff_percent.txt) |
+| ![Diff image](example/img/diff.png) | ![IpynbDiff image](example/img/ipynbdiff_percent.png) |
+
+This started as a port of [ipynbdiff](https://gitlab.com/gitlab-org/incubation-engineering/mlops/poc/ipynbdiff),
+but now has extended functionality although not working as git driver.
+
+## Usage
+
+### Generating diffs
+
+```ruby
+IpynbDiff.diff(from_path, to_path, options)
+```
+
+Options:
+
+```ruby
+@default_transform_options = {
+ preprocess_input: true, # Whether the input should be transformed
+ write_output_to: nil, # Pass a path to save the output to a file
+ format: :text, # These are the formats Diffy accepts https://github.com/samg/diffy
+ sources_are_files: false, # Weather to use the from/to as string or path to a file
+ raise_if_invalid_notebook: false, # Raises an error if the notebooks are invalid, otherwise returns nil
+ transform_options: @default_transform_options, # See below for transform options
+ diff_opts: {
+ include_diff_info: false # These are passed to Diffy https://github.com/samg/diffy
+ }
+}
+```
+
+### Transforming the notebooks
+
+It might be necessary to have the transformed files in addition to the diff.
+
+```ruby
+IpynbDiff.transform(notebook, options)
+```
+
+Options:
+
+```ruby
+@default_transform_options = {
+ include_frontmatter: false, # Whether to include or not the notebook metadata (kernel, language, etc)
+}
+```
diff --git a/gems/ipynbdiff/ipynbdiff.gemspec b/gems/ipynbdiff/ipynbdiff.gemspec
new file mode 100644
index 00000000000..8bc3e1b142d
--- /dev/null
+++ b/gems/ipynbdiff/ipynbdiff.gemspec
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+$LOAD_PATH.push File.expand_path('lib', __dir__ || '')
+
+require_relative 'lib/ipynb_diff/version'
+
+Gem::Specification.new do |s|
+ s.name = 'ipynbdiff'
+ s.version = IpynbDiff::Version::VERSION
+ s.summary = 'Human Readable diffs for Jupyter Notebooks'
+ s.description = 'Better diff for Jupyter Notebooks by first preprocessing them and removing clutter'
+ s.authors = ['Eduardo Bonet']
+ s.email = 'ebonet@gitlab.com'
+ # Specify which files should be added to the gem when it is released.
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
+ s.files = Dir['lib/**/*.rb']
+ s.require_paths = ["lib"]
+ s.required_ruby_version = ">= 3.0"
+ s.homepage = 'https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/ipynbdiff'
+ s.license = 'MIT'
+
+ s.add_runtime_dependency 'diffy', '~> 3.4'
+ s.add_runtime_dependency 'oj', '~> 3.13.16'
+
+ s.add_development_dependency 'benchmark-memory', '~>0.2.0'
+ s.add_development_dependency 'bundler', '~> 2.2'
+ s.add_development_dependency 'gitlab-styles', '~> 10.1.0'
+ s.add_development_dependency 'pry', '~> 0.14'
+ s.add_development_dependency 'rake', '~> 13.0'
+ s.add_development_dependency 'rspec', '~> 3.10'
+ s.add_development_dependency 'rspec-parameterized', '~> 1.0'
+ s.add_development_dependency 'simplecov', '~> 0.22.0'
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff.rb b/gems/ipynbdiff/lib/ipynb_diff.rb
new file mode 100644
index 00000000000..605ff6e4a75
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'ipynb_diff/transformer'
+require 'ipynb_diff/diff'
+require 'ipynb_diff/symbol_map'
+
+# Human Readable Jupyter Diffs
+module IpynbDiff
+ def self.diff(from, to, raise_if_invalid_nb: false, include_frontmatter: false, hide_images: false, diffy_opts: {})
+ transformer = Transformer.new(include_frontmatter: include_frontmatter, hide_images: hide_images)
+
+ Diff.new(transformer.transform(from), transformer.transform(to), diffy_opts)
+ rescue InvalidNotebookError
+ raise if raise_if_invalid_nb
+ end
+
+ def self.transform(notebook, raise_errors: false, include_frontmatter: true, hide_images: false)
+ return unless notebook
+
+ Transformer.new(include_frontmatter: include_frontmatter, hide_images: hide_images).transform(notebook).as_text
+ rescue InvalidNotebookError
+ raise if raise_errors
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/diff.rb b/gems/ipynbdiff/lib/ipynb_diff/diff.rb
new file mode 100644
index 00000000000..3554ac55d99
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/diff.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Custom differ for Jupyter Notebooks
+module IpynbDiff
+ require 'delegate'
+
+ # The result of a diff object
+ class Diff < SimpleDelegator
+ require 'diffy'
+
+ attr_reader :from, :to
+
+ def initialize(from, to, diffy_opts)
+ super(Diffy::Diff.new(from.as_text, to.as_text, **diffy_opts))
+
+ @from = from
+ @to = to
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb b/gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb
new file mode 100644
index 00000000000..95dbcecf95c
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'ipynb_diff/symbolized_markdown_helper'
+
+module IpynbDiff
+ # Transforms Jupyter output data into markdown
+ class OutputTransformer
+ include SymbolizedMarkdownHelper
+
+ HIDDEN_IMAGE_OUTPUT = ' [Hidden Image Output]'
+
+ ORDERED_KEYS = {
+ 'execute_result' => %w[image/png image/svg+xml image/jpeg text/markdown text/latex text/plain],
+ 'display_data' => %w[image/png image/svg+xml image/jpeg text/markdown text/latex],
+ 'stream' => %w[text]
+ }.freeze
+
+ def initialize(hide_images = false)
+ @hide_images = hide_images
+ end
+
+ def transform(output, symbol)
+ case (output_type = output['output_type'])
+ when 'error'
+ transform_error(output['traceback'], symbol / 'traceback')
+ when 'execute_result', 'display_data'
+ transform_non_error(ORDERED_KEYS[output_type], output['data'], symbol / 'data')
+ when 'stream'
+ transform_element('text', output['text'], symbol)
+ end
+ end
+
+ def transform_error(traceback, symbol)
+ traceback.map.with_index do |t, idx|
+ t.split("\n").map do |l|
+ ___(symbol / idx, l.gsub(/\[[0-9][0-9;]*m/, '').sub("\u001B", ' ').delete("\u001B").rstrip)
+ end
+ end
+ end
+
+ def transform_non_error(accepted_keys, elements, symbol)
+ accepted_keys.filter { |key| elements.key?(key) }.map do |key|
+ transform_element(key, elements[key], symbol)
+ end
+ end
+
+ def transform_element(output_type, output_element, symbol_prefix)
+ new_symbol = symbol_prefix / output_type
+ case output_type
+ when 'image/png', 'image/jpeg'
+ transform_image("#{output_type};base64", output_element, new_symbol)
+ when 'image/svg+xml'
+ transform_image("#{output_type};utf8", output_element, new_symbol)
+ when 'text/markdown', 'text/latex', 'text/plain', 'text'
+ transform_text(output_element, new_symbol)
+ end
+ end
+
+ def transform_image(image_type, image_content, symbol)
+ return ___(nil, HIDDEN_IMAGE_OUTPUT) if @hide_images
+
+ lines = image_content.is_a?(Array) ? image_content : [image_content]
+
+ single_line = lines.map(&:strip).join.gsub(/\s+/, ' ')
+
+ ___(symbol, " ![](data:#{image_type},#{single_line})")
+ end
+
+ def transform_text(text_content, symbol)
+ symbolize_array(symbol, text_content) { |l| " #{l.rstrip}" }
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb b/gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb
new file mode 100644
index 00000000000..383f1de5c18
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ require 'oj'
+
+ # Creates a map from a symbol to the line number it appears in a Json file
+ #
+ # Example:
+ #
+ # Input:
+ #
+ # 1. {
+ # 2. 'obj1': [
+ # 3. {
+ # 4. 'obj2': 5
+ # 5. },
+ # 6. 3,
+ # 7. {
+ # 8. 'obj3': {
+ # 9. 'obj4': 'b'
+ # 10. }
+ # 11. }
+ # 12. ]
+ # 13.}
+ #
+ # Output:
+ #
+ # Symbol Line Number
+ # .obj1 -> 2
+ # .obj1.0 -> 3
+ # .obj1.0 -> 3
+ # .obj1.0.obj2 -> 4
+ # .obj1.1 -> 6
+ # .obj1.2 -> 7
+ # .obj1.2.obj3 -> 8
+ # .obj1.2.obj3.obj4 -> 9
+ #
+ class SymbolMap
+ # rubocop:disable Lint/UnusedMethodArgument
+ class << self
+ def handler
+ @handler ||= SymbolMap.new
+ end
+
+ def parser
+ @parser ||= Oj::Parser.new(:saj).tap { |p| p.handler = handler }
+ end
+
+ def parse(notebook, *args)
+ handler.reset
+ parser.parse(notebook)
+ handler.symbols
+ end
+ end
+
+ attr_accessor :symbols
+
+ def hash_start(key, line, column)
+ add_symbol(key_or_index(key), line)
+ end
+
+ def hash_end(key, line, column)
+ @current_path.pop
+ end
+
+ def array_start(key, line, column)
+ @current_array_index << 0
+
+ add_symbol(key, line)
+ end
+
+ def array_end(key, line, column)
+ @current_path.pop
+ @current_array_index.pop
+ end
+
+ def add_value(value, key, line, column)
+ add_symbol(key_or_index(key), line)
+
+ @current_path.pop
+ end
+
+ def add_symbol(symbol, line)
+ @symbols[@current_path.append(symbol).join('.')] = line if symbol
+ end
+
+ def key_or_index(key)
+ if key.nil? # value in an array
+ if @current_path.empty?
+ @current_path = ['']
+ return
+ end
+
+ symbol = @current_array_index.last
+ @current_array_index[-1] += 1
+ symbol
+ else
+ key
+ end
+ end
+
+ def reset
+ @current_path = []
+ @symbols = {}
+ @current_array_index = []
+ end
+ # rubocop:enable Lint/UnusedMethodArgument
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb b/gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb
new file mode 100644
index 00000000000..991c9e493bc
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ # Helper functions
+ module SymbolizedMarkdownHelper
+ def ___(symbol = nil, content = '')
+ { symbol: symbol, content: content }
+ end
+
+ def symbolize_array(symbol, content)
+ if content.is_a?(Array)
+ content.map.with_index { |l, idx| ___(symbol / idx, yield(l)) }
+ else
+ content.split("\n").map { |c| ___(symbol, c) }
+ end
+ end
+ end
+
+ # Simple wrapper for a string
+ class JsonSymbol < String
+ def /(other)
+ JsonSymbol.new((other.is_a?(Array) ? [self, *other] : [self, other]).join('.'))
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb b/gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb
new file mode 100644
index 00000000000..f98e5f68086
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ # Notebook that was transformed into md, including location of source cells
+ class TransformedNotebook
+ attr_reader :blocks
+
+ def as_text
+ @blocks.map { |b| b[:content].gsub(/\n/, '\\n') }.join("\n")
+ end
+
+ private
+
+ def initialize(lines = [], symbol_map = {})
+ @blocks = lines.map do |line|
+ { content: line[:content], source_symbol: (symbol = line[:symbol]), source_line: symbol && symbol_map[symbol] }
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/transformer.rb b/gems/ipynbdiff/lib/ipynb_diff/transformer.rb
new file mode 100644
index 00000000000..2b386168b5d
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/transformer.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'yaml'
+require 'ipynb_diff/output_transformer'
+require 'ipynb_diff/symbolized_markdown_helper'
+require 'ipynb_diff/symbol_map'
+require 'ipynb_diff/transformed_notebook'
+require 'oj'
+
+module IpynbDiff
+ InvalidNotebookError = Class.new(StandardError)
+
+ # Returns a markdown version of the Jupyter Notebook
+ class Transformer
+ include SymbolizedMarkdownHelper
+
+ @include_frontmatter = true
+
+ def initialize(include_frontmatter: true, hide_images: false)
+ @include_frontmatter = include_frontmatter
+ @hide_images = hide_images
+ @out_transformer = OutputTransformer.new(hide_images)
+ end
+
+ def validate_notebook(notebook)
+ notebook_json = Oj::Parser.usual.parse(notebook)
+
+ return notebook_json if notebook_json&.key?('cells')
+
+ raise InvalidNotebookError
+ rescue EncodingError, Oj::ParseError, JSON::ParserError
+ raise InvalidNotebookError
+ end
+
+ def transform(notebook)
+ return TransformedNotebook.new unless notebook
+
+ notebook_json = validate_notebook(notebook)
+ transformed = transform_document(notebook_json)
+ symbol_map = SymbolMap.parse(notebook)
+
+ TransformedNotebook.new(transformed, symbol_map)
+ end
+
+ def transform_document(notebook)
+ symbol = JsonSymbol.new('.cells')
+
+ transformed_blocks = notebook['cells'].map.with_index do |cell, idx|
+ decorate_cell(transform_cell(cell, notebook, symbol / idx), cell, symbol / idx)
+ end
+
+ transformed_blocks.prepend(transform_metadata(notebook)) if @include_frontmatter
+ transformed_blocks.flatten
+ end
+
+ def decorate_cell(rows, cell, symbol)
+ tags = cell['metadata']&.fetch('tags', [])
+ type = cell['cell_type'] || 'raw'
+
+ [
+ ___(symbol, %(%% Cell type:#{type} id:#{cell['id']} tags:#{tags&.join(',')})),
+ ___,
+ rows,
+ ___
+ ]
+ end
+
+ def transform_cell(cell, notebook, symbol)
+ cell['cell_type'] == 'code' ? transform_code_cell(cell, notebook, symbol) : transform_text_cell(cell, symbol)
+ end
+
+ def transform_code_cell(cell, notebook, symbol)
+ [
+ ___(symbol / 'source', %(``` #{notebook.dig('metadata', 'kernelspec', 'language') || ''})),
+ symbolize_array(symbol / 'source', cell['source'], &:rstrip),
+ ___(nil, '```'),
+ transform_outputs(cell['outputs'], symbol)
+ ]
+ end
+
+ def transform_outputs(outputs, symbol)
+ transformed = outputs.map
+ .with_index { |output, i| @out_transformer.transform(output, symbol / ['outputs', i]) }
+ .compact
+ .map { |el| [___, el] }
+
+ [
+ transformed.empty? ? [] : [___, ___(symbol / 'outputs', '%% Output')],
+ transformed
+ ]
+ end
+
+ def transform_text_cell(cell, symbol)
+ symbolize_array(symbol / 'source', cell['source'], &:rstrip)
+ end
+
+ def transform_metadata(notebook_json)
+ as_yaml = {
+ 'jupyter' => {
+ 'kernelspec' => notebook_json['metadata']['kernelspec'],
+ 'language_info' => notebook_json['metadata']['language_info'],
+ 'nbformat' => notebook_json['nbformat'],
+ 'nbformat_minor' => notebook_json['nbformat_minor']
+ }
+ }.to_yaml
+
+ as_yaml.split("\n").map { |l| ___(nil, l) }.append(___(nil, '---'), ___)
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/version.rb b/gems/ipynbdiff/lib/ipynb_diff/version.rb
new file mode 100644
index 00000000000..1a407f9c0fa
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ module Version
+ VERSION = '0.4.7'
+ end
+end
diff --git a/gems/ipynbdiff/spec/benchmark.rb b/gems/ipynbdiff/spec/benchmark.rb
new file mode 100644
index 00000000000..514c8700183
--- /dev/null
+++ b/gems/ipynbdiff/spec/benchmark.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'ipynbdiff'
+require 'benchmark'
+require 'benchmark/memory'
+require_relative 'test_helper'
+
+# rubocop:disable Layout/LineLength
+large_cell = '{
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "24f32781-48bf-4378-b30c-ccdce7b05ba0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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\n",
+ "text/plain": [
+ "<Figure size 2000x600 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "do_plot(is_sin = False)"
+ ]
+},'
+# rubocop:enable Layout/LineLength
+
+base = '{
+ "cells": [
+ <<>>{
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}'
+
+SMALL_NOTEBOOK = base.gsub('<<>>', large_cell)
+LARGE_NOTEBOOK = base.gsub('<<>>', Array.new(100, large_cell).join("\n"))
+
+puts "Small Notebook: #{SMALL_NOTEBOOK.bytesize}"
+puts "Large Notebook: #{LARGE_NOTEBOOK.bytesize}"
+
+def cases(benchmark_runner)
+ benchmark_runner.report('small_notebook') { IpynbDiff.transform(SMALL_NOTEBOOK) }
+ benchmark_runner.report('large_notebook') { IpynbDiff.transform(LARGE_NOTEBOOK) }
+end
+
+Benchmark.benchmark { |x| cases(x) }
+Benchmark.memory { |x| cases(x) }
diff --git a/gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb b/gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb
new file mode 100644
index 00000000000..94cb10772aa
--- /dev/null
+++ b/gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require_relative '../test_helper'
+
+describe IpynbDiff::SymbolMap do
+ def res(*cases)
+ cases&.to_h || []
+ end
+
+ describe '.parse' do
+ subject(:symbol_map) { described_class.parse(JSON.pretty_generate(source)) }
+
+ context 'when object has blank key' do
+ let(:source) { { "": { "": 5 } } }
+
+ it { is_expected.to match_array(res([".", 2], ["..", 3])) }
+ end
+
+ context 'when object is empty' do
+ let(:source) { {} }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when object is empty array' do
+ let(:source) { [] }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when object has inner object and number' do
+ let(:source) { { obj1: { obj2: 1 } } }
+
+ it { is_expected.to match_array(res(['.obj1', 2], ['.obj1.obj2', 3])) }
+ end
+
+ context 'when object has inner object and number, string and array with object' do
+ let(:source) { { obj1: { obj2: [123, 2, true], obj3: "hel\nlo", obj4: true, obj5: 123, obj6: 'a' } } }
+
+ specify do
+ expect(symbol_map).to match_array(
+ res(['.obj1', 2],
+ ['.obj1.obj2', 3],
+ ['.obj1.obj2.0', 4],
+ ['.obj1.obj2.1', 5],
+ ['.obj1.obj2.2', 6],
+ ['.obj1.obj3', 8],
+ ['.obj1.obj4', 9],
+ ['.obj1.obj5', 10],
+ ['.obj1.obj6', 11])
+ )
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb b/gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb
new file mode 100644
index 00000000000..214a8192542
--- /dev/null
+++ b/gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require_relative '../test_helper'
+
+describe IpynbDiff::Transformer do
+ describe '.transform' do
+ using RSpec::Parameterized::TableSyntax
+
+ let!(:default_config) { { include_frontmatter: false, hide_images: false } }
+
+ let(:test_case) { read_test_case(test_case_name) }
+ let(:notebook) { test_case[:input] || FROM_IPYNB }
+ let(:config) { {} }
+
+ subject { described_class.new(**default_config.merge(config)).transform(notebook) }
+
+ where(:ctx, :test_case_name, :config) do
+ 'renders metadata' | 'no_cells' | { include_frontmatter: true }
+ 'is empty for no cells, but metadata is false' | 'no_cells_no_metadata' | {}
+ 'adds markdown cell' | 'only_md' | {}
+ 'adds block with only one line of markdown' | 'single_line_md' | {}
+ 'adds raw block' | 'only_raw' | {}
+ 'code cell, but no output' | 'only_code' | {}
+ 'code cell, but no language' | 'only_code_no_language' | {}
+ 'code cell, but no kernelspec' | 'only_code_no_kernelspec' | {}
+ 'code cell, but no nb metadata' | 'only_code_no_metadata' | {}
+ 'text output' | 'text_output' | {}
+ 'ignores html output' | 'ignore_html_output' | {}
+ 'extracts png output along with text' | 'text_png_output' | {}
+ 'embeds svg as image' | 'svg' | {}
+ 'extracts latex output' | 'latex_output' | {}
+ 'extracts error output' | 'error_output' | {}
+ 'does not fetch tags if there is no cell metadata' | 'no_metadata_on_cell' | {}
+ 'generates :percent decorator' | 'percent_decorator' | {}
+ 'parses stream output' | 'stream_text' | {}
+ 'ignores unknown output type' | 'unknown_output_type' | {}
+ 'handles backslash correctly' | 'backslash_as_last_char' | {}
+ 'multiline png output' | 'multiline_png_output' | {}
+ 'hides images when option passed' | 'hide_images' | { hide_images: true }
+ '\n within source lines' | 'source_with_linebreak' | { hide_images: true }
+ end
+
+ with_them do
+ it 'generates the expected markdown' do
+ expect(subject.as_text).to eq test_case[:expected_markdown]
+ end
+
+ it 'marks the lines correctly' do
+ blocks = subject.blocks.map { |b| b[:source_symbol] }.join("\n")
+
+ expect(blocks).to eq test_case[:expected_symbols]
+ end
+ end
+
+ describe 'Source line map' do
+ let(:config) { { include_frontmatter: false } }
+ let(:test_case_name) { 'text_png_output' }
+
+ it 'generates the correct transformed to source line map' do
+ line_numbers = subject.blocks.map { |b| b[:source_line] }.join("\n")
+
+ expect(line_numbers).to eq test_case[:expected_line_numbers]
+ end
+ end
+
+ context 'when json is invalid' do
+ let(:notebook) { 'a' }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(IpynbDiff::InvalidNotebookError)
+ end
+ end
+
+ context 'when it does not have the cell tag' do
+ let(:notebook) { '{"metadata":[]}' }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(IpynbDiff::InvalidNotebookError)
+ end
+ end
+
+ context 'when notebook can not be parsed' do
+ let(:notebook) { '{"cells":[]}' }
+
+ before do
+ allow(Oj::Parser.usual).to receive(:parse).and_return(nil)
+ end
+
+ it 'raises error' do
+ expect { subject }.to raise_error(IpynbDiff::InvalidNotebookError)
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/spec/ipynb_diff_spec.rb b/gems/ipynbdiff/spec/ipynb_diff_spec.rb
new file mode 100644
index 00000000000..3ca092aaf5c
--- /dev/null
+++ b/gems/ipynbdiff/spec/ipynb_diff_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require_relative 'test_helper'
+
+describe IpynbDiff do
+ def diff_signs(diff)
+ diff.to_s(:text).scan(/.*\n/).map { |l| l[0] }.join('')
+ end
+
+ describe '.diff' do
+ let(:from_path) { FROM_PATH }
+ let(:to_path) { TO_PATH }
+ let(:from) { File.read(from_path) }
+ let(:to) { File.read(to_path) }
+ let(:include_frontmatter) { false }
+ let(:hide_images) { false }
+
+ subject { described_class.diff(from, to, include_frontmatter: include_frontmatter, hide_images: hide_images) }
+
+ context 'if preprocessing is active' do
+ it { is_expected.not_to include('<td>') }
+ end
+
+ context 'when to is nil' do
+ let(:to) { nil }
+ let(:from_path) { test_case_input_path('only_md') }
+
+ it 'all lines are removals' do
+ expect(diff_signs(subject)).to eq('-----')
+ end
+ end
+
+ context 'when from is nil' do
+ let(:from) { nil }
+ let(:to_path) { test_case_input_path('only_md') }
+
+ it 'all lines are additions' do
+ expect(diff_signs(subject)).to eq('+++++')
+ end
+ end
+
+ context 'when include_frontmatter is true' do
+ let(:include_frontmatter) { true }
+
+ it 'shows changes metadata in the metadata' do
+ expect(subject.to_s(:text)).to include('+ display_name: New Python 3 (ipykernel)')
+ end
+ end
+
+ context 'when hide_images is true' do
+ let(:hide_images) { true }
+
+ it 'hides images' do
+ expect(subject.to_s(:text)).to include(' [Hidden Image Output]')
+ end
+ end
+
+ context 'when include_frontmatter is false' do
+ it 'drops metadata from the diff' do
+ expect(subject.to_s(:text)).not_to include('+ display_name: New Python 3 (ipykernel)')
+ end
+ end
+
+ context 'when either notebook can not be processed' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ctx, :from, :to) do
+ 'because from is invalid' | 'a' | nil
+ 'because from does not have the cell tag' | '{"metadata":[]}' | nil
+ 'because to is invalid' | nil | 'a'
+ 'because to does not have the cell tag' | nil | '{"metadata":[]}'
+ end
+
+ with_them do
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
+ describe '.transform' do
+ let(:notebook) { FROM_IPYNB }
+ let(:include_frontmatter) { false }
+ let(:hide_images) { false }
+
+ subject do
+ described_class.transform(notebook,
+ include_frontmatter: include_frontmatter,
+ hide_images: hide_images)
+ end
+
+ describe 'error cases' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ctx, :notebook) do
+ 'notebook is nil' | nil
+ 'notebook is invalid' | 'a'
+ 'notebook does not have cell' | '{"metadata":[]}'
+ end
+
+ with_them do
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe 'options' do
+ context 'when include_frontmatter is false' do
+ it { is_expected.not_to include('display_name: Python 3 (ipykernel)') }
+ end
+
+ context 'when include_frontmatter is true' do
+ let(:include_frontmatter) { true }
+
+ it { is_expected.to include('display_name: Python 3 (ipykernel)') }
+ end
+
+ context 'when hide_images is false' do
+ it { is_expected.not_to include('[Hidden Image Output]') }
+ end
+
+ context 'when hide_images is true' do
+ let(:hide_images) { true }
+
+ it { is_expected.to include(' [Hidden Image Output]') }
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/spec/test_helper.rb b/gems/ipynbdiff/spec/test_helper.rb
new file mode 100644
index 00000000000..626b72b99f0
--- /dev/null
+++ b/gems/ipynbdiff/spec/test_helper.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'simplecov'
+SimpleCov.start
+
+require 'ipynb_diff'
+require 'rspec'
+require 'rspec-parameterized'
+require 'json'
+
+BASE_PATH = File.join(__dir__ || '', 'testdata')
+
+FROM_PATH = File.join(BASE_PATH, 'from.ipynb')
+TO_PATH = File.join(BASE_PATH, 'to.ipynb')
+
+FROM_IPYNB = File.read(FROM_PATH)
+TO_IPYNB = File.read(TO_PATH)
+
+def test_case_input_path(test_case)
+ File.join(BASE_PATH, test_case, 'input.ipynb')
+end
+
+def test_case_symbols_path(test_case)
+ File.join(BASE_PATH, test_case, 'expected_symbols.txt')
+end
+
+def test_case_md_path(test_case)
+ File.join(BASE_PATH, test_case, 'expected.md')
+end
+
+def test_case_line_numbers_path(test_case)
+ File.join(BASE_PATH, test_case, 'expected_line_numbers.txt')
+end
+
+def read_file_if_exists(path)
+ File.read(path) if File.file?(path)
+end
+
+def read_test_case(test_case_name)
+ {
+ input: read_file_if_exists(test_case_input_path(test_case_name)),
+ expected_markdown: read_file_if_exists(test_case_md_path(test_case_name)),
+ expected_symbols: read_file_if_exists(test_case_symbols_path(test_case_name)),
+ expected_line_numbers: read_file_if_exists(test_case_line_numbers_path(test_case_name))
+ }
+end
diff --git a/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md
new file mode 100644
index 00000000000..299e286c679
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md
@@ -0,0 +1,7 @@
+%% Cell type:markdown id: tags:
+
+\
+
+%% Cell type:markdown id: tags:
+
+a
diff --git a/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt
new file mode 100644
index 00000000000..6fa29ae28de
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt
@@ -0,0 +1,7 @@
+.cells.0
+
+.cells.0.source.0
+
+.cells.1
+
+.cells.1.source.0
diff --git a/gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb
new file mode 100644
index 00000000000..0714044e3ae
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb
@@ -0,0 +1,16 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "source": [
+ "\\"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "source": [
+ "a"
+ ]
+ }
+ ]
+}
diff --git a/gems/ipynbdiff/spec/testdata/error_output/expected.md b/gems/ipynbdiff/spec/testdata/error_output/expected.md
new file mode 100644
index 00000000000..e6e8a075598
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/error_output/expected.md
@@ -0,0 +1,16 @@
+%% Cell type:code id:5 tags:
+
+``` python
+# A cell that has an error
+y = sin(x)
+```
+
+%% Output
+
+ ---------------------------------------------------------------------------
+ NameError Traceback (most recent call last)
+ /var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_72857/3962062127.py in <module>
+ 1 # A cell that has an error
+ ----> 2 y = sin(x)
+
+ NameError: name 'sin' is not defined
diff --git a/gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt
new file mode 100644
index 00000000000..5d2f248135d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt
@@ -0,0 +1,16 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.traceback.0
+.cells.0.outputs.0.traceback.1
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.3
diff --git a/gems/ipynbdiff/spec/testdata/error_output/input.ipynb b/gems/ipynbdiff/spec/testdata/error_output/input.ipynb
new file mode 100644
index 00000000000..45ee81a0e2d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/error_output/input.ipynb
@@ -0,0 +1,32 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "5",
+ "metadata": {},
+ "outputs": [
+ {
+ "ename": "NameError",
+ "evalue": "name 'sin' is not defined",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)",
+ "\u001b[0;32m/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_72857/3962062127.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# A cell that has an error\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0my\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msin\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mx\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
+ "\u001b[0;31mNameError\u001b[0m: name 'sin' is not defined"
+ ]
+ }
+ ],
+ "source": [
+ "# A cell that has an error\n",
+ "y = sin(x)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/from.ipynb b/gems/ipynbdiff/spec/testdata/from.ipynb
new file mode 100644
index 00000000000..68a4b11cbbc
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/from.ipynb
@@ -0,0 +1,197 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0aac5da7-745c-4eda-847a-3d0d07a1bb9b",
+ "metadata": {
+ "tags": []
+ },
+ "source": [
+ "# This is a markdown cell\n",
+ "\n",
+ "This paragraph has\n",
+ "With\n",
+ "Many\n",
+ "Lines. How we will he handle MR notes?\n",
+ "\n",
+ "But I can add another paragraph"
+ ]
+ },
+ {
+ "cell_type": "raw",
+ "id": "faecea5b-de0a-49fa-9a3a-61c2add652da",
+ "metadata": {},
+ "source": [
+ "This is a raw cell\n",
+ "With\n",
+ "Multiple lines"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "893ca2c0-ab75-4276-9dad-be1c40e16e8a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "0d707fb5-226f-46d6-80bd-489ebfb8905c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "np.random.seed(42)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "35467fcf-28b1-4c7b-bb09-4cb192c35293",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ }, "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x123e39370>]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "some_invalid_base64_image_here\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "x = np.linspace(0, 4*np.pi,50)\n",
+ "y = np.sin(x)\n",
+ "\n",
+ "plt.plot(x, y)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "dc1178cd-c46d-4da3-9ab5-08f000699884",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "df = pd.DataFrame({\"x\": x, \"y\": y})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "6e749b4f-b409-4700-870f-f68c39462490",
+ "metadata": {
+ "tags": [
+ "some-table"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "<div>\n",
+ "<style scoped>\n",
+ " .dataframe tbody tr th:only-of-type {\n",
+ " vertical-align: middle;\n",
+ " }\n",
+ "\n",
+ " .dataframe tbody tr th {\n",
+ " vertical-align: top;\n",
+ " }\n",
+ "\n",
+ " .dataframe thead th {\n",
+ " text-align: right;\n",
+ " }\n",
+ "</style>\n",
+ "<table border=\"1\" class=\"dataframe\">\n",
+ " <thead>\n",
+ " <tr style=\"text-align: right;\">\n",
+ " <th></th>\n",
+ " <th>x</th>\n",
+ " <th>y</th>\n",
+ " </tr>\n",
+ " </thead>\n",
+ " <tbody>\n",
+ " <tr>\n",
+ " <th>0</th>\n",
+ " <td>0.000000</td>\n",
+ " <td>0.000000</td>\n",
+ " </tr>\n",
+ " <tr>\n",
+ " <th>1</th>\n",
+ " <td>0.256457</td>\n",
+ " <td>0.253655</td>\n",
+ " </tr>\n",
+ " </tbody>\n",
+ "</table>\n",
+ "</div>"
+ ],
+ "text/plain": [
+ " x y\n",
+ "0 0.000000 0.000000\n",
+ "1 0.256457 0.253655"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "df[:2]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0ddef5ef-94a3-4afd-9c70-ddee9694f512",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/hide_images/expected.md b/gems/ipynbdiff/spec/testdata/hide_images/expected.md
new file mode 100644
index 00000000000..ff63d351a3b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/hide_images/expected.md
@@ -0,0 +1,10 @@
+%% Cell type:code id:5 tags:senoid
+
+``` python
+```
+
+%% Output
+
+ [Hidden Image Output]
+
+ [Hidden Image Output]
diff --git a/gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt
new file mode 100644
index 00000000000..b8f24f9fba5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt
@@ -0,0 +1,10 @@
+.cells.0
+
+.cells.0.source
+
+
+.cells.0.outputs
+
+
+
+
diff --git a/gems/ipynbdiff/spec/testdata/hide_images/input.ipynb b/gems/ipynbdiff/spec/testdata/hide_images/input.ipynb
new file mode 100644
index 00000000000..dab0e5bb9cf
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/hide_images/input.ipynb
@@ -0,0 +1,45 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "this_is_an_invalid_hash_for_testing_purposes\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><circle cx=\"50\" cy=\"50\" r=\"50\"/></svg>",
+ "text/plain": [
+ "<IPython.core.display.SVG object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md
new file mode 100644
index 00000000000..3085da739ed
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md
@@ -0,0 +1,11 @@
+%% Cell type:code id:5 tags:some-table
+
+``` python
+df[:2]
+```
+
+%% Output
+
+ x y
+ 0 0.000000 0.000000
+ 1 0.256457 0.507309
diff --git a/gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt
new file mode 100644
index 00000000000..3bf319d1fa6
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt
@@ -0,0 +1,11 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/plain.0
+.cells.0.outputs.0.data.text/plain.1
+.cells.0.outputs.0.data.text/plain.2
diff --git a/gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb b/gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb
new file mode 100644
index 00000000000..26117a78934
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb
@@ -0,0 +1,74 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "some-table"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "<div>\n",
+ "<style scoped>\n",
+ " .dataframe tbody tr th:only-of-type {\n",
+ " vertical-align: middle;\n",
+ " }\n",
+ "\n",
+ " .dataframe tbody tr th {\n",
+ " vertical-align: top;\n",
+ " }\n",
+ "\n",
+ " .dataframe thead th {\n",
+ " text-align: right;\n",
+ " }\n",
+ "</style>\n",
+ "<table border=\"1\" class=\"dataframe\">\n",
+ " <thead>\n",
+ " <tr style=\"text-align: right;\">\n",
+ " <th></th>\n",
+ " <th>x</th>\n",
+ " <th>y</th>\n",
+ " </tr>\n",
+ " </thead>\n",
+ " <tbody>\n",
+ " <tr>\n",
+ " <th>0</th>\n",
+ " <td>0.000000</td>\n",
+ " <td>0.000000</td>\n",
+ " </tr>\n",
+ " <tr>\n",
+ " <th>1</th>\n",
+ " <td>0.256457</td>\n",
+ " <td>0.507309</td>\n",
+ " </tr>\n",
+ " </tbody>\n",
+ "</table>\n",
+ "</div>"
+ ],
+ "text/plain": [
+ " x y\n",
+ "0 0.000000 0.000000\n",
+ "1 0.256457 0.507309"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "df[:2]"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/latex_output/expected.md b/gems/ipynbdiff/spec/testdata/latex_output/expected.md
new file mode 100644
index 00000000000..194c1f43c42
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/latex_output/expected.md
@@ -0,0 +1,10 @@
+%% Cell type:code id:5 tags:
+
+``` python
+from IPython.display import display, Math
+display(Math(r'Dims: {}x{}m \\ Area: {}m^2 \\ Volume: {}m^3'.format(1, round(2,2), 3, 4)))
+```
+
+%% Output
+
+ $\displaystyle Dims: 1x2m \\ Area: 3m^2 \\ Volume: 4m^3$
diff --git a/gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt
new file mode 100644
index 00000000000..868adca2712
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt
@@ -0,0 +1,10 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/latex.0
diff --git a/gems/ipynbdiff/spec/testdata/latex_output/input.ipynb b/gems/ipynbdiff/spec/testdata/latex_output/input.ipynb
new file mode 100644
index 00000000000..f8ff3e72beb
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/latex_output/input.ipynb
@@ -0,0 +1,34 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "5",
+ "outputs": [
+ {
+ "data": {
+ "text/latex": [
+ "$\\displaystyle Dims: 1x2m \\\\ Area: 3m^2 \\\\ Volume: 4m^3$"
+ ],
+ "text/plain": [
+ "<IPython.core.display.Math object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from IPython.display import display, Math\n",
+ "display(Math(r'Dims: {}x{}m \\\\ Area: {}m^2 \\\\ Volume: {}m^3'.format(1, round(2,2), 3, 4)))"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md
new file mode 100644
index 00000000000..0a69c8370e7
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md
@@ -0,0 +1,9 @@
+%% Cell type:code id:5 tags:
+
+```
+Some Image
+```
+
+%% Output
+
+ ![](data:image/png;base64,this_is_an_invalid_hash_for_testing_purposes)
diff --git a/gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt
new file mode 100644
index 00000000000..1b66012ef20
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt
@@ -0,0 +1,9 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.image/png
diff --git a/gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb b/gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb
new file mode 100644
index 00000000000..4d19a504553
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb
@@ -0,0 +1,25 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "id": "5",
+ "metadata": {
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": [
+ "this_is_an_invalid_hash_for_testing_purposes"
+ ]
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "Some Image"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/no_cells/expected.md b/gems/ipynbdiff/spec/testdata/no_cells/expected.md
new file mode 100644
index 00000000000..b7c09c51fb8
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells/expected.md
@@ -0,0 +1,19 @@
+---
+jupyter:
+ kernelspec:
+ display_name: Python 3 (ipykernel)
+ language: python
+ name: python3
+ language_info:
+ codemirror_mode:
+ name: ipython
+ version: 3
+ file_extension: ".py"
+ mimetype: text/x-python
+ name: python
+ nbconvert_exporter: python
+ pygments_lexer: ipython3
+ version: 3.9.7
+ nbformat: 4
+ nbformat_minor: 5
+---
diff --git a/gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt
new file mode 100644
index 00000000000..a60f3032882
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gems/ipynbdiff/spec/testdata/no_cells/input.ipynb b/gems/ipynbdiff/spec/testdata/no_cells/input.ipynb
new file mode 100644
index 00000000000..c2ba0ebf50a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells/input.ipynb
@@ -0,0 +1,25 @@
+{
+ "cells": [],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md
diff --git a/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt
diff --git a/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb
new file mode 100644
index 00000000000..c2ba0ebf50a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb
@@ -0,0 +1,25 @@
+{
+ "cells": [],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md
new file mode 100644
index 00000000000..d9d72bf8f76
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md
@@ -0,0 +1,13 @@
+%% Cell type:markdown id:1 tags:
+
+# A
+
+B
+
+%% Cell type:code id:3 tags:
+
+``` python
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+```
diff --git a/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt
new file mode 100644
index 00000000000..a7000494a1b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt
@@ -0,0 +1,13 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+
+.cells.1
+
+.cells.1.source
+.cells.1.source.0
+.cells.1.source.1
+.cells.1.source.2
+
diff --git a/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb
new file mode 100644
index 00000000000..62060124a2a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb
@@ -0,0 +1,29 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code/expected.md b/gems/ipynbdiff/spec/testdata/only_code/expected.md
new file mode 100644
index 00000000000..124b8217a6a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code/expected.md
@@ -0,0 +1,7 @@
+%% Cell type:code id:3 tags:
+
+``` python
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt
new file mode 100644
index 00000000000..59b11103195
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt
@@ -0,0 +1,7 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code/input.ipynb
new file mode 100644
index 00000000000..a93108dccb8
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code/input.ipynb
@@ -0,0 +1,21 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md
new file mode 100644
index 00000000000..c6d8e13fc3a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:code id:3 tags:
+
+```
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt
new file mode 100644
index 00000000000..2e902582e14
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb
new file mode 100644
index 00000000000..c3ff71057a6
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb
@@ -0,0 +1,12 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "source": "",
+ "outputs": []
+ }
+ ],
+ "metadata": {}
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md
new file mode 100644
index 00000000000..c6d8e13fc3a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:code id:3 tags:
+
+```
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt
new file mode 100644
index 00000000000..2e902582e14
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb
new file mode 100644
index 00000000000..fb16b106cbe
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb
@@ -0,0 +1,14 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "source": "",
+ "outputs": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {}
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md
new file mode 100644
index 00000000000..c6d8e13fc3a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:code id:3 tags:
+
+```
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt
new file mode 100644
index 00000000000..2e902582e14
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb
new file mode 100644
index 00000000000..364c080168b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb
@@ -0,0 +1,11 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "source": "",
+ "outputs": []
+ }
+ ]
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_md/expected.md b/gems/ipynbdiff/spec/testdata/only_md/expected.md
new file mode 100644
index 00000000000..bdf4db5aea5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_md/expected.md
@@ -0,0 +1,5 @@
+%% Cell type:markdown id:1 tags:hello,world
+
+# A
+
+B
diff --git a/gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt
new file mode 100644
index 00000000000..d3d6d526fc3
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt
@@ -0,0 +1,5 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
diff --git a/gems/ipynbdiff/spec/testdata/only_md/input.ipynb b/gems/ipynbdiff/spec/testdata/only_md/input.ipynb
new file mode 100644
index 00000000000..9d6b550af31
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_md/input.ipynb
@@ -0,0 +1,21 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_raw/expected.md b/gems/ipynbdiff/spec/testdata/only_raw/expected.md
new file mode 100644
index 00000000000..91c476e843b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_raw/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:raw id:2 tags:
+
+A
+B
diff --git a/gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt
new file mode 100644
index 00000000000..bceaf355c2f
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
diff --git a/gems/ipynbdiff/spec/testdata/only_raw/input.ipynb b/gems/ipynbdiff/spec/testdata/only_raw/input.ipynb
new file mode 100644
index 00000000000..750e1bba615
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_raw/input.ipynb
@@ -0,0 +1,15 @@
+{
+ "cells": [
+ {
+ "cell_type": "raw",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "A\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/percent_decorator/expected.md b/gems/ipynbdiff/spec/testdata/percent_decorator/expected.md
new file mode 100644
index 00000000000..1ece1f2fd06
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/percent_decorator/expected.md
@@ -0,0 +1,68 @@
+%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:
+
+# This is a markdown cell
+
+This paragraph has
+With
+Many
+Lines. How we will he handle MR notes?
+
+But I can add another paragraph
+
+%% Cell type:raw id:faecea5b-de0a-49fa-9a3a-61c2add652da tags:
+
+This is a raw cell
+With
+Multiple lines
+
+%% Cell type:code id:893ca2c0-ab75-4276-9dad-be1c40e16e8a tags:
+
+``` python
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+```
+
+%% Cell type:code id:0d707fb5-226f-46d6-80bd-489ebfb8905c tags:
+
+``` python
+np.random.seed(42)
+```
+
+%% Cell type:code id:35467fcf-28b1-4c7b-bb09-4cb192c35293 tags:senoid
+
+``` python
+x = np.linspace(0, 4*np.pi,50)
+y = np.sin(x)
+
+plt.plot(x, y)
+```
+
+%% Output
+
+ [<matplotlib.lines.Line2D at 0x123e39370>]
+
+ ![](data:image/png;base64,some_invalid_base64_image_here)
+
+%% Cell type:code id:dc1178cd-c46d-4da3-9ab5-08f000699884 tags:
+
+``` python
+df = pd.DataFrame({"x": x, "y": y})
+```
+
+%% Cell type:code id:6e749b4f-b409-4700-870f-f68c39462490 tags:some-table
+
+``` python
+df[:2]
+```
+
+%% Output
+
+ x y
+ 0 0.000000 0.000000
+ 1 0.256457 0.253655
+
+%% Cell type:code id:0ddef5ef-94a3-4afd-9c70-ddee9694f512 tags:
+
+``` python
+```
diff --git a/gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt
new file mode 100644
index 00000000000..c95665d1903
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt
@@ -0,0 +1,68 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+.cells.0.source.3
+.cells.0.source.4
+.cells.0.source.5
+.cells.0.source.6
+.cells.0.source.7
+
+.cells.1
+
+.cells.1.source.0
+.cells.1.source.1
+.cells.1.source.2
+
+.cells.2
+
+.cells.2.source
+.cells.2.source.0
+.cells.2.source.1
+.cells.2.source.2
+
+
+.cells.3
+
+.cells.3.source
+.cells.3.source.0
+
+
+.cells.4
+
+.cells.4.source
+.cells.4.source.0
+.cells.4.source.1
+.cells.4.source.2
+.cells.4.source.3
+
+
+.cells.4.outputs
+
+.cells.4.outputs.0.data.text/plain.0
+
+.cells.4.outputs.1.data.image/png
+
+.cells.5
+
+.cells.5.source
+.cells.5.source.0
+
+
+.cells.6
+
+.cells.6.source
+.cells.6.source.0
+
+
+.cells.6.outputs
+
+.cells.6.outputs.0.data.text/plain.0
+.cells.6.outputs.0.data.text/plain.1
+.cells.6.outputs.0.data.text/plain.2
+
+.cells.7
+
+.cells.7.source
+
diff --git a/gems/ipynbdiff/spec/testdata/single_line_md/expected.md b/gems/ipynbdiff/spec/testdata/single_line_md/expected.md
new file mode 100644
index 00000000000..392a5048f59
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/single_line_md/expected.md
@@ -0,0 +1,3 @@
+%% Cell type:markdown id:1 tags:hello,world
+
+A
diff --git a/gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt
new file mode 100644
index 00000000000..86a7f6b3960
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt
@@ -0,0 +1,3 @@
+.cells.0
+
+.cells.0.source
diff --git a/gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb b/gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb
new file mode 100644
index 00000000000..5ebd41adbfa
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb
@@ -0,0 +1,17 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": "A"
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md
new file mode 100644
index 00000000000..180fffe24ce
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md
@@ -0,0 +1,5 @@
+%% Cell type:markdown id: tags:
+
+> This is a test
+>
+> To see if I can duplicate my bug
diff --git a/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt
new file mode 100644
index 00000000000..1e8bdda4b9a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt
@@ -0,0 +1,5 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source
+.cells.0.source
diff --git a/gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb b/gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb
new file mode 100644
index 00000000000..faacc703969
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb
@@ -0,0 +1,11 @@
+{
+ "metadata": {
+ },
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "source": "> This is a test\n>\n> To see if I can duplicate my bug",
+ "metadata": {}
+ }
+ ]
+}
diff --git a/gems/ipynbdiff/spec/testdata/stream_text/expected.md b/gems/ipynbdiff/spec/testdata/stream_text/expected.md
new file mode 100644
index 00000000000..0448bf21111
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/stream_text/expected.md
@@ -0,0 +1,9 @@
+%% Cell type:code id:123 tags:
+
+``` python
+print("G'bye")
+```
+
+%% Output
+
+ G'bye
diff --git a/gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt
new file mode 100644
index 00000000000..be4e2861ea9
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt
@@ -0,0 +1,9 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.text.0
diff --git a/gems/ipynbdiff/spec/testdata/stream_text/input.ipynb b/gems/ipynbdiff/spec/testdata/stream_text/input.ipynb
new file mode 100644
index 00000000000..14601fe35e5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/stream_text/input.ipynb
@@ -0,0 +1,27 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "123",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "G'bye\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"G'bye\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/svg/expected.md b/gems/ipynbdiff/spec/testdata/svg/expected.md
new file mode 100644
index 00000000000..a5a167d31c5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/svg/expected.md
@@ -0,0 +1,17 @@
+%% Cell type:code id:5 tags:
+
+``` python
+from IPython.display import SVG, display
+
+svg = """<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="50" cy="50" r="50"/>
+</svg>"""
+
+display(SVG(svg))
+```
+
+%% Output
+
+ ![](data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50"/></svg>)
+
+ ![](data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50"/></svg>)
diff --git a/gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt
new file mode 100644
index 00000000000..861198a8c92
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt
@@ -0,0 +1,17 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+.cells.0.source.3
+.cells.0.source.4
+.cells.0.source.5
+.cells.0.source.6
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.image/svg+xml
+
+.cells.0.outputs.1.data.image/svg+xml
diff --git a/gems/ipynbdiff/spec/testdata/svg/input.ipynb b/gems/ipynbdiff/spec/testdata/svg/input.ipynb
new file mode 100644
index 00000000000..a02d01f7bf2
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/svg/input.ipynb
@@ -0,0 +1,66 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "5",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\">\n",
+ " <circle cx=\"50\" cy=\"50\" r=\"50\"/>\n",
+ "</svg>"
+ ],
+ "text/plain": [
+ "<IPython.core.display.SVG object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><circle cx=\"50\" cy=\"50\" r=\"50\"/></svg>",
+ "text/plain": [
+ "<IPython.core.display.SVG object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from IPython.display import SVG, display\n",
+ "\n",
+ "svg = \"\"\"<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\">\n",
+ " <circle cx=\"50\" cy=\"50\" r=\"50\"/>\n",
+ "</svg>\"\"\"\n",
+ "\n",
+ "display(SVG(svg))"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.8.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/text_output/expected.md b/gems/ipynbdiff/spec/testdata/text_output/expected.md
new file mode 100644
index 00000000000..1b6c086ecd5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_output/expected.md
@@ -0,0 +1,9 @@
+%% Cell type:code id:5 tags:senoid
+
+``` python
+plt.plot(x, y)
+```
+
+%% Output
+
+ [<matplotlib.lines.Line2D at 0x12a4e43d0>]
diff --git a/gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt
new file mode 100644
index 00000000000..a004d852ba4
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt
@@ -0,0 +1,9 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/plain.0
diff --git a/gems/ipynbdiff/spec/testdata/text_output/input.ipynb b/gems/ipynbdiff/spec/testdata/text_output/input.ipynb
new file mode 100644
index 00000000000..b1b387bb99d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_output/input.ipynb
@@ -0,0 +1,31 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x12a4e43d0>]"
+ ]
+ },
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "plt.plot(x, y)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/expected.md b/gems/ipynbdiff/spec/testdata/text_png_output/expected.md
new file mode 100644
index 00000000000..c77f109378c
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/expected.md
@@ -0,0 +1,14 @@
+%% Cell type:code id:5 tags:senoid
+
+``` python
+x = np.linspace(0, 4*np.pi,50)
+y = 2 * np.sin(x)
+
+plt.plot(x, y)
+```
+
+%% Output
+
+ [<matplotlib.lines.Line2D at 0x12a4e43d0>]
+
+ ![](data:image/png;base64,this_is_an_invalid_hash_for_testing_purposes)
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt b/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt
new file mode 100644
index 00000000000..62e35deb96d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt
@@ -0,0 +1,14 @@
+3
+
+36
+37
+38
+39
+40
+
+
+12
+
+16
+
+25
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt
new file mode 100644
index 00000000000..49f2d7149d8
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt
@@ -0,0 +1,14 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+.cells.0.source.3
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/plain.0
+
+.cells.0.outputs.1.data.image/png
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb b/gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb
new file mode 100644
index 00000000000..3728b129d26
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb
@@ -0,0 +1,49 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x12a4e43d0>]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "this_is_an_invalid_hash_for_testing_purposes\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "x = np.linspace(0, 4*np.pi,50)\n",
+ "y = 2 * np.sin(x)\n",
+ "\n",
+ "plt.plot(x, y)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/to.ipynb b/gems/ipynbdiff/spec/testdata/to.ipynb
new file mode 100644
index 00000000000..99b51f3b857
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/to.ipynb
@@ -0,0 +1,200 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0aac5da7-745c-4eda-847a-3d0d07a1bb9b",
+ "metadata": {
+ "tags": []
+ },
+ "source": [
+ "# This is a markdown cell\n",
+ "\n",
+ "This paragraph has\n",
+ "With\n",
+ "Many\n",
+ "Lines. How we will he handle MR notes?\n",
+ "\n",
+ "But I can add another paragraph\n",
+ "\n",
+ "Another paragraph added"
+ ]
+ },
+ {
+ "cell_type": "raw",
+ "id": "faecea5b-de0a-49fa-9a3a-61c2add652da",
+ "metadata": {},
+ "source": [
+ "This is a raw cell\n",
+ "With\n",
+ "Multiple lines"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "893ca2c0-ab75-4276-9dad-be1c40e16e8a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "0d707fb5-226f-46d6-80bd-489ebfb8905c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "np.random.seed(42)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "35467fcf-28b1-4c7b-bb09-4cb192c35293",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x12a4e43d0>]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "another_invalid_base64_image_here\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "x = np.linspace(0, 4*np.pi,50)\n",
+ "y = 2 * np.sin(x)\n",
+ "\n",
+ "plt.plot(x, y)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "dc1178cd-c46d-4da3-9ab5-08f000699884",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "df = pd.DataFrame({\"x\": x, \"y\": y})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "6e749b4f-b409-4700-870f-f68c39462490",
+ "metadata": {
+ "tags": [
+ "some-table"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "<div>\n",
+ "<style scoped>\n",
+ " .dataframe tbody tr th:only-of-type {\n",
+ " vertical-align: middle;\n",
+ " }\n",
+ "\n",
+ " .dataframe tbody tr th {\n",
+ " vertical-align: top;\n",
+ " }\n",
+ "\n",
+ " .dataframe thead th {\n",
+ " text-align: right;\n",
+ " }\n",
+ "</style>\n",
+ "<table border=\"1\" class=\"dataframe\">\n",
+ " <thead>\n",
+ " <tr style=\"text-align: right;\">\n",
+ " <th></th>\n",
+ " <th>x</th>\n",
+ " <th>y</th>\n",
+ " </tr>\n",
+ " </thead>\n",
+ " <tbody>\n",
+ " <tr>\n",
+ " <th>0</th>\n",
+ " <td>0.000000</td>\n",
+ " <td>0.000000</td>\n",
+ " </tr>\n",
+ " <tr>\n",
+ " <th>1</th>\n",
+ " <td>0.256457</td>\n",
+ " <td>0.507309</td>\n",
+ " </tr>\n",
+ " </tbody>\n",
+ "</table>\n",
+ "</div>"
+ ],
+ "text/plain": [
+ " x y\n",
+ "0 0.000000 0.000000\n",
+ "1 0.256457 0.507309"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "df[:2]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0ddef5ef-94a3-4afd-9c70-ddee9694f512",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "New Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md
new file mode 100644
index 00000000000..af34d6eb8c3
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md
@@ -0,0 +1,5 @@
+%% Cell type:code id:123 tags:
+
+``` python
+print("G'bye")
+```
diff --git a/gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt
new file mode 100644
index 00000000000..cb35f88c897
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt
@@ -0,0 +1,5 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
diff --git a/gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb b/gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb
new file mode 100644
index 00000000000..42f4b39b365
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb
@@ -0,0 +1,27 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "123",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "unknown_output",
+ "text": [
+ "G'bye\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"G'bye\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/rspec_flaky/.gitignore b/gems/rspec_flaky/.gitignore
new file mode 100644
index 00000000000..b04a8c840df
--- /dev/null
+++ b/gems/rspec_flaky/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/gems/rspec_flaky/.gitlab-ci.yml b/gems/rspec_flaky/.gitlab-ci.yml
new file mode 100644
index 00000000000..41fac86e7a5
--- /dev/null
+++ b/gems/rspec_flaky/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "rspec_flaky"
diff --git a/gems/rspec_flaky/.rspec b/gems/rspec_flaky/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/rspec_flaky/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/rspec_flaky/.rubocop.yml b/gems/rspec_flaky/.rubocop.yml
new file mode 100644
index 00000000000..62cb8a982c5
--- /dev/null
+++ b/gems/rspec_flaky/.rubocop.yml
@@ -0,0 +1,13 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+# FIXME once Gitlab::Json is in a gem
+Gitlab/Json:
+ Enabled: false
+
+# FIXME
+RSpec/MultipleMemoizedHelpers:
+ Enabled: false
+
+RSpec/VerifiedDoubles:
+ Enabled: false
diff --git a/gems/rspec_flaky/Gemfile b/gems/rspec_flaky/Gemfile
new file mode 100644
index 00000000000..90bf29fb647
--- /dev/null
+++ b/gems/rspec_flaky/Gemfile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in rspec_flaky.gemspec
+gemspec
+
+gem "gitlab-rspec", "~> 0.1", path: "../gitlab-rspec"
diff --git a/gems/rspec_flaky/Gemfile.lock b/gems/rspec_flaky/Gemfile.lock
new file mode 100644
index 00000000000..547dc24e375
--- /dev/null
+++ b/gems/rspec_flaky/Gemfile.lock
@@ -0,0 +1,126 @@
+PATH
+ remote: ../gitlab-rspec
+ specs:
+ gitlab-rspec (0.1.0)
+ activesupport (>= 6.1, < 7.1)
+ rspec (~> 3.0)
+
+PATH
+ remote: .
+ specs:
+ rspec_flaky (0.1.0)
+ activesupport (>= 6.1, < 7.1)
+ rspec (~> 3.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (7.0.6)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ ast (2.4.2)
+ binding_of_caller (1.0.0)
+ debug_inspector (>= 0.0.1)
+ coderay (1.1.3)
+ concurrent-ruby (1.2.2)
+ debug_inspector (1.1.0)
+ diff-lcs (1.5.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ json (2.6.3)
+ minitest (5.18.1)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ racc (1.7.1)
+ rack (3.0.8)
+ rainbow (3.1.1)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-core (3.12.2)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-parameterized (1.0.0)
+ rspec-parameterized-core (< 2)
+ rspec-parameterized-table_syntax (< 2)
+ rspec-parameterized-core (1.0.0)
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-parameterized-table_syntax (1.0.0)
+ binding_of_caller
+ rspec-parameterized-core (< 2)
+ rspec-support (3.12.1)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.22.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+ unparser (0.6.8)
+ diff-lcs (~> 1.3)
+ parser (>= 3.2.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ gitlab-rspec (~> 0.1)!
+ gitlab-styles (~> 10.1.0)
+ rspec-parameterized (~> 1.0)
+ rspec_flaky!
+ rubocop (~> 1.50)
+ rubocop-rspec (~> 2.22)
+
+BUNDLED WITH
+ 2.4.14
diff --git a/gems/rspec_flaky/lib/rspec_flaky.rb b/gems/rspec_flaky/lib/rspec_flaky.rb
new file mode 100644
index 00000000000..90fc6b1dc49
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require "rspec"
+require_relative "rspec_flaky/config"
+require_relative "rspec_flaky/listener"
+require_relative "rspec_flaky/version"
diff --git a/gems/rspec_flaky/lib/rspec_flaky/config.rb b/gems/rspec_flaky/lib/rspec_flaky/config.rb
new file mode 100644
index 00000000000..ca57d1e08be
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/config.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module RspecFlaky
+ class Config
+ def self.generate_report?
+ !!(ENV['FLAKY_RSPEC_GENERATE_REPORT'] =~ /1|true/)
+ end
+
+ def self.suite_flaky_examples_report_path
+ ENV['FLAKY_RSPEC_SUITE_REPORT_PATH'] || "rspec/flaky/suite-report.json"
+ end
+
+ def self.flaky_examples_report_path
+ ENV['FLAKY_RSPEC_REPORT_PATH'] || "rspec/flaky/report.json"
+ end
+
+ def self.new_flaky_examples_report_path
+ ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] || "rspec/flaky/new-report.json"
+ end
+ end
+end
diff --git a/gems/rspec_flaky/lib/rspec_flaky/example.rb b/gems/rspec_flaky/lib/rspec_flaky/example.rb
new file mode 100644
index 00000000000..4a128a151dc
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/example.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'forwardable'
+require 'digest'
+
+module RspecFlaky
+ # This is a wrapper class for RSpec::Core::Example
+ class Example
+ extend Forwardable
+
+ def_delegators :execution_result, :status, :exception
+
+ def initialize(rspec_example)
+ @rspec_example = rspec_example.respond_to?(:example) ? rspec_example.example : rspec_example
+ end
+
+ def uid
+ @uid ||= Digest::MD5.hexdigest("#{description}-#{file}") # rubocop:disable Fips/MD5
+ end
+
+ def example_id
+ rspec_example.id
+ end
+
+ def file
+ metadata[:file_path]
+ end
+
+ def line
+ metadata[:line_number]
+ end
+
+ def description
+ metadata[:full_description]
+ end
+
+ def attempts
+ rspec_example.respond_to?(:attempts) ? rspec_example.attempts : 1
+ end
+
+ def feature_category
+ metadata[:feature_category]
+ end
+
+ def to_h
+ {
+ example_id: example_id,
+ file: file,
+ line: line,
+ description: description,
+ last_attempts_count: attempts,
+ feature_category: feature_category
+ }
+ end
+
+ private
+
+ attr_reader :rspec_example
+
+ def metadata
+ rspec_example.metadata
+ end
+
+ def execution_result
+ rspec_example.execution_result
+ end
+ end
+end
diff --git a/gems/rspec_flaky/lib/rspec_flaky/flaky_example.rb b/gems/rspec_flaky/lib/rspec_flaky/flaky_example.rb
new file mode 100644
index 00000000000..35d1f34d2a2
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/flaky_example.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'ostruct'
+
+module RspecFlaky
+ # This represents a flaky RSpec example and is mainly meant to be saved in a JSON file
+ class FlakyExample
+ ALLOWED_ATTRIBUTES = %i[
+ example_id
+ file
+ line
+ description
+ first_flaky_at
+ last_flaky_at
+ last_flaky_job
+ last_attempts_count
+ flaky_reports
+ feature_category
+ ].freeze
+
+ def initialize(example_hash)
+ @attributes = {
+ first_flaky_at: Time.now,
+ last_flaky_at: Time.now,
+ last_flaky_job: nil,
+ last_attempts_count: example_hash[:attempts],
+ flaky_reports: 0,
+ feature_category: example_hash[:feature_category]
+ }.merge(example_hash.slice(*ALLOWED_ATTRIBUTES))
+
+ %i[first_flaky_at last_flaky_at].each do |attr|
+ attributes[attr] = Time.parse(attributes[attr]) if attributes[attr].is_a?(String)
+ end
+ end
+
+ def update!(example_hash)
+ attributes[:file] = example_hash[:file]
+ attributes[:line] = example_hash[:line]
+ attributes[:description] = example_hash[:description]
+ attributes[:first_flaky_at] ||= Time.now
+ attributes[:last_flaky_at] = Time.now
+ attributes[:flaky_reports] += 1
+ attributes[:feature_category] = example_hash[:feature_category]
+ attributes[:last_attempts_count] = example_hash[:last_attempts_count] if example_hash[:last_attempts_count]
+
+ return unless ENV['CI_JOB_URL']
+
+ attributes[:last_flaky_job] = (ENV['CI_JOB_URL']).to_s
+ end
+
+ def to_h
+ attributes.dup
+ end
+
+ private
+
+ attr_reader :attributes
+ end
+end
diff --git a/gems/rspec_flaky/lib/rspec_flaky/flaky_examples_collection.rb b/gems/rspec_flaky/lib/rspec_flaky/flaky_examples_collection.rb
new file mode 100644
index 00000000000..f03fe63d11b
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/flaky_examples_collection.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'active_support/hash_with_indifferent_access'
+require 'delegate'
+
+require_relative 'flaky_example'
+
+module RspecFlaky
+ class FlakyExamplesCollection < SimpleDelegator
+ def initialize(collection = {})
+ raise ArgumentError, "`collection` must be a Hash, #{collection.class} given!" unless collection.is_a?(Hash)
+
+ collection_of_flaky_examples =
+ collection.map do |uid, example|
+ [
+ uid,
+ FlakyExample.new(example.to_h.symbolize_keys)
+ ]
+ end
+
+ super(Hash[collection_of_flaky_examples])
+ end
+
+ def to_h
+ transform_values(&:to_h).deep_symbolize_keys
+ end
+
+ def -(other)
+ raise ArgumentError, "`other` must respond to `#key?`, #{other.class} does not!" unless other.respond_to?(:key)
+
+ self.class.new(reject { |uid, _| other.key?(uid) })
+ end
+ end
+end
diff --git a/gems/rspec_flaky/lib/rspec_flaky/listener.rb b/gems/rspec_flaky/lib/rspec_flaky/listener.rb
new file mode 100644
index 00000000000..c2deb1c327a
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/listener.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'json'
+
+require_relative 'config'
+require_relative 'example'
+require_relative 'flaky_example'
+require_relative 'flaky_examples_collection'
+require_relative 'report'
+
+module RspecFlaky
+ class Listener
+ # - suite_flaky_examples: contains all the currently tracked flacky example
+ # for the whole RSpec suite
+ # - flaky_examples: contains the examples detected as flaky during the
+ # current RSpec run
+ attr_reader :suite_flaky_examples, :flaky_examples
+
+ def initialize(suite_flaky_examples_json = nil)
+ @flaky_examples = FlakyExamplesCollection.new
+ @suite_flaky_examples = init_suite_flaky_examples(suite_flaky_examples_json)
+ end
+
+ def example_passed(notification)
+ current_example = Example.new(notification.example)
+
+ return unless current_example.attempts > 1
+
+ flaky_example = suite_flaky_examples.fetch(current_example.uid) do
+ FlakyExample.new(current_example.to_h)
+ end
+ flaky_example.update!(current_example.to_h)
+
+ flaky_examples[current_example.uid] = flaky_example
+ end
+
+ def dump_summary(_)
+ Report.new(flaky_examples).write(Config.flaky_examples_report_path)
+
+ return unless new_flaky_examples.any?
+
+ rails_logger_warn("\nNew flaky examples detected:\n")
+ rails_logger_warn(JSON.pretty_generate(new_flaky_examples.to_h))
+
+ Report.new(new_flaky_examples).write(Config.new_flaky_examples_report_path)
+ end
+
+ private
+
+ def new_flaky_examples
+ @new_flaky_examples ||= flaky_examples - suite_flaky_examples
+ end
+
+ def init_suite_flaky_examples(suite_flaky_examples_json = nil)
+ if suite_flaky_examples_json
+ Report.load_json(suite_flaky_examples_json).flaky_examples
+ else
+ return {} unless File.exist?(Config.suite_flaky_examples_report_path)
+
+ Report.load(Config.suite_flaky_examples_report_path).flaky_examples
+ end
+ end
+
+ def rails_logger_warn(text)
+ target = defined?(Rails) ? Rails.logger : Kernel
+
+ target.warn(text)
+ end
+ end
+end
diff --git a/gems/rspec_flaky/lib/rspec_flaky/report.rb b/gems/rspec_flaky/lib/rspec_flaky/report.rb
new file mode 100644
index 00000000000..cc213d336ae
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/report.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'time'
+require 'fileutils'
+
+require_relative 'config'
+require_relative 'flaky_examples_collection'
+
+module RspecFlaky
+ # This class is responsible for loading/saving JSON reports, and pruning
+ # outdated examples.
+ class Report < SimpleDelegator
+ OUTDATED_DAYS_THRESHOLD = 7
+
+ attr_reader :flaky_examples
+
+ def self.load(file_path)
+ load_json(File.read(file_path))
+ end
+
+ def self.load_json(json)
+ new(FlakyExamplesCollection.new(JSON.parse(json)))
+ end
+
+ def initialize(flaky_examples)
+ unless flaky_examples.is_a?(FlakyExamplesCollection)
+ raise ArgumentError,
+ "`flaky_examples` must be a RspecFlaky::FlakyExamplesCollection, #{flaky_examples.class} given!"
+ end
+
+ @flaky_examples = flaky_examples
+ super(flaky_examples)
+ end
+
+ def write(file_path)
+ unless Config.generate_report?
+ Kernel.warn "! Generating reports is disabled. To enable it, please set the `FLAKY_RSPEC_GENERATE_REPORT=1` !"
+ return
+ end
+
+ report_path_dir = File.dirname(file_path)
+ FileUtils.mkdir_p(report_path_dir)
+
+ File.write(file_path, JSON.pretty_generate(flaky_examples.to_h))
+ end
+
+ def prune_outdated(days: OUTDATED_DAYS_THRESHOLD)
+ outdated_date_threshold = Time.now - (3600 * 24 * days)
+ recent_flaky_examples = flaky_examples.dup
+ .delete_if do |_uid, flaky_example|
+ last_flaky_at = flaky_example.to_h[:last_flaky_at]
+ last_flaky_at && last_flaky_at.to_i < outdated_date_threshold.to_i
+ end
+
+ self.class.new(FlakyExamplesCollection.new(recent_flaky_examples))
+ end
+ end
+end
diff --git a/gems/rspec_flaky/lib/rspec_flaky/version.rb b/gems/rspec_flaky/lib/rspec_flaky/version.rb
new file mode 100644
index 00000000000..ec507d734c8
--- /dev/null
+++ b/gems/rspec_flaky/lib/rspec_flaky/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module RspecFlaky
+ module Version
+ VERSION = "0.1.0"
+ end
+end
diff --git a/gems/rspec_flaky/rspec_flaky.gemspec b/gems/rspec_flaky/rspec_flaky.gemspec
new file mode 100644
index 00000000000..5c0a434218f
--- /dev/null
+++ b/gems/rspec_flaky/rspec_flaky.gemspec
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require_relative "lib/rspec_flaky/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "rspec_flaky"
+ spec.version = RspecFlaky::Version::VERSION
+ spec.authors = ["Engineering Productivity"]
+ spec.email = ["quality@gitlab.com"]
+
+ spec.summary = "GitLab's RSpec Flaky test detector"
+ spec.description =
+ "This gem provide an RSpec listener that allows to detect flaky examples. See " \
+ "https://docs.gitlab.com/ee/development/testing_guide/flaky_tests.html#automatic-retries-and-flaky-tests-detection."
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/rspec_flaky"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.0"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir["lib/**/*.rb"]
+ spec.require_paths = ["lib"]
+
+ spec.add_runtime_dependency "activesupport", ">= 6.1", "< 7.1"
+ spec.add_runtime_dependency "rspec", "~> 3.0"
+
+ spec.add_development_dependency "gitlab-styles", "~> 10.1.0"
+ spec.add_development_dependency "rspec-parameterized", "~> 1.0"
+ spec.add_development_dependency "rubocop", "~> 1.50"
+ spec.add_development_dependency "rubocop-rspec", "~> 2.22"
+end
diff --git a/gems/rspec_flaky/spec/rspec_flaky/config_spec.rb b/gems/rspec_flaky/spec/rspec_flaky/config_spec.rb
new file mode 100644
index 00000000000..827249efefa
--- /dev/null
+++ b/gems/rspec_flaky/spec/rspec_flaky/config_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'rspec_flaky/config'
+
+RSpec.describe RspecFlaky::Config, :aggregate_failures do
+ include StubENV
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('FLAKY_RSPEC_GENERATE_REPORT', nil)
+ stub_env('FLAKY_RSPEC_SUITE_REPORT_PATH', nil)
+ stub_env('FLAKY_RSPEC_REPORT_PATH', nil)
+ stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', nil)
+ end
+
+ describe '.generate_report?' do
+ context "when ENV['FLAKY_RSPEC_GENERATE_REPORT'] is not set" do
+ it 'returns false' do
+ expect(described_class).not_to be_generate_report
+ end
+ end
+
+ context "when ENV['FLAKY_RSPEC_GENERATE_REPORT'] is set" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:env_value, :result) do
+ '1' | true
+ 'true' | true
+ 'foo' | false
+ '0' | false
+ 'false' | false
+ end
+
+ with_them do
+ before do
+ stub_env('FLAKY_RSPEC_GENERATE_REPORT', env_value)
+ end
+
+ it 'returns false' do
+ expect(described_class.generate_report?).to be(result)
+ end
+ end
+ end
+ end
+
+ describe '.suite_flaky_examples_report_path' do
+ context "when ENV['FLAKY_RSPEC_SUITE_REPORT_PATH'] is not set" do
+ it 'returns the default path' do
+ expect(described_class.suite_flaky_examples_report_path).to eq('rspec/flaky/suite-report.json')
+ end
+ end
+
+ context "when ENV['FLAKY_RSPEC_SUITE_REPORT_PATH'] is set" do
+ before do
+ stub_env('FLAKY_RSPEC_SUITE_REPORT_PATH', 'foo/suite-report.json')
+ end
+
+ it 'returns the value of the env variable' do
+ expect(described_class.suite_flaky_examples_report_path).to eq('foo/suite-report.json')
+ end
+ end
+ end
+
+ describe '.flaky_examples_report_path' do
+ context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is not set" do
+ it 'returns the default path' do
+ expect(described_class.flaky_examples_report_path).to eq('rspec/flaky/report.json')
+ end
+ end
+
+ context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is set" do
+ before do
+ stub_env('FLAKY_RSPEC_REPORT_PATH', 'foo/report.json')
+ end
+
+ it 'returns the value of the env variable' do
+ expect(described_class.flaky_examples_report_path).to eq('foo/report.json')
+ end
+ end
+ end
+
+ describe '.new_flaky_examples_report_path' do
+ context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is not set" do
+ it 'returns the default path' do
+ expect(described_class.new_flaky_examples_report_path).to eq('rspec/flaky/new-report.json')
+ end
+ end
+
+ context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is set" do
+ before do
+ stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', 'foo/new-report.json')
+ end
+
+ it 'returns the value of the env variable' do
+ expect(described_class.new_flaky_examples_report_path).to eq('foo/new-report.json')
+ end
+ end
+ end
+end
diff --git a/gems/rspec_flaky/spec/rspec_flaky/example_spec.rb b/gems/rspec_flaky/spec/rspec_flaky/example_spec.rb
new file mode 100644
index 00000000000..64d65c0e170
--- /dev/null
+++ b/gems/rspec_flaky/spec/rspec_flaky/example_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'rspec_flaky/example'
+
+RSpec.describe RspecFlaky::Example do
+ let(:example_attrs) do
+ {
+ id: 'spec/foo/bar_spec.rb:2',
+ metadata: {
+ file_path: 'spec/foo/bar_spec.rb',
+ line_number: 2,
+ full_description: 'hello world',
+ feature_category: :feature_category
+ },
+ execution_result: double(status: 'passed', exception: 'BOOM!'),
+ attempts: 1
+ }
+ end
+
+ let(:rspec_example) { double(example_attrs) }
+
+ describe '#initialize' do
+ shared_examples 'a valid Example instance' do
+ it 'returns valid attributes' do
+ example = described_class.new(args)
+
+ expect(example.example_id).to eq(example_attrs[:id])
+ end
+ end
+
+ context 'when given an Rspec::Core::Example that responds to #example' do
+ let(:args) { double(example: rspec_example) }
+
+ it_behaves_like 'a valid Example instance'
+ end
+
+ context 'when given an Rspec::Core::Example that does not respond to #example' do
+ let(:args) { rspec_example }
+
+ it_behaves_like 'a valid Example instance'
+ end
+ end
+
+ subject { described_class.new(rspec_example) }
+
+ describe '#uid' do
+ it 'returns a hash of the full description' do
+ expect(subject.uid).to eq(Digest::MD5.hexdigest("#{subject.description}-#{subject.file}")) # rubocop:disable Fips/MD5
+ end
+ end
+
+ describe '#example_id' do
+ it 'returns the ID of the RSpec::Core::Example' do
+ expect(subject.example_id).to eq(rspec_example.id)
+ end
+ end
+
+ describe '#attempts' do
+ it 'returns the attempts of the RSpec::Core::Example' do
+ expect(subject.attempts).to eq(rspec_example.attempts)
+ end
+ end
+
+ describe '#file' do
+ it 'returns the metadata[:file_path] of the RSpec::Core::Example' do
+ expect(subject.file).to eq(rspec_example.metadata[:file_path])
+ end
+ end
+
+ describe '#line' do
+ it 'returns the metadata[:line_number] of the RSpec::Core::Example' do
+ expect(subject.line).to eq(rspec_example.metadata[:line_number])
+ end
+ end
+
+ describe '#description' do
+ it 'returns the metadata[:full_description] of the RSpec::Core::Example' do
+ expect(subject.description).to eq(rspec_example.metadata[:full_description])
+ end
+ end
+
+ describe '#status' do
+ it 'returns the execution_result.status of the RSpec::Core::Example' do
+ expect(subject.status).to eq(rspec_example.execution_result.status)
+ end
+ end
+
+ describe '#exception' do
+ it 'returns the execution_result.exception of the RSpec::Core::Example' do
+ expect(subject.exception).to eq(rspec_example.execution_result.exception)
+ end
+ end
+
+ describe '#feature_category' do
+ it 'returns the metadata[:feature_category] of the RSpec::Core::Example' do
+ expect(subject.feature_category).to eq(rspec_example.metadata[:feature_category])
+ end
+ end
+end
diff --git a/gems/rspec_flaky/spec/rspec_flaky/flaky_example_spec.rb b/gems/rspec_flaky/spec/rspec_flaky/flaky_example_spec.rb
new file mode 100644
index 00000000000..244ca275f14
--- /dev/null
+++ b/gems/rspec_flaky/spec/rspec_flaky/flaky_example_spec.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+require 'rspec_flaky/flaky_example'
+
+RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
+ include StubENV
+
+ let(:example_attrs) do
+ {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ last_attempts_count: 2,
+ feature_category: :feature_category
+ }
+ end
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('CI_JOB_URL', nil)
+ end
+
+ describe '#initialize', :freeze_time do
+ shared_examples 'a valid FlakyExample instance' do
+ let(:flaky_example) { described_class.new(args) }
+
+ it 'returns valid attributes' do
+ attrs = flaky_example.to_h
+
+ expect(attrs[:uid]).to eq(example_attrs[:uid])
+ expect(attrs[:file]).to eq(example_attrs[:file])
+ expect(attrs[:line]).to eq(example_attrs[:line])
+ expect(attrs[:description]).to eq(example_attrs[:description])
+ expect(attrs[:feature_category]).to eq(example_attrs[:feature_category])
+ expect(attrs[:first_flaky_at]).to eq(expected_first_flaky_at)
+ expect(attrs[:last_flaky_at]).to eq(expected_last_flaky_at)
+ expect(attrs[:last_attempts_count]).to eq(example_attrs[:last_attempts_count])
+ expect(attrs[:flaky_reports]).to eq(expected_flaky_reports)
+ end
+ end
+
+ context 'when given an Example.to_h' do
+ it_behaves_like 'a valid FlakyExample instance' do
+ let(:args) { example_attrs }
+ let(:expected_first_flaky_at) { Time.now }
+ let(:expected_last_flaky_at) { Time.now }
+ let(:expected_flaky_reports) { 0 }
+ end
+ end
+ end
+
+ describe '#update!' do
+ shared_examples 'an up-to-date FlakyExample instance' do
+ let(:flaky_example) { described_class.new(args) }
+
+ it 'sets the first_flaky_at if none exists' do
+ args[:first_flaky_at] = nil
+
+ freeze_time do
+ flaky_example.update!(example_attrs)
+
+ expect(flaky_example.to_h[:first_flaky_at]).to eq(Time.now)
+ end
+ end
+
+ it 'maintains the first_flaky_at if exists' do
+ flaky_example.update!(example_attrs)
+ expected_first_flaky_at = flaky_example.to_h[:first_flaky_at]
+
+ travel_to(Time.now + 42) do
+ flaky_example.update!(example_attrs)
+ expect(flaky_example.to_h[:first_flaky_at]).to eq(expected_first_flaky_at)
+ end
+ end
+
+ it 'updates the last_flaky_at' do
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ flaky_example.update!(example_attrs)
+
+ expect(flaky_example.to_h[:last_flaky_at]).to eq(the_future)
+ end
+ end
+
+ it 'updates the flaky_reports' do
+ expected_flaky_reports = flaky_example.to_h[:first_flaky_at] ? flaky_example.to_h[:flaky_reports] + 1 : 1
+
+ expect { flaky_example.update!(example_attrs) }.to change { flaky_example.to_h[:flaky_reports] }.by(1)
+ expect(flaky_example.to_h[:flaky_reports]).to eq(expected_flaky_reports)
+ end
+
+ it 'updates the last_attempts_count' do
+ example_attrs[:last_attempts_count] = 42
+ flaky_example.update!(example_attrs)
+
+ expect(flaky_example.to_h[:last_attempts_count]).to eq(42)
+ end
+
+ context 'when run on the CI' do
+ let(:job_url) { 'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/42' }
+
+ before do
+ stub_env('CI_JOB_URL', job_url)
+ end
+
+ it 'updates the last_flaky_job' do
+ flaky_example.update!(example_attrs)
+
+ expect(flaky_example.to_h[:last_flaky_job]).to eq(job_url)
+ end
+ end
+ end
+
+ context 'when given an Example hash' do
+ it_behaves_like 'an up-to-date FlakyExample instance' do
+ let(:args) { example_attrs }
+ end
+ end
+ end
+
+ describe '#to_h', :freeze_time do
+ shared_examples 'a valid FlakyExample hash' do
+ let(:additional_attrs) { {} }
+
+ it 'returns a valid hash' do
+ flaky_example = described_class.new(args)
+ final_hash = example_attrs.merge(additional_attrs)
+
+ expect(flaky_example.to_h).to eq(final_hash)
+ end
+ end
+
+ context 'when given an Example hash' do
+ let(:args) { example_attrs }
+
+ it_behaves_like 'a valid FlakyExample hash' do
+ let(:additional_attrs) do
+ { first_flaky_at: Time.now, last_flaky_at: Time.now, last_flaky_job: nil, flaky_reports: 0 }
+ end
+ end
+ end
+ end
+end
diff --git a/gems/rspec_flaky/spec/rspec_flaky/flaky_examples_collection_spec.rb b/gems/rspec_flaky/spec/rspec_flaky/flaky_examples_collection_spec.rb
new file mode 100644
index 00000000000..260ebc72192
--- /dev/null
+++ b/gems/rspec_flaky/spec/rspec_flaky/flaky_examples_collection_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'rspec_flaky/flaky_examples_collection'
+
+RSpec.describe RspecFlaky::FlakyExamplesCollection, :aggregate_failures, :freeze_time do
+ let(:collection_hash) do
+ {
+ a: { example_id: 'spec/foo/bar_spec.rb:2' },
+ b: { example_id: 'spec/foo/baz_spec.rb:3' }
+ }
+ end
+
+ let(:collection_report) do
+ {
+ a: {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ first_flaky_at: Time.now,
+ last_flaky_at: Time.now,
+ last_flaky_job: nil,
+ flaky_reports: 0,
+ feature_category: nil,
+ last_attempts_count: nil
+ },
+ b: {
+ example_id: 'spec/foo/baz_spec.rb:3',
+ first_flaky_at: Time.now,
+ last_flaky_at: Time.now,
+ last_flaky_job: nil,
+ flaky_reports: 0,
+ feature_category: nil,
+ last_attempts_count: nil
+ }
+ }
+ end
+
+ describe '#initialize' do
+ it 'accepts no argument' do
+ expect { described_class.new }.not_to raise_error
+ end
+
+ it 'accepts a hash' do
+ expect { described_class.new(collection_hash) }.not_to raise_error
+ end
+
+ it 'does not accept anything else' do
+ expect do
+ described_class.new([1, 2, 3])
+ end.to raise_error(ArgumentError, "`collection` must be a Hash, Array given!")
+ end
+ end
+
+ describe '#to_h' do
+ it 'calls #to_h on the values' do
+ collection = described_class.new(collection_hash)
+
+ expect(collection.to_h).to eq(collection_report)
+ end
+ end
+
+ describe '#-' do
+ it 'returns only examples that are not present in the given collection' do
+ collection1 = described_class.new(collection_hash)
+ collection2 = described_class.new(
+ a: { example_id: 'spec/foo/bar_spec.rb:2' },
+ c: { example_id: 'spec/bar/baz_spec.rb:4' })
+
+ expect((collection2 - collection1).to_h).to eq(
+ c: {
+ example_id: 'spec/bar/baz_spec.rb:4',
+ first_flaky_at: Time.now,
+ last_flaky_at: Time.now,
+ last_flaky_job: nil,
+ flaky_reports: 0,
+ feature_category: nil,
+ last_attempts_count: nil
+ })
+ end
+
+ it 'fails if the given collection does not respond to `#key?`' do
+ collection = described_class.new(collection_hash)
+
+ expect do
+ collection - [1, 2, 3]
+ end.to raise_error(ArgumentError, "`other` must respond to `#key?`, Array does not!")
+ end
+ end
+end
diff --git a/gems/rspec_flaky/spec/rspec_flaky/listener_spec.rb b/gems/rspec_flaky/spec/rspec_flaky/listener_spec.rb
new file mode 100644
index 00000000000..cbc5422a763
--- /dev/null
+++ b/gems/rspec_flaky/spec/rspec_flaky/listener_spec.rb
@@ -0,0 +1,229 @@
+# frozen_string_literal: true
+
+require 'rspec_flaky/listener'
+
+RSpec.describe RspecFlaky::Listener, :aggregate_failures do
+ include StubENV
+
+ let(:already_flaky_example_uid) { '6e869794f4cfd2badd93eb68719371d1' }
+ let(:suite_flaky_example_report) do
+ {
+ "#{already_flaky_example_uid}": {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: 4321,
+ last_attempts_count: 3,
+ flaky_reports: 1,
+ last_flaky_job: nil
+ }
+ }
+ end
+
+ let(:already_flaky_example_attrs) do
+ {
+ id: 'spec/foo/bar_spec.rb:2',
+ metadata: {
+ file_path: 'spec/foo/bar_spec.rb',
+ line_number: 2,
+ full_description: 'hello world'
+ },
+ execution_result: double(status: 'passed', exception: nil)
+ }
+ end
+
+ let(:already_flaky_example) do
+ RspecFlaky::FlakyExample.new(suite_flaky_example_report[already_flaky_example_uid])
+ end
+
+ let(:new_example_attrs) do
+ {
+ id: 'spec/foo/baz_spec.rb:3',
+ metadata: {
+ file_path: 'spec/foo/baz_spec.rb',
+ line_number: 3,
+ full_description: 'hello GitLab'
+ },
+ execution_result: double(status: 'passed', exception: nil)
+ }
+ end
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('CI_JOB_URL', nil)
+ stub_env('FLAKY_RSPEC_SUITE_REPORT_PATH', nil)
+ end
+
+ describe '#initialize' do
+ shared_examples 'a valid Listener instance' do
+ let(:expected_suite_flaky_examples) { {} }
+
+ it 'returns a valid Listener instance' do
+ listener = described_class.new
+
+ expect(listener.suite_flaky_examples.to_h).to eq(expected_suite_flaky_examples)
+ expect(listener.flaky_examples).to eq({})
+ end
+ end
+
+ context 'when no report file exists' do
+ it_behaves_like 'a valid Listener instance'
+ end
+
+ context 'when FLAKY_RSPEC_SUITE_REPORT_PATH is set' do
+ let(:report_file_path) { 'foo/report.json' }
+
+ before do
+ stub_env('FLAKY_RSPEC_SUITE_REPORT_PATH', report_file_path)
+ end
+
+ context 'when report file exists' do
+ before do
+ allow(File).to receive(:exist?).with(report_file_path).and_return(true)
+ end
+
+ it 'delegates the load to RspecFlaky::Report' do
+ report = RspecFlaky::Report
+ .new(RspecFlaky::FlakyExamplesCollection.new(suite_flaky_example_report))
+
+ expect(RspecFlaky::Report).to receive(:load).with(report_file_path).and_return(report)
+ expect(described_class.new.suite_flaky_examples.to_h).to eq(report.flaky_examples.to_h)
+ end
+ end
+
+ context 'when report file does not exist' do
+ before do
+ allow(File).to receive(:exist?).with(report_file_path).and_return(false)
+ end
+
+ it 'return an empty hash' do
+ expect(RspecFlaky::Report).not_to receive(:load)
+ expect(described_class.new.suite_flaky_examples.to_h).to eq({})
+ end
+ end
+ end
+ end
+
+ describe '#example_passed' do
+ let(:rspec_example) { double(new_example_attrs) }
+ let(:notification) { double(example: rspec_example) }
+ let(:listener) { described_class.new(suite_flaky_example_report.to_json) }
+
+ shared_examples 'a non-flaky example' do
+ it 'does not change the flaky examples hash' do
+ expect { listener.example_passed(notification) }
+ .not_to change { listener.flaky_examples }
+ end
+ end
+
+ shared_examples 'an existing flaky example' do
+ let(:expected_flaky_example) do
+ {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_attempts_count: 2,
+ flaky_reports: 2,
+ feature_category: nil,
+ last_flaky_job: nil
+ }
+ end
+
+ it 'changes the flaky examples hash' do
+ new_example = RspecFlaky::Example.new(rspec_example)
+
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ expect { listener.example_passed(notification) }
+ .to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(last_flaky_at: the_future))
+ end
+ end
+ end
+
+ shared_examples 'a new flaky example' do
+ let(:expected_flaky_example) do
+ {
+ example_id: 'spec/foo/baz_spec.rb:3',
+ file: 'spec/foo/baz_spec.rb',
+ line: 3,
+ description: 'hello GitLab',
+ last_attempts_count: 2,
+ flaky_reports: 1,
+ feature_category: nil,
+ last_flaky_job: nil
+ }
+ end
+
+ it 'changes the all flaky examples hash' do
+ new_example = RspecFlaky::Example.new(rspec_example)
+
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ expect { listener.example_passed(notification) }
+ .to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(first_flaky_at: the_future, last_flaky_at: the_future))
+ end
+ end
+ end
+
+ describe 'when the RSpec example does not respond to attempts' do
+ it_behaves_like 'a non-flaky example'
+ end
+
+ describe 'when the RSpec example has 1 attempt' do
+ let(:rspec_example) { double(new_example_attrs.merge(attempts: 1)) }
+
+ it_behaves_like 'a non-flaky example'
+ end
+
+ describe 'when the RSpec example has 2 attempts' do
+ let(:rspec_example) { double(new_example_attrs.merge(attempts: 2)) }
+
+ it_behaves_like 'a new flaky example'
+
+ context 'with an existing flaky example' do
+ let(:rspec_example) { double(already_flaky_example_attrs.merge(attempts: 2)) }
+
+ it_behaves_like 'an existing flaky example'
+ end
+ end
+ end
+
+ describe '#dump_summary' do
+ let(:listener) { described_class.new(suite_flaky_example_report.to_json) }
+ let(:new_flaky_rspec_example) { double(new_example_attrs.merge(attempts: 2)) }
+ let(:already_flaky_rspec_example) { double(already_flaky_example_attrs.merge(attempts: 2)) }
+ let(:notification_new_flaky_rspec_example) { double(example: new_flaky_rspec_example) }
+ let(:notification_already_flaky_rspec_example) { double(example: already_flaky_rspec_example) }
+
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
+ context 'when a report file path is set by FLAKY_RSPEC_REPORT_PATH' do
+ it 'delegates the writes to RspecFlaky::Report' do
+ listener.example_passed(notification_new_flaky_rspec_example)
+ listener.example_passed(notification_already_flaky_rspec_example)
+
+ report1 = double
+ report2 = double
+
+ expect(RspecFlaky::Report).to receive(:new).with(listener.flaky_examples).and_return(report1)
+ expect(report1).to receive(:write).with(RspecFlaky::Config.flaky_examples_report_path)
+
+ expect(RspecFlaky::Report)
+ .to receive(:new).with(listener.__send__(:new_flaky_examples)).and_return(report2)
+ expect(report2).to receive(:write).with(RspecFlaky::Config.new_flaky_examples_report_path)
+
+ listener.dump_summary(nil)
+ end
+ end
+ end
+end
diff --git a/gems/rspec_flaky/spec/rspec_flaky/report_spec.rb b/gems/rspec_flaky/spec/rspec_flaky/report_spec.rb
new file mode 100644
index 00000000000..e1e9bd6a7b1
--- /dev/null
+++ b/gems/rspec_flaky/spec/rspec_flaky/report_spec.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+
+require 'rspec_flaky/report'
+
+RSpec.describe RspecFlaky::Report, :aggregate_failures, :freeze_time do
+ let(:thirty_one_days) { 3600 * 24 * 31 }
+ let(:collection_hash) do
+ {
+ a: { example_id: 'spec/foo/bar_spec.rb:2' },
+ b: { example_id: 'spec/foo/baz_spec.rb:3', first_flaky_at: (Time.now - thirty_one_days).to_s,
+ last_flaky_at: (Time.now - thirty_one_days).to_s }
+ }
+ end
+
+ let(:suite_flaky_example_report) do
+ {
+ '6e869794f4cfd2badd93eb68719371d1': {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: 4321,
+ last_attempts_count: 3,
+ flaky_reports: 1,
+ feature_category: 'feature_category',
+ last_flaky_job: nil
+ }
+ }
+ end
+
+ let(:flaky_examples) { RspecFlaky::FlakyExamplesCollection.new(collection_hash) }
+ let(:report) { described_class.new(flaky_examples) }
+
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
+ describe '.load' do
+ let!(:report_file) do
+ Tempfile.new(%w[rspec_flaky_report .json]).tap do |f|
+ f.write(JSON.pretty_generate(suite_flaky_example_report))
+ f.rewind
+ end
+ end
+
+ after do
+ report_file.close
+ report_file.unlink
+ end
+
+ it 'loads the report file' do
+ expect(described_class.load(report_file.path).flaky_examples.to_h).to eq(suite_flaky_example_report)
+ end
+ end
+
+ describe '.load_json' do
+ let(:report_json) do
+ JSON.pretty_generate(suite_flaky_example_report)
+ end
+
+ it 'loads the report file' do
+ expect(described_class.load_json(report_json).flaky_examples.to_h).to eq(suite_flaky_example_report)
+ end
+ end
+
+ describe '#initialize' do
+ it 'accepts a RspecFlaky::FlakyExamplesCollection' do
+ expect { report }.not_to raise_error
+ end
+
+ it 'does not accept anything else' do
+ expect do
+ described_class.new([1, 2,
+ 3])
+ end.to raise_error(ArgumentError,
+ "`flaky_examples` must be a RspecFlaky::FlakyExamplesCollection, Array given!")
+ end
+ end
+
+ it 'delegates to #flaky_examples using SimpleDelegator' do
+ expect(report.__getobj__).to eq(flaky_examples)
+ end
+
+ describe '#write' do
+ let(:report_file_path) { File.join('tmp', 'rspec_flaky_report.json') }
+
+ before do
+ FileUtils.rm_f(report_file_path)
+ end
+
+ after do
+ FileUtils.rm_f(report_file_path)
+ end
+
+ context 'when RspecFlaky::Config.generate_report? is false' do
+ before do
+ allow(RspecFlaky::Config).to receive(:generate_report?).and_return(false)
+ end
+
+ it 'does not write any report file' do
+ report.write(report_file_path)
+
+ expect(File.exist?(report_file_path)).to be(false)
+ end
+ end
+
+ context 'when RspecFlaky::Config.generate_report? is true' do
+ before do
+ allow(RspecFlaky::Config).to receive(:generate_report?).and_return(true)
+ end
+
+ it 'delegates the writes to RspecFlaky::Report' do
+ report.write(report_file_path)
+
+ expect(File.exist?(report_file_path)).to be(true)
+ expect(File.read(report_file_path))
+ .to eq(JSON.pretty_generate(report.flaky_examples.to_h))
+ end
+ end
+ end
+
+ describe '#prune_outdated' do
+ it 'returns a new collection without the examples older than 30 days by default' do
+ new_report = flaky_examples.to_h.dup.tap { |r| r.delete(:b) }
+ new_flaky_examples = report.prune_outdated
+
+ expect(new_flaky_examples).to be_a(described_class)
+ expect(new_flaky_examples.to_h).to eq(new_report)
+ expect(flaky_examples).to have_key(:b)
+ end
+
+ it 'accepts a given number of days' do
+ new_flaky_examples = report.prune_outdated(days: 32)
+
+ expect(new_flaky_examples.to_h).to eq(report.to_h)
+ end
+ end
+end
diff --git a/gems/rspec_flaky/spec/spec_helper.rb b/gems/rspec_flaky/spec/spec_helper.rb
new file mode 100644
index 00000000000..72d48ee6e63
--- /dev/null
+++ b/gems/rspec_flaky/spec/spec_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require "rspec-parameterized"
+require "gitlab/rspec/all"
+require "rspec_flaky"
+
+RSpec.configure do |config|
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end