diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6530dd3..5975c7f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2025-03-02 07:09:13 UTC using RuboCop version 1.73.0. +# on 2025-03-03 08:09:12 UTC using RuboCop version 1.73.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 1 +# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. # URISchemes: http, https @@ -14,17 +14,27 @@ Layout/LineLength: Exclude: - 'spec/integrations/import/dynamic-columns/import_by_static_columns_spec.rb' -# Offense count: 1 +# Offense count: 2 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 20 + Max: 44 -# Offense count: 2 +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 8 + +# Offense count: 3 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 17 + Max: 35 -# Offense count: 3 +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 10 + +# Offense count: 5 # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. # SupportedStyles: snake_case, normalcase, non_integer # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 @@ -56,12 +66,12 @@ RSpec/DescribeClass: - 'spec/integrations/import/dynamic-columns/import_spec.rb' - 'spec/integrations/import/dynamic-columns/validate_dynamic_header_values_spec.rb' -# Offense count: 17 +# Offense count: 18 # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 31 -# Offense count: 14 +# Offense count: 15 RSpec/MultipleExpectations: Max: 14 @@ -70,11 +80,6 @@ RSpec/MultipleExpectations: RSpec/NestedGroups: Max: 6 -# Offense count: 1 -RSpec/RemoveConst: - Exclude: - - 'spec/integrations/import/dynamic-columns/import_by_static_columns_spec.rb' - # Offense count: 2 RSpec/RepeatedExample: Exclude: diff --git a/spec/integrations/import/dynamic-columns/import_by_static_columns_spec.rb b/spec/integrations/import/dynamic-columns/import_by_static_columns_spec.rb index 35cecb9..2af8189 100644 --- a/spec/integrations/import/dynamic-columns/import_by_static_columns_spec.rb +++ b/spec/integrations/import/dynamic-columns/import_by_static_columns_spec.rb @@ -1,5 +1,89 @@ # frozen_string_literal: true +module Csvbuilder + module MetaDynamicColumns + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + # Ensure the DSL definitions are stored on the class and inherited + def dynamic_columns_definitions + @dynamic_columns_definitions ||= if superclass.respond_to?(:dynamic_columns_definitions) + superclass.dynamic_columns_definitions.dup + else + {} + end + end + + # DSL method to define a dynamic column + def dynamic_column(column_type, **opts) + dynamic_columns_definitions.merge!(column_type => { allow_blank: false, required: true, header_method: :name }.reverse_merge(opts)) + end + + def with_dynamic_columns(collection_name:, collection:) + # Retrieve DSL options for the given collection name + # i.e dynamic_column :tag, inclusion: %[operational strategic] + # dynamic_column :category, inclusion: ->(group) { group.categories.pluck(:name) }, + dsl_opts = dynamic_columns_definitions[collection_name] + unless dsl_opts + raise NotImplementedError, "No dynamic column definition found for #{collection_name}. Please define one using dynamic_column." + end + + # Create a new subclass of the current class + new_class = Class.new(self) do + instance_variable_set(:"@#{collection_name}_columns", {}) + + # Define a method to access the collection + collection.each.with_index do |entry, index| + column_name = :"#{collection_name}_#{index}" + + # Evaluate header value using a proc or symbol. + header_value = if dsl_opts[:header_method].respond_to?(:call) + dsl_opts[:header_method].call(entry) + else + entry.send(dsl_opts[:header_method]) + end + + required_value = dsl_opts.fetch(:required, false) + # Define the dynamic column as static column + column(column_name, header: header_value, required: required_value) + + inclusion_value = if dsl_opts[:inclusion].respond_to?(:call) + dsl_opts[:inclusion].call(entry) + else + dsl_opts[:inclusion] + end + + # We add the inclusion validation to the column, which can depends upon the record. + validates(column_name, inclusion: inclusion_value, allow_blank: dsl_opts[:allow_blank]) + instance_variable_get(:"@#{collection_name}_columns")[column_name] = columns[column_name] + end + + # Dynamically define a class-level reader for the dynamic columns. + singleton_class.send(:attr_reader, "#{collection_name}_columns") + + # And define an instance-level accessor. + define_method(:"#{collection_name}_columns") do + self.class.send(:"#{collection_name}_columns") + end + end + + # Copy over any existing dynamic columns from the parent to the new subclass. + # Otherwise, the new subclass will not have access to the previous defined dynamic columns. + instance_variables.each do |var| + if var.to_s.end_with?("_columns") && var != :"@#{collection_name}_columns" + new_class.instance_variable_set(var, instance_variable_get(var)) + new_class.singleton_class.send(:attr_reader, var.to_s.delete_prefix("@")) + end + end + + new_class # Masquerade the extends class and make it chainable + end + end + end +end + RSpec.describe "Import With Metaprogramming Instead Of Dynamic Columns" do let(:row_model) do Class.new do @@ -37,40 +121,18 @@ def skip? super || user.nil? end + include Csvbuilder::MetaDynamicColumns + + # Define the DSL for dynamic skill columns. + # The :skill dynamic column will extract its header using the `name` method (or proc) on each entry, + # and use the given options to set up validations. + dynamic_column :skill, inclusion: %w[0 1] + dynamic_column :tag, inclusion: ->(area) { area.tags.pluck(:name) } + class << self def name "DynamicColumnsImportModel" end - - def with_skills(skills) - new_class = Class.new(self) do - @skill_columns = {} - - skills.each.with_index do |skill, index| - column_name = :"skill_#{index}" - define_skill_column(skill, name: column_name) - @skill_columns[column_name] = columns[column_name] - end - - class << self - attr_reader :skill_columns - end - - def skill_columns - self.class.skill_columns - end - end - - Object.send(:remove_const, "DynamicColumnsImportModel") if Object.const_defined?(:DynamicColumnsImportModel) - Object.const_set(:DynamicColumnsImportModel, new_class) - - new_class - end - - def define_skill_column(skill, name:) - column(name, header: skill.name, required: false) - validates(name, inclusion: %w[0 1], allow_blank: true) - end end end end @@ -85,16 +147,44 @@ def define_skill_column(skill, name:) %w[Ruby Python Javascript].each do |skill_name| Skill.create(name: skill_name) end + + { + areas: [ + { + name: "Area 1", + tags: [ + { name: "Tag 1" }, + { name: "Tag 2" } + ] + }, { + name: "Area 2", + tags: [ + { name: "Tag 3" }, + { name: "Tag 4" } + ] + } + ] + }[:areas].each do |area| + area_instance = Area.create!(name: area[:name]) + + area[:tags].each do |tag| + Tag.create!(name: tag[:name], area: area_instance) + end + end end context "with dynamic columns" do - let(:importer_with_dynamic_columns) { import_model.with_skills(Skill.all) } + let(:importer_with_dynamic_columns) do + import_model + .with_dynamic_columns(collection_name: :skill, collection: Skill.all) + .with_dynamic_columns(collection_name: :tag, collection: Area.all) + end describe "import" do let(:csv_source) do [ - %w[Name Surname Ruby Python Javascript], - %w[John Doe 1 0 1] + ["Name", "Surname", "Ruby", "Python", "Javascript", "Area 1", "Area 2"], + ["John", "Doe", "1", "0", "1", "Tag 1", "Tag 3"] ] end @@ -104,11 +194,11 @@ def define_skill_column(skill, name:) end it "adds skills to column names" do - expect(importer_with_dynamic_columns.column_names).to match_array(%i[first_name last_name skill_0 skill_1 skill_2]) + expect(importer_with_dynamic_columns.column_names).to match_array(%i[first_name last_name skill_0 skill_1 skill_2 tag_0 tag_1]) end it "adds skills to headers" do - expect(importer_with_dynamic_columns.headers).to match_array(%w[Name Surname Ruby Python Javascript]) + expect(importer_with_dynamic_columns.headers).to contain_exactly("Name", "Surname", "Ruby", "Python", "Javascript", "Area 1", "Area 2") end it "adds skills to users" do @@ -126,13 +216,31 @@ def define_skill_column(skill, name:) expect(row_model.user.skills.map(&:name)).to match_array(%w[Ruby Javascript]) end end + + it "adds tags to users" do + Csvbuilder::Import::File.new(file.path, importer_with_dynamic_columns, options).each do |row_model| + row_model.tag_columns.each do |column_name, tag_data| + row_cell_value = row_model.attribute_objects[column_name].value # Get the value of the cell + + area = Area.find_by(name: tag_data[:header]) + + row_model.user.taggings.create(tag: area.tags.find_by(name: row_cell_value)) if area + end + + expect(row_model.user.taggings).to be_truthy + expect(row_model.user.taggings.count).to eq(2) + expect( + row_model.user.taggings.map { |t| t.tag.name } + ).to eq(["Tag 1", "Tag 3"]) + end + end end context "with invalid data" do let(:csv_source) do [ - %w[Name Surname Ruby Python Javascript], - %w[John Doe 1 0 2] + ["Name", "Surname", "Ruby", "Python", "Javascript", "Area 1", "Area 2"], + ["John", "Doe", "1", "0", "2", "Tag 1", "Tag 3"] ] end @@ -155,8 +263,8 @@ def define_skill_column(skill, name:) context "with invalid headers" do let(:csv_source) do [ - ["Name", "Surname", "Ruby", "Python", "Visual Basic"], - %w[John Doe 1 0 2] + ["Name", "Surname", "Ruby", "Python", "Visual Basic", "Area 1", "Area 2"], + ["John", "Doe", "1", "0", "2", "Tag 1", "Tag 3"] ] end @@ -167,7 +275,7 @@ def define_skill_column(skill, name:) expect(importer.errors.full_messages).to eq( [ - "Headers mismatch. Given headers (Name, Surname, Ruby, Python, Visual Basic). Expected headers (Name, Surname, Ruby, Python, Javascript). Unrecognized headers (Visual Basic)." + "Headers mismatch. Given headers (Name, Surname, Ruby, Python, Visual Basic, Area 1, Area 2). Expected headers (Name, Surname, Ruby, Python, Javascript, Area 1, Area 2). Unrecognized headers (Visual Basic)." ] ) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bb48e9d..e79ba35 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,11 +7,24 @@ # require "logger" require "tempfile" -ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") -# ActiveRecord::Base.logger = Logger.new($stdout) -# ActiveRecord::Migration.verbose = true +# Load support files +Dir["./spec/support/**/*.rb"].each do |file| + next if /databases|models|data/.match?(file) # "support/databases/#{database}/connection" load models and data -Dir["#{Dir.pwd}/spec/support/**/*.rb"].each { |f| require f } + puts("Loading #{file}") + + require file +end + +# Load database support files +ENV["DATABASE"] ||= "sqlite3" +database = ENV.fetch("DATABASE", "sqlite3") + +puts("Loading support/databases/#{database}/connection") + +require "support/databases/#{database}/connection" + +require "support/database_cleaner" RSpec.configure do |config| config.filter_run focus: true @@ -29,32 +42,3 @@ config.include CsvString end - -ActiveRecord::Schema.define do - create_table :users, force: true do |t| - t.string :first_name - t.string :last_name - t.string :full_name - end - create_table :skills_users, force: true do |t| - t.references :skill - t.references :user - end - create_table :skills, force: true do |t| - t.string :name - end -end - -class User < ActiveRecord::Base - self.table_name = :users - - validates :full_name, presence: true - - has_and_belongs_to_many :skills, join_table: :skills_users -end - -class Skill < ActiveRecord::Base - self.table_name = :skills - - validates :name, presence: true -end diff --git a/spec/support/adapters/active_record.rb b/spec/support/adapters/active_record.rb new file mode 100644 index 0000000..2649b0e --- /dev/null +++ b/spec/support/adapters/active_record.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "active_record" + +if ENV["DEBUG"] + require "logger" + ActiveRecord::Base.logger = Logger.new($stdout) +end diff --git a/spec/support/data.rb b/spec/support/data.rb new file mode 100644 index 0000000..2ec1b40 --- /dev/null +++ b/spec/support/data.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +def load_data! + { + areas: [ + { + name: "Area 1", + tags: [ + { name: "Tag 1" }, + { name: "Tag 2" } + ] + }, { + name: "Area 2", + tags: [ + { name: "Tag 3" }, + { name: "Tag 4" } + ] + } + ] + }[:areas].each do |area| + area_instance = Area.create!(name: area[:name]) + + area[:tags].each do |tag| + Tag.create!(name: tag[:name], area: area_instance) + end + end + + %w[Ruby Python Javascript].each do |skill_name| + Skill.create(name: skill_name) + end + + puts "Data loaded" +end + +load_data! diff --git a/spec/support/databases/sqlite3/connection.rb b/spec/support/databases/sqlite3/connection.rb new file mode 100644 index 0000000..a042aac --- /dev/null +++ b/spec/support/databases/sqlite3/connection.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + +RSpec.configure do |config| + config.before(:suite) do + # Load the schema + puts "Loading schema" + load File.expand_path("../sqlite3/schema.rb", __dir__) + + # Load the models + puts "Loading models" + load File.expand_path("../../models.rb", __dir__) + + # Load the data + puts "Loading data" + load File.expand_path("../../data.rb", __dir__) + end +end diff --git a/spec/support/databases/sqlite3/schema.rb b/spec/support/databases/sqlite3/schema.rb new file mode 100644 index 0000000..c788092 --- /dev/null +++ b/spec/support/databases/sqlite3/schema.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +ActiveRecord::Schema.define do + self.verbose = ENV.fetch("DEBUG", nil) + + create_table :users, force: true do |t| + t.string :first_name + t.string :last_name + t.string :full_name + end + + create_table :skills_users, force: true do |t| + t.references :skill + t.references :user + end + + create_table :skills, force: true do |t| + t.string :name + end + + create_table :areas, force: true do |t| + t.string :name + end + + create_table :tags, force: true do |t| + t.string :name + t.references(:area) + end + + create_table :taggings, force: true do |t| + t.references(:tag) + t.references(:source, polymorphic: true) + end +end diff --git a/spec/support/models.rb b/spec/support/models.rb new file mode 100644 index 0000000..9c85832 --- /dev/null +++ b/spec/support/models.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class User < ActiveRecord::Base + self.table_name = :users + + validates :full_name, presence: true + + has_and_belongs_to_many :skills, join_table: :skills_users + has_many :taggings, as: :source +end + +class Skill < ActiveRecord::Base + self.table_name = :skills + + validates :name, presence: true +end + +class Area < ActiveRecord::Base + has_many :tags +end + +class Tag < ActiveRecord::Base + belongs_to :area +end + +class Tagging < ActiveRecord::Base + belongs_to :tag + belongs_to :source, polymorphic: true +end