Skip to content
33 changes: 19 additions & 14 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
# 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
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
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
50 changes: 17 additions & 33 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading