diff --git a/doc/topics/awesome_co.md b/doc/topics/awesome_co.md index e89dd6772780c31055abe6b8dac5542b1295329d..f6cad7eb804d76b05d3b17c7b02826807f7cb00e 100644 --- a/doc/topics/awesome_co.md +++ b/doc/topics/awesome_co.md @@ -141,6 +141,25 @@ create(:project, name: 'No longer throws error', owner: @owner, namespace: creat create(:epic, group: create(:group), author: @owner) ``` +#### `parsing id "my id" as "my_id"` + +See [specifying variables](#specify-a-variable) + +#### `id is invalid` + +Given that non-Ruby parsers parse IDs as Ruby Objects, the [naming conventions](https://docs.ruby-lang.org/en/2.0.0/syntax/methods_rdoc.html#label-Method+Names) of Ruby must be followed when specifying an ID. + +Examples of invalid IDs: + +- IDs that start with a number +- IDs that have special characters (-, !, $, @, `, =, <, >, ;, :) + +#### ActiveRecord::AssociationTypeMismatch: Model expected, got ... which is an instance of String + +This is currently a limitation for the seeder. + +See the issue for [allowing parsing of raw Ruby objects](https://gitlab.com/gitlab-org/gitlab/-/issues/403079). + ## YAML Factories ### Generator to generate _n_ amount of records @@ -210,3 +229,50 @@ epics: start_date: <%= 1.day.ago %> due_date: <%= 1.month.from_now %> ``` + +## Variables + +Each created factory can be assigned an identifier to be used in future seeding. + +You can specify an ID for any created factory that you may use later in the seed file. + +### Specify a variable + +You may pass an `_id` attribute on any factory to refer back to it later in non-Ruby parsers. + +Variables are under the factory definitions that they reside in. + +```yaml +--- +group_labels: + - _id: my_label #=> group_labels.my_label + +projects: + - _id: my_project #=> projects.my_project +``` + +Variables: + +NOTE: +It is not advised, but you may specify variables with spaces. These variables may be referred back to with underscores. + +### Referencing a variable + +Given a YAML seed file: + +```yaml +--- +group_labels: + - _id: my_group_label #=> group_labels.my_group_label + name: My Group Label + color: "#FF0000" + - _id: my_other_group_label #=> group_labels.my_other_group_label + color: <%= group_labels.my_group_label.color %> + +projects: + - _id: my_project #=> projects.my_project + name: My Project +``` + +When referring to a variable, the variable refers to the _already seeded_ models. In other words, the model's `id` attribute will +be populated. diff --git a/ee/db/seeds/awesome_co/awesome_co.rb b/ee/db/seeds/awesome_co/awesome_co.rb index 6f4f9dd738511b15fdd4ce380c44da6da8834fbb..26768ada0adf128f017265694d1b70c23ba5fe9b 100644 --- a/ee/db/seeds/awesome_co/awesome_co.rb +++ b/ee/db/seeds/awesome_co/awesome_co.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'ostruct' + module AwesomeCo class << self # Seed test data using AwesomeCo generator @@ -45,23 +47,61 @@ class FactoryDefinition # @param [Array<String>] traits FactoryBot traits that should be applied # @param [Hash<String, String>] attributes Attributes to apply def initialize(factory, *traits, **attributes) + # "my id" #=> "my_id" + # "my_id" #=> "my_id" @id = attributes.delete('_id') + if @id + raise "id `#{@id}` is invalid" if @id.match?(/[\x21-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F]/) # special chars + raise "id `#{@id}` is invalid. id cannot start with a number" if @id.match?(/^[0-9]/) + + if @id.include?(' ') + new_id = @id.tr(' ', '_') + warn %(parsing id "#{@id}" as "#{new_id}") + + @id = new_id + end + end + @factory = factory @traits = traits @attributes = attributes end + # Build and save the Factory + # @param [Binding] parser_binding + # @return [ApplicationRecord] the built and saved Factory def fabricate(parser_binding) - Gitlab::AppLogger.info("Creating `#{@factory}` with traits `#{@traits}` and attributes `#{@attributes}`") - - build(parser_binding).save + factory = if @id + parser_binding.local_variable_get(@factory.pluralize)[@id] + else + build(parser_binding) + end + + factory.tap do |f| + f.save + Gitlab::AppLogger.info( + "Created `#{@factory}` with traits `#{@traits}` and attributes `#{@attributes}` [ID: #{f.id}]" + ) + + parser_binding.local_variable_get(@factory.pluralize)[@id] = f if @id + end end + # Build the Factory + # @param [Binding] parser_binding + # @return [ApplicationRecord] the built factory def build(parser_binding) @attributes.transform_values! { |v| v.is_a?(String) ? ERB.new(v).result(parser_binding) : v } - FactoryBot.build(@factory, *@traits, **@attributes) + FactoryBot.build(@factory, *@traits, **@attributes).tap do |factory| + next unless @id + next unless parser_binding.local_variable_defined?(@factory.pluralize) + + raise "id `#{@id}` must be unique" if parser_binding.local_variable_get(@factory.pluralize)[@id] + + parser_binding.local_variable_get(@factory.pluralize)[@id] = factory if @id + end end end end @@ -89,13 +129,16 @@ def initialize(seed_file, owner) end def parse + raise 'Seed file must specify a name' unless @name + # create the seeded group with a path that is hyphenated and random @group = FactoryBot.create(:group, name: @name, path: "#{@name.parameterize}-#{@owner.username}-#{SecureRandom.hex(3)}") @group.add_owner(@owner) @definitions.each do |factory, definitions| - @parser_binding.local_variable_set(factory, definitions) + # Using OpenStruct for dot-notation and saves a custom class impl. Ruby's discouragement does not apply + @parser_binding.local_variable_set(factory, OpenStruct.new) unless @parser_binding.local_variable_defined?(factory) # rubocop:disable Style/OpenStructUse @factories << FactoryDefinitions.new(factory, group, definitions) end @@ -145,7 +188,7 @@ def parse begin @definitions = YAML.safe_load_file(@seed_file, aliases: true) rescue Psych::SyntaxError => e - # put the yaml seed file on the top of the backtrace to help with tracability + # put the yaml seed file on the top of the backtrace to help with traceability e.backtrace.unshift("#{@seed_file.path}:#{e.line}:#{e.column}") raise e, "Seed file is malformed. #{e.message}" end diff --git a/ee/db/seeds/awesome_co/awesome_co.yml.erb b/ee/db/seeds/awesome_co/awesome_co.yml.erb index 2fa71a662757e75592766ce70eb45217becfb79f..d1402c7b36d42362535b2a7b36d4237e8101a7fe 100644 --- a/ee/db/seeds/awesome_co/awesome_co.yml.erb +++ b/ee/db/seeds/awesome_co/awesome_co.yml.erb @@ -14,8 +14,7 @@ name: AwesomeCo group_labels: # Priorities - - _id: priority_1 - name: priority::1 + - name: priority::1 group_id: <%= @group.id %> color: "#FF0000" - name: priority::2 @@ -29,24 +28,25 @@ group_labels: color: "#BB0000" # Squads - - name: squad::a + - _id: squad_a + name: squad::a group_id: <%= @group.id %> color: "#CCCCCC" - name: squad::b group_id: <%= @group.id %> - color: "#CCCCCC" + color: <%= group_labels.squad_a.color %> - name: squad::c group_id: <%= @group.id %> - color: "#CCCCCC" + color: <%= group_labels.squad_a.color %> - name: squad::d group_id: <%= @group.id %> - color: "#CCCCCC" + color: <%= group_labels.squad_a.color %> - name: squad::e group_id: <%= @group.id %> - color: "#CCCCCC" + color: <%= group_labels.squad_a.color %> - name: squad::f group_id: <%= @group.id %> - color: "#CCCCCC" + color: <%= group_labels.squad_a.color %> # Types - name: type::0-idea diff --git a/ee/spec/db/seeds/awesome_co/awesome_co_spec.rb b/ee/spec/db/seeds/awesome_co/awesome_co_spec.rb index 69b8f9619a91c1b71ac4983f0b57ee6ad885e546..c27ad6310c8dd59cc3e1adcd075ce921eef095c7 100644 --- a/ee/spec/db/seeds/awesome_co/awesome_co_spec.rb +++ b/ee/spec/db/seeds/awesome_co/awesome_co_spec.rb @@ -256,6 +256,38 @@ def initialize(_seed_file, owner) end end end + + context 'when an id already exists' do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - _id: my_label + title: My Label + - _id: my_label + title: My other label + YAML + end + + it 'raises a validation error' do + expect { parser.parse }.to raise_error(/id `my_label` must be unique/) + end + end + end + + describe '#parse' do + context 'when name is not specified' do + let(:seed_file_content) do + <<~YAML + group_labels: + - title: My Label + YAML + end + + it 'raises an error when name is not specified' do + expect { parser.parse }.to raise_error(/Seed file must specify a name/) + end + end end context 'when parsed' do @@ -302,6 +334,107 @@ def initialize(_seed_file, owner) parser.parse end end + + describe '@parser_binding' do + let(:group_labels) { parser.instance_variable_get(:@parser_binding).local_variable_get('group_labels') } + + context 'when a definition has an id' do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - _id: my_label + title: My Label + YAML + end + + context 'when the id has spaces' do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - _id: id with spaces + title: With Spaces + YAML + end + + it 'binds to an underscored variable', :aggregate_failures do + parser.parse + + expect(group_labels).to respond_to(:id_with_spaces) + expect(group_labels.id_with_spaces.title).to eq('With Spaces') + end + + it 'renders a warning' do + expect { parser.parse }.to output(%(parsing id "id with spaces" as "id_with_spaces"\n)).to_stderr + end + end + + it 'binds the object', :aggregate_failures do + parser.parse + + expect(group_labels).to be_a(OpenStruct) # rubocop:disable Style/OpenStructUse + expect(group_labels.my_label).to be_a(GroupLabel) + expect(group_labels.my_label.title).to eq('My Label') + end + + context 'when id is malformed' do + shared_examples 'invalid id' do |message| + it 'raises an error' do + expect { parser.parse }.to raise_error(message) + end + end + + context 'when id contains invalid characters' do + it_behaves_like 'invalid id', /id `--invalid-id` is invalid/ do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - _id: --invalid-id + YAML + end + end + + it_behaves_like 'invalid id', /id `invalid!id` is invalid/ do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - _id: invalid!id + YAML + end + end + + it_behaves_like 'invalid id', /id `1_label` is invalid. id cannot start with a number/ do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - _id: 1_label + YAML + end + end + end + end + end + + context 'when a definition does not have an id' do + let(:seed_file_content) do + <<~YAML + name: Test + group_labels: + - title: Test + YAML + end + + it 'does not bind the object' do + parser.parse + + expect(group_labels.to_h).to be_empty + end + end + end end end end diff --git a/lib/tasks/gitlab/feature_categories.rake b/lib/tasks/gitlab/feature_categories.rake index cecfaf3cb363eceb541134ee761fbd84344f6637..db49601215879ae3de2b2e0b59037fb3c4d68fdf 100644 --- a/lib/tasks/gitlab/feature_categories.rake +++ b/lib/tasks/gitlab/feature_categories.rake @@ -15,7 +15,7 @@ namespace :gitlab do hash[feature_category] << { klass: controller.to_s, action: action, - source_location: source_location(controller, action) + source_location: src_location(controller, action) } end @@ -28,7 +28,7 @@ namespace :gitlab do hash[feature_category] << { klass: klass.to_s, action: path, - source_location: source_location(klass) + source_location: src_location(klass) } end @@ -40,7 +40,7 @@ namespace :gitlab do hash[feature_category] ||= [] hash[feature_category] << { klass: worker.klass.name, - source_location: source_location(worker.klass.name) + source_location: src_location(worker.klass.name) } end @@ -60,7 +60,14 @@ namespace :gitlab do 'database_tables' => database_tables) end - def source_location(klass, method = nil) + private + + # Source location of the trace + # @param [Class] klass + # @param [Method,UnboundMethod] method + # @note This method was named `source_location` but this name shadowed Binding#source_location + # @note This method was made private as it is not being used elsewhere + def src_location(klass, method = nil) file, line = if method && klass.method_defined?(method) klass.instance_method(method).source_location