From 0dea2ae5fe3af17359757629fd50f18ebf3bdb68 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 27 Oct 2023 11:08:33 +0000 Subject: [PATCH 01/13] Introduce ManyToMany builder --- lib/rom/factory/attributes/association.rb | 40 +++++++++++++++++++++++ spec/integration/rom/factory_spec.rb | 30 +++++++++++++++++ spec/shared/relations.rb | 2 ++ 3 files changed, 72 insertions(+) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index 582dccd..b7d496e 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -3,6 +3,7 @@ module ROM::Factory module Attributes # @api private + # rubocop:disable Style/OptionalArguments module Association class << self def new(assoc, builder, *traits, **options) @@ -166,6 +167,45 @@ def tpk assoc.target.primary_key end end + + class ManyToMany < Core + def call(attrs = EMPTY_HASH, parent, persist: true) + return if attrs.key?(name) + + structs = count.times.map do + if persist && attrs[tpk] + attrs + elsif persist + builder.persistable.create(*traits, **attrs) + else + builder.struct(*traits, **attrs) + end + end + + assoc.persist([parent], structs) if persist + + {name => structs} + end + + def dependency?(rel) + assoc.source == rel + end + + def through? + true + end + + private + + def count + options.fetch(:count, 1) + end + + def tpk + assoc.target.primary_key + end + end end end + # rubocop:enable Style/OptionalArguments end diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 0759068..04210c8 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -899,6 +899,36 @@ class Admin < ROM::Struct end end + context "has_many-through" do + before do + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "jane@doe.org" + f.timestamps + f.association(:addresses, count: 2) + end + + factories.define(:address) do |f| + f.sequence(:full_address) { |n| "Address #{n}" } + end + end + + it "creates associated records" do + user = factories[:user] + + expect(user.addresses.count).to be(2) + + a1, a2 = user.addresses + + expect(a1.user_id).to be(user.id) + expect(a1.full_address).to eql("Address 1") + + expect(a2.user_id).to be(user.id) + expect(a2.full_address).to eql("Address 2") + end + end + context "belongs_to" do before do factories.define(:user) do |f| diff --git a/spec/shared/relations.rb b/spec/shared/relations.rb index cf4dd4f..56f0803 100644 --- a/spec/shared/relations.rb +++ b/spec/shared/relations.rb @@ -52,6 +52,7 @@ has_many :tasks has_one :user_addresses has_one :address, through: :user_addresses + has_many :addresses, through: :user_addresses end end end @@ -61,6 +62,7 @@ associations do has_one :user_addresses has_one :user, through: :user_addresses + has_one :users, through: :user_addresses end end end From d5c899d341b703f059435c7a122404a6cce49af9 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 3 Nov 2023 14:42:40 +0000 Subject: [PATCH 02/13] Use intermediate through factory if available --- lib/rom/factory/attributes/association.rb | 61 +++++++++-------------- lib/rom/factory/builder.rb | 4 ++ lib/rom/factory/dsl.rb | 8 ++- spec/integration/rom/factory_spec.rb | 12 +++++ spec/shared/relations.rb | 2 + spec/spec_helper.rb | 7 +-- 6 files changed, 50 insertions(+), 44 deletions(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index b7d496e..9cb9c12 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -47,6 +47,11 @@ def dependency?(*) def value? false end + + # @api private + def factories + builder.factories + end end # @api private @@ -132,42 +137,6 @@ def count end end - class OneToOneThrough < Core - def call(attrs = EMPTY_HASH, parent, persist: true) - return if attrs.key?(name) - - struct = if persist && attrs[tpk] - attrs - elsif persist - builder.persistable.create(*traits, **attrs) - else - builder.struct(*traits, **attrs) - end - - assoc.persist([parent], struct) if persist - - {name => struct} - end - - def dependency?(rel) - assoc.source == rel - end - - def through? - true - end - - private - - def count - options.fetch(:count, 1) - end - - def tpk - assoc.target.primary_key - end - end - class ManyToMany < Core def call(attrs = EMPTY_HASH, parent, persist: true) return if attrs.key?(name) @@ -182,7 +151,14 @@ def call(attrs = EMPTY_HASH, parent, persist: true) end end - assoc.persist([parent], structs) if persist + # Delegate to through factory if it exists + if through_factory? && persist + structs.each do |child| + factories[through_factory_name, user: parent, address: child] + end + else + assoc.persist([parent], structs) if persist + end {name => structs} end @@ -195,6 +171,14 @@ def through? true end + def through_factory? + factories.registry.key?(through_factory_name) + end + + def through_factory_name + ROM::Inflector.singularize(assoc.definition.through.source).to_sym + end + private def count @@ -205,6 +189,9 @@ def tpk assoc.target.primary_key end end + + class OneToOneThrough < ManyToMany + end end end # rubocop:enable Style/OptionalArguments diff --git a/lib/rom/factory/builder.rb b/lib/rom/factory/builder.rb index 87a0292..bc0a6d0 100644 --- a/lib/rom/factory/builder.rb +++ b/lib/rom/factory/builder.rb @@ -28,6 +28,10 @@ class Builder # @return [Module] Custom struct namespace option :struct_namespace, reader: false + # @!attribute [r] factories + # @return [Module] Factories with other builders + option :factories, reader: true, optional: true + # @api private def tuple(*traits, **attrs) tuple_evaluator.defaults(traits, attrs) diff --git a/lib/rom/factory/dsl.rb b/lib/rom/factory/dsl.rb index 94a7c62..4eb158d 100644 --- a/lib/rom/factory/dsl.rb +++ b/lib/rom/factory/dsl.rb @@ -50,7 +50,13 @@ def initialize(name, relation:, factories:, struct_namespace:, attributes: Attri # @api private def call - ::ROM::Factory::Builder.new(_attributes, _traits, relation: _relation, struct_namespace: _struct_namespace) + ::ROM::Factory::Builder.new( + _attributes, + _traits, + relation: _relation, + struct_namespace: _struct_namespace, + factories: _factories + ) end # Delegate to a builder and persist a struct diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 04210c8..3ce2fed 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -109,6 +109,12 @@ f.association :address end + factories.define(:user_address) do |f| + f.association(:user) + f.association(:address) + f.timestamps + end + factories.define(:address) do |f| f.full_address "123 Elm St." end @@ -909,6 +915,12 @@ class Admin < ROM::Struct f.association(:addresses, count: 2) end + factories.define(:user_address) do |f| + f.association(:user) + f.association(:address) + f.timestamps + end + factories.define(:address) do |f| f.sequence(:full_address) { |n| "Address #{n}" } end diff --git a/spec/shared/relations.rb b/spec/shared/relations.rb index 56f0803..b314292 100644 --- a/spec/shared/relations.rb +++ b/spec/shared/relations.rb @@ -31,6 +31,8 @@ primary_key :id foreign_key :user_id, :users, on_delete: :cascade foreign_key :address_id, :addresses, on_delete: :cascade + column :created_at, Time, null: false + column :updated_at, Time, null: false end conn.create_table?(:key_values) do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 718ae8b..b169824 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,13 +5,8 @@ require "pathname" SPEC_ROOT = root = Pathname(__FILE__).dirname -begin - require "pry-byebug" -rescue LoadError - require "pry" -end - require "rom-factory" +require "byebug" require "rspec" Dir[root.join("support/*.rb").to_s].sort.each do |f| From 1fb461785cbb1b58fc8512f6563d333c0bca4b09 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 24 Nov 2023 10:03:11 +0000 Subject: [PATCH 03/13] Support building project child structs --- lib/rom/factory/attributes/association.rb | 53 +++++++++++++---------- lib/rom/factory/builder/persistable.rb | 3 +- lib/rom/factory/tuple_evaluator.rb | 14 +++++- spec/integration/rom/factory_spec.rb | 6 +-- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index 9cb9c12..7cb6770 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -52,6 +52,16 @@ def value? def factories builder.factories end + + # @api private + def foreign_key + assoc.foreign_key + end + + # @api private + def count + options.fetch(:count, 1) + end end # @api private @@ -70,11 +80,6 @@ def call(attrs, persist: true) assoc.associate(tuple, struct) end end - - # @api private - def foreign_key - assoc.foreign_key - end end # @api private @@ -101,11 +106,6 @@ def call(attrs = EMPTY_HASH, parent, persist: true) def dependency?(rel) assoc.source == rel end - - # @api private - def count - options.fetch(:count) - end end # @api private @@ -130,11 +130,6 @@ def call(attrs = EMPTY_HASH, parent, persist: true) {name => struct} end - - # @api private - def count - options.fetch(:count, 1) - end end class ManyToMany < Core @@ -152,14 +147,27 @@ def call(attrs = EMPTY_HASH, parent, persist: true) end # Delegate to through factory if it exists - if through_factory? && persist - structs.each do |child| - factories[through_factory_name, user: parent, address: child] + if persist + if through_factory? + structs.each do |child| + through_attrs = { + Dry::Core::Inflector.singularize(assoc.source.name.key).to_sym => parent, + assoc.through.assoc_name => child + } + + factories[through_factory_name, **through_attrs] + end + else + assoc.persist([parent], structs) end + + {name => result(structs)} else - assoc.persist([parent], structs) if persist + result(structs) end + end + def result(structs) {name => structs} end @@ -181,16 +189,15 @@ def through_factory_name private - def count - options.fetch(:count, 1) - end - def tpk assoc.target.primary_key end end class OneToOneThrough < ManyToMany + def result(structs) + {name => structs[0]} + end end end end diff --git a/lib/rom/factory/builder/persistable.rb b/lib/rom/factory/builder/persistable.rb index 17c941f..56a850f 100644 --- a/lib/rom/factory/builder/persistable.rb +++ b/lib/rom/factory/builder/persistable.rb @@ -22,8 +22,9 @@ def initialize(builder, relation = builder.relation) # @api private def create(*traits, **attrs) - tuple = tuple(*traits, **attrs) validate_keys(traits, attrs) + + tuple = tuple(*traits, **attrs) persisted = persist(tuple) if tuple_evaluator.has_associations?(traits) diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index 254a99a..f55f424 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -52,7 +52,11 @@ def struct(*traits, **attrs) attributes.merge!(materialized_callables) associations = assoc_names - .map { |key| [key, attributes[key]] if attributes.key?(key) } + .map { |key| + if (assoc = @attributes[key]) && assoc.count.positive? + [key, build_assoc_attrs(key, attributes[relation.primary_key], attributes[key])] + end + } .compact .to_h @@ -62,6 +66,14 @@ def struct(*traits, **attrs) model.new(attributes) end + def build_assoc_attrs(key, fk, value) + if value.is_a?(Array) + value.map { |el| build_assoc_attrs(key, fk, el) } + else + {attributes[key].foreign_key => fk}.merge(value.to_h) + end + end + # @api private def persist_associations(tuple, parent, traits = []) assoc_names(traits).each do |name| diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 3ce2fed..ad0b486 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -121,7 +121,7 @@ end context "when persisting" do - it "creates the correct records when the is no pre-existing entity" do + it "creates the correct records when there's no pre-existing entity" do user = factories[:user] expect(user.address).to have_attributes(full_address: "123 Elm St.") @@ -137,8 +137,6 @@ context "when building a struct" do it "persists the relation properly with pre-existing assoc record" do - skip "TODO: This does not work, cannot figure out why" - address = factories.structs[:address] user = factories.structs[:user, address: address] @@ -146,8 +144,6 @@ end it "persists the relation properly without pre-existing assoc record" do - skip "TODO: This does not work, cannot figure out why" - user = factories.structs[:user] expect(user.address).to have_attributes(full_address: "123 Elm St.") From 2ca92214a9409dc4fa9c6a514890983c49bf3142 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Sat, 2 Dec 2023 10:14:02 +0000 Subject: [PATCH 04/13] Do not create ManyToOne if it is set to nil explicitly --- lib/rom/factory/attributes/association.rb | 6 ++++-- spec/integration/rom/factory_spec.rb | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index 7cb6770..de80e20 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -68,9 +68,11 @@ def count class ManyToOne < Core # @api private def call(attrs, persist: true) - if attrs.key?(name) && !attrs[foreign_key] + assoc_data = attrs[name] + + if assoc_data && !attrs[foreign_key] assoc.associate(attrs, attrs[name]) - elsif !attrs[foreign_key] + elsif !(attrs.key?(name) && attrs[name].nil?) && !attrs[foreign_key] struct = if persist builder.persistable.create(*traits) else diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index ad0b486..64250e4 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -90,12 +90,23 @@ f.association(:user) end - factories.define(:user, &:timestamps) + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "janjiss@gmail.com" + f.timestamps + end end it "does not pass provided attributes into associations" do expect { factories.structs[:task, title: "Bar"] }.not_to raise_error end + + it "does not build associated struct if it's set to nil explicitly" do + task = factories[:task, user: nil] + + expect(task.user).to be_nil + end end context "one-to-one-through" do From d947948a57e8a664225dd62e74ac4790d0f0e40a Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Sat, 2 Dec 2023 10:24:43 +0000 Subject: [PATCH 05/13] Respect nested attributes for ManyToOne association --- lib/rom/factory/attributes/association.rb | 10 ++++++---- spec/integration/rom/factory_spec.rb | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index de80e20..ed6e937 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -68,15 +68,17 @@ def count class ManyToOne < Core # @api private def call(attrs, persist: true) - assoc_data = attrs[name] + assoc_data = attrs.fetch(name, EMPTY_HASH) - if assoc_data && !attrs[foreign_key] + if assoc_data && assoc_data[assoc.source.primary_key] && !attrs[foreign_key] assoc.associate(attrs, attrs[name]) + elsif assoc_data.is_a?(ROM::Struct) + assoc.associate(attrs, assoc_data) elsif !(attrs.key?(name) && attrs[name].nil?) && !attrs[foreign_key] struct = if persist - builder.persistable.create(*traits) + builder.persistable.create(*traits, **assoc_data) else - builder.struct(*traits) + builder.struct(*traits, **assoc_data) end tuple = {name => struct} assoc.associate(tuple, struct) diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 64250e4..16a0663 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -107,6 +107,12 @@ expect(task.user).to be_nil end + + it "creates the associated record with provided attributes" do + task = factories[:task, user: {first_name: "Jane"}] + + expect(task.user.first_name).to eql("Jane") + end end context "one-to-one-through" do From 0408fe263c21b88d7b8fbfa05a3f4469d65eb74d Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Sat, 2 Dec 2023 11:47:35 +0000 Subject: [PATCH 06/13] Skip building inversed assocs in ManyToOne --- lib/rom/factory/attribute_registry.rb | 4 ++++ lib/rom/factory/attributes/association.rb | 23 ++++++++++++++++++----- lib/rom/factory/builder.rb | 6 +++++- lib/rom/factory/tuple_evaluator.rb | 21 ++++++++++++++------- spec/integration/rom/factory_spec.rb | 13 +++++++++---- 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/lib/rom/factory/attribute_registry.rb b/lib/rom/factory/attribute_registry.rb index 551262b..9a5c443 100644 --- a/lib/rom/factory/attribute_registry.rb +++ b/lib/rom/factory/attribute_registry.rb @@ -50,6 +50,10 @@ def associations self.class.new(elements.select { |e| e.is_a?(Attributes::Association::Core) }) end + def reject(&block) + self.class.new(elements.reject(&block)) + end + private # @api private diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index ed6e937..50bdd11 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -75,15 +75,28 @@ def call(attrs, persist: true) elsif assoc_data.is_a?(ROM::Struct) assoc.associate(attrs, assoc_data) elsif !(attrs.key?(name) && attrs[name].nil?) && !attrs[foreign_key] - struct = if persist - builder.persistable.create(*traits, **assoc_data) + parent = if persist + builder.persistable.create(*parent_traits, **assoc_data) else - builder.struct(*traits, **assoc_data) + builder.struct(*parent_traits, **assoc_data) end - tuple = {name => struct} - assoc.associate(tuple, struct) + + tuple = {name => parent} + + assoc.associate(tuple, parent) end end + + private + + def parent_traits + @parent_traits ||= + if assoc.target.associations.key?(assoc.source.name) + traits + [assoc.target.associations[assoc.source.name].name => false] + else + traits + end + end end # @api private diff --git a/lib/rom/factory/builder.rb b/lib/rom/factory/builder.rb index bc0a6d0..41748f2 100644 --- a/lib/rom/factory/builder.rb +++ b/lib/rom/factory/builder.rb @@ -61,7 +61,11 @@ def persistable # @api private def tuple_evaluator - @__tuple_evaluator__ ||= TupleEvaluator.new(attributes, tuple_evaluator_relation, traits) + @__tuple_evaluator__ ||= TupleEvaluator.new( + attributes, + tuple_evaluator_relation, + traits + ) end # @api private diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index f55f424..fcb9128 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -88,10 +88,15 @@ def assoc_names(traits = []) end def assocs(traits_names = []) - traits + found_assocs = traits .values_at(*traits_names) + .compact .map(&:associations).flat_map(&:elements) .inject(AttributeRegistry.new(attributes.associations.elements), :<<) + + exclude = traits_names.select { |t| t.is_a?(Hash) }.reduce(:merge) || EMPTY_HASH + + found_assocs.reject { |a| exclude[a.name] == false } end # @api private @@ -109,7 +114,7 @@ def primary_key # @api private def evaluate(traits, attrs, opts) evaluate_values(attrs) - .merge(evaluate_associations(attrs, opts)) + .merge(evaluate_associations(traits, attrs, opts)) .merge(evaluate_traits(traits, attrs, opts)) end @@ -125,17 +130,19 @@ def evaluate_values(attrs) end end - def evaluate_traits(traits, attrs, opts) - return {} if traits.empty? + def evaluate_traits(trait_list, attrs, opts) + return {} if trait_list.empty? + + traits = trait_list.map { |v| v.is_a?(Hash) ? v : {v => true} }.reduce(:merge) - traits_attrs = self.traits.values_at(*traits).flat_map(&:elements) + traits_attrs = self.traits.select { |key, value| traits[key] }.values.flat_map(&:elements) registry = AttributeRegistry.new(traits_attrs) self.class.new(registry, relation).defaults([], attrs, **opts) end # @api private - def evaluate_associations(attrs, opts) - attributes.associations.each_with_object({}) do |assoc, h| + def evaluate_associations(traits, attrs, opts) + assocs(traits).associations.each_with_object({}) do |assoc, h| if assoc.dependency?(relation) h[assoc.name] = ->(parent, call_opts) do assoc.call(parent, **opts, **call_opts) diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 16a0663..a4d8dfa 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -95,11 +95,16 @@ f.last_name "Doe" f.email "janjiss@gmail.com" f.timestamps + + f.association(:tasks) end end - it "does not pass provided attributes into associations" do - expect { factories.structs[:task, title: "Bar"] }.not_to raise_error + it "creates a struct with associated parent" do + task = factories.structs[:task, title: "Bar"] + + expect(task.title).to eql("Bar") + expect(task.user.first_name).to eql("Jane") end it "does not build associated struct if it's set to nil explicitly" do @@ -109,9 +114,9 @@ end it "creates the associated record with provided attributes" do - task = factories[:task, user: {first_name: "Jane"}] + task = factories[:task, user: {first_name: "John"}] - expect(task.user.first_name).to eql("Jane") + expect(task.user.first_name).to eql("John") end end From f3a4442a3b8b7308a8deb31e49fcda323ad3cdee Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Sat, 2 Dec 2023 11:48:35 +0000 Subject: [PATCH 07/13] rubocop --- lib/rom/factory/attributes/association.rb | 2 ++ lib/rom/factory/tuple_evaluator.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index 50bdd11..de37895 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -67,6 +67,7 @@ def count # @api private class ManyToOne < Core # @api private + # rubocop:disable Metrics/AbcSize def call(attrs, persist: true) assoc_data = attrs.fetch(name, EMPTY_HASH) @@ -86,6 +87,7 @@ def call(attrs, persist: true) assoc.associate(tuple, parent) end end + # rubocop:enable Metrics/AbcSize private diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index fcb9128..f2e9d29 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -135,7 +135,7 @@ def evaluate_traits(trait_list, attrs, opts) traits = trait_list.map { |v| v.is_a?(Hash) ? v : {v => true} }.reduce(:merge) - traits_attrs = self.traits.select { |key, value| traits[key] }.values.flat_map(&:elements) + traits_attrs = self.traits.select { |key, _value| traits[key] }.values.flat_map(&:elements) registry = AttributeRegistry.new(traits_attrs) self.class.new(registry, relation).defaults([], attrs, **opts) end From 82e3778f974a97a672bcae9f5e812f2945be65a7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Sat, 2 Dec 2023 13:34:02 +0000 Subject: [PATCH 08/13] Handle aliased assocs and struct builder properly --- lib/rom/factory/attributes/association.rb | 6 +- lib/rom/factory/tuple_evaluator.rb | 24 +++--- spec/integration/rom/factory_spec.rb | 95 +++++++++++++++++------ spec/shared/relations.rb | 1 + 4 files changed, 92 insertions(+), 34 deletions(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index de37895..a32783c 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -69,13 +69,15 @@ class ManyToOne < Core # @api private # rubocop:disable Metrics/AbcSize def call(attrs, persist: true) + return if attrs.key?(name) && attrs[name].nil? + assoc_data = attrs.fetch(name, EMPTY_HASH) - if assoc_data && assoc_data[assoc.source.primary_key] && !attrs[foreign_key] + if assoc_data.is_a?(Hash) && assoc_data[assoc.target.primary_key] && !attrs[foreign_key] assoc.associate(attrs, attrs[name]) elsif assoc_data.is_a?(ROM::Struct) assoc.associate(attrs, assoc_data) - elsif !(attrs.key?(name) && attrs[name].nil?) && !attrs[foreign_key] + elsif !attrs[foreign_key] parent = if persist builder.persistable.create(*parent_traits, **assoc_data) else diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index f2e9d29..8b3b073 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -15,9 +15,6 @@ class TupleEvaluator # @api private attr_reader :traits - # @api private - attr_reader :model - # @api private attr_reader :sequence @@ -26,10 +23,13 @@ def initialize(attributes, relation, traits = {}) @attributes = attributes @relation = relation.with(auto_struct: true) @traits = traits - @model = @relation.combine(*assoc_names).mapper.model @sequence = Sequences[relation] end + def model(traits) + @relation.combine(*assoc_names(traits)).mapper.model + end + # @api private def defaults(traits, attrs, **opts) mergeable_attrs = select_mergeable_attrs(traits, attrs) @@ -51,7 +51,8 @@ def struct(*traits, **attrs) attributes.merge!(materialized_callables) - associations = assoc_names + associations = assoc_names(traits) + .reject { |name| attrs.key?(name) && attrs[name].nil? } .map { |key| if (assoc = @attributes[key]) && assoc.count.positive? [key, build_assoc_attrs(key, attributes[relation.primary_key], attributes[key])] @@ -63,7 +64,7 @@ def struct(*traits, **attrs) attributes = relation.output_schema[attributes] attributes.update(associations) - model.new(attributes) + model(traits).new(attributes) end def build_assoc_attrs(key, fk, value) @@ -137,19 +138,22 @@ def evaluate_traits(trait_list, attrs, opts) traits_attrs = self.traits.select { |key, _value| traits[key] }.values.flat_map(&:elements) registry = AttributeRegistry.new(traits_attrs) + self.class.new(registry, relation).defaults([], attrs, **opts) end # @api private def evaluate_associations(traits, attrs, opts) - assocs(traits).associations.each_with_object({}) do |assoc, h| - if assoc.dependency?(relation) - h[assoc.name] = ->(parent, call_opts) do + assocs(traits).associations.each_with_object({}) do |assoc, memo| + if attrs.key?(assoc.name) && attrs[assoc.name].nil? + memo + elsif assoc.dependency?(relation) + memo[assoc.name] = ->(parent, call_opts) do assoc.call(parent, **opts, **call_opts) end else result = assoc.(attrs, **opts) - h.update(result) if result + memo.update(result) if result end end end diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index a4d8dfa..f57bc5c 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -84,39 +84,90 @@ end context "many-to-one" do - before do - factories.define(:task) do |f| - f.title { "Foo" } - f.association(:user) + context "with an association that is not aliased" do + before do + factories.define(:task) do |f| + f.title { "Foo" } + f.association(:user) + end + + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "janjiss@gmail.com" + f.timestamps + + f.association(:tasks) + end end - factories.define(:user) do |f| - f.first_name "Jane" - f.last_name "Doe" - f.email "janjiss@gmail.com" - f.timestamps + it "creates a struct with associated parent" do + task = factories.structs[:task, title: "Bar"] - f.association(:tasks) + expect(task.title).to eql("Bar") + expect(task.user.first_name).to eql("Jane") end - end - it "creates a struct with associated parent" do - task = factories.structs[:task, title: "Bar"] + it "does not build associated struct if it's set to nil explicitly" do + task = factories.structs[:task, user: nil] - expect(task.title).to eql("Bar") - expect(task.user.first_name).to eql("Jane") - end + expect(task.user).to be_nil + end + + it "does not persist associated struct if it's set to nil explicitly" do + task = factories[:task, user: nil] + + expect(task.user).to be_nil + end - it "does not build associated struct if it's set to nil explicitly" do - task = factories[:task, user: nil] + it "creates the associated record with provided attributes" do + task = factories[:task, user: {first_name: "John"}] - expect(task.user).to be_nil + expect(task.user.first_name).to eql("John") + end end - it "creates the associated record with provided attributes" do - task = factories[:task, user: {first_name: "John"}] + context "with an aliased association" do + before do + factories.define(:task) do |f| + f.title { "Foo" } + f.association(:author) + end + + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "janjiss@gmail.com" + f.timestamps + + f.association(:tasks) + end + end + + it "creates a struct with associated parent" do + task = factories.structs[:task, title: "Bar"] + + expect(task.title).to eql("Bar") + expect(task.author.first_name).to eql("Jane") + end + + it "does not build associated struct if it's set to nil explicitly" do + task = factories.structs[:task, author: nil] + + expect(task.author).to be_nil + end + + it "does not persist associated struct if it's set to nil explicitly" do + task = factories[:task, author: nil] - expect(task.user.first_name).to eql("John") + expect(task.author).to be_nil + end + + it "creates the associated record with provided attributes" do + task = factories[:task, author: {first_name: "John"}] + + expect(task.author.first_name).to eql("John") + end end end diff --git a/spec/shared/relations.rb b/spec/shared/relations.rb index b314292..b9e5fc2 100644 --- a/spec/shared/relations.rb +++ b/spec/shared/relations.rb @@ -44,6 +44,7 @@ schema(infer: true) do associations do belongs_to :user + belongs_to :user, as: :author end end end From 881efe485d8b58b2a7147ceb7fa5392a2f9b3be9 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 6 Dec 2023 10:02:45 +0000 Subject: [PATCH 09/13] Cherry-pick assoc names for model --- lib/rom/factory/tuple_evaluator.rb | 21 ++++++++++++--------- spec/integration/rom/factory_spec.rb | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index 8b3b073..3f92c5c 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -26,8 +26,8 @@ def initialize(attributes, relation, traits = {}) @sequence = Sequences[relation] end - def model(traits) - @relation.combine(*assoc_names(traits)).mapper.model + def model(traits, combine: assoc_names(traits)) + @relation.combine(*combine).mapper.model end # @api private @@ -51,15 +51,14 @@ def struct(*traits, **attrs) attributes.merge!(materialized_callables) - associations = assoc_names(traits) - .reject { |name| attrs.key?(name) && attrs[name].nil? } + associations = attrs.slice(*assoc_names(traits)).merge(assoc_names(traits) + .select { |key| + build_assoc?(key, attributes) + } .map { |key| - if (assoc = @attributes[key]) && assoc.count.positive? - [key, build_assoc_attrs(key, attributes[relation.primary_key], attributes[key])] - end + [key, build_assoc_attrs(key, attributes[relation.primary_key], attributes[key])] } - .compact - .to_h + .to_h) attributes = relation.output_schema[attributes] attributes.update(associations) @@ -67,6 +66,10 @@ def struct(*traits, **attrs) model(traits).new(attributes) end + def build_assoc?(key, attributes) + attributes.key?(key) && attributes[key] != [] && !attributes[key].nil? + end + def build_assoc_attrs(key, fk, value) if value.is_a?(Array) value.map { |el| build_assoc_attrs(key, fk, el) } diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index f57bc5c..ce6a0ca 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -54,6 +54,9 @@ end factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "jane@doe.org" f.timestamps f.association(:tasks, count: 2) end @@ -69,6 +72,18 @@ expect(user_with_tasks.tasks).to all(have_attributes(user_id: user_with_tasks.id)) end + it "sets a value explicitly" do + user = factories.structs[:user, tasks: []] + + expect(user.tasks).to be_empty + end + + it "sets a value explicitly when persisting" do + user = factories[:user, tasks: []] + + expect(user.tasks).to be_empty + end + it "does not create records when building child" do factories.structs[:task] @@ -111,13 +126,13 @@ it "does not build associated struct if it's set to nil explicitly" do task = factories.structs[:task, user: nil] - expect(task.user).to be_nil + expect(task.user).to be(nil) end it "does not persist associated struct if it's set to nil explicitly" do task = factories[:task, user: nil] - expect(task.user).to be_nil + expect(task.user).to be(nil) end it "creates the associated record with provided attributes" do From 16febd474a1095085335f05849b0b838e4d0ae65 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Sat, 2 Dec 2023 14:10:14 +0000 Subject: [PATCH 10/13] Handle properly aliased associations --- lib/rom/factory/attributes/association.rb | 2 +- spec/integration/rom/factory_spec.rb | 16 ++++++++++++++++ spec/shared/relations.rb | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index a32783c..2d48a53 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -96,7 +96,7 @@ def call(attrs, persist: true) def parent_traits @parent_traits ||= if assoc.target.associations.key?(assoc.source.name) - traits + [assoc.target.associations[assoc.source.name].name => false] + traits + [assoc.target.associations[assoc.source.name].key => false] else traits end diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index ce6a0ca..9594ea9 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -184,6 +184,22 @@ expect(task.author.first_name).to eql("John") end end + + context "with a self-ref association" do + before do + factories.define(:task) do |f| + f.title { "A Task" } + f.association(:parent) + end + end + + it "creates the associated record with provided attributes" do + task = factories[:task, title: "Foo", parent: {title: "Bar"}] + + expect(task.title).to eql("Foo") + expect(task.parent.title).to eql("Bar") + end + end end context "one-to-one-through" do diff --git a/spec/shared/relations.rb b/spec/shared/relations.rb index b9e5fc2..59018c8 100644 --- a/spec/shared/relations.rb +++ b/spec/shared/relations.rb @@ -19,6 +19,7 @@ conn.create_table?(:tasks) do primary_key :id foreign_key :user_id, :users + foreign_key :task_id, :tasks, null: true column :title, String, null: false end @@ -45,6 +46,7 @@ associations do belongs_to :user belongs_to :user, as: :author + belongs_to :task, as: :parent end end end From 480b321d01f1d0be70f6f2d5101408e2130add66 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 15 Dec 2023 16:26:43 +0000 Subject: [PATCH 11/13] Raise more helpful exceptions when building a struct fails --- lib/rom/factory/tuple_evaluator.rb | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index 3f92c5c..c69d18c 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -6,6 +6,29 @@ module ROM module Factory # @api private class TupleEvaluator + class TupleEvaluatorError < StandardError + attr_reader :original_exception + + def initialize(relation, original_exception, attrs, traits, assoc_attrs) + super(<<~STR) + Failed to build attributes for #{relation.name} + + Attributes: + #{attrs.inspect} + + Associations: + #{assoc_attrs} + + Traits: + #{traits.inspect} + + Original exception: #{original_exception.message} + STR + + set_backtrace(original_exception.backtrace) + end + end + # @api private attr_reader :attributes @@ -37,7 +60,7 @@ def defaults(traits, attrs, **opts) end # @api private - def struct(*traits, **attrs) + def struct(*traits, **attrs) # rubocop:disable Metrics/AbcSize merged_attrs = struct_attrs.merge(defaults(traits, attrs, persist: false)) is_callable = proc { |_name, value| value.respond_to?(:call) } @@ -64,6 +87,8 @@ def struct(*traits, **attrs) attributes.update(associations) model(traits).new(attributes) + rescue StandardError => e + raise TupleEvaluatorError.new(relation, e, attrs, traits, assoc_attrs) end def build_assoc?(key, attributes) From aa3d803dd122b36c4564d31e47cc19eb0b7359fb Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 15 Dec 2023 16:27:20 +0000 Subject: [PATCH 12/13] More test cases for count: 0 --- lib/rom/factory/tuple_evaluator.rb | 10 +- spec/integration/rom/factory_spec.rb | 138 +++++++++++++++++++-------- 2 files changed, 104 insertions(+), 44 deletions(-) diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index c69d18c..fa24235 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -60,7 +60,7 @@ def defaults(traits, attrs, **opts) end # @api private - def struct(*traits, **attrs) # rubocop:disable Metrics/AbcSize + def struct(*traits, **attrs) merged_attrs = struct_attrs.merge(defaults(traits, attrs, persist: false)) is_callable = proc { |_name, value| value.respond_to?(:call) } @@ -74,7 +74,7 @@ def struct(*traits, **attrs) # rubocop:disable Metrics/AbcSize attributes.merge!(materialized_callables) - associations = attrs.slice(*assoc_names(traits)).merge(assoc_names(traits) + assoc_attrs = attributes.slice(*assoc_names(traits)).merge(assoc_names(traits) .select { |key| build_assoc?(key, attributes) } @@ -83,10 +83,10 @@ def struct(*traits, **attrs) # rubocop:disable Metrics/AbcSize } .to_h) - attributes = relation.output_schema[attributes] - attributes.update(associations) + model_attrs = relation.output_schema[attributes] + model_attrs.update(assoc_attrs) - model(traits).new(attributes) + model(traits).new(**model_attrs) rescue StandardError => e raise TupleEvaluatorError.new(relation, e, attrs, traits, assoc_attrs) end diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 9594ea9..65c63e0 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -976,68 +976,128 @@ class Admin < ROM::Struct end context "has_many" do - before do - factories.define(:user) do |f| - f.first_name "Jane" - f.last_name "Doe" - f.email "jane@doe.org" - f.timestamps - f.association(:tasks, count: 2) + context "when count is > 0" do + before do + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "jane@doe.org" + f.timestamps + f.association(:tasks, count: 2) + end + + factories.define(:task) do |f| + f.sequence(:title) { |n| "Task #{n}" } + end end - factories.define(:task) do |f| - f.sequence(:title) { |n| "Task #{n}" } + it "creates associated records" do + user = factories[:user] + + expect(user.tasks.count).to be(2) + + t1, t2 = user.tasks + + expect(t1.user_id).to be(user.id) + expect(t1.title).to eql("Task 1") + + expect(t2.user_id).to be(user.id) + expect(t2.title).to eql("Task 2") end end - it "creates associated records" do - user = factories[:user] + context "when count is 0" do + before do + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "jane@doe.org" + f.timestamps + f.association(:tasks, count: 0) + end - expect(user.tasks.count).to be(2) + factories.define(:task) do |f| + f.sequence(:title) { |n| "Task #{n}" } + end + end - t1, t2 = user.tasks + it "doesn't build associated records" do + user = factories.structs[:user] - expect(t1.user_id).to be(user.id) - expect(t1.title).to eql("Task 1") + expect(user.tasks).to be_empty + end - expect(t2.user_id).to be(user.id) - expect(t2.title).to eql("Task 2") + it "doesn't create associated records" do + user = factories[:user] + + expect(user.tasks).to be_empty + end end end context "has_many-through" do - before do - factories.define(:user) do |f| - f.first_name "Jane" - f.last_name "Doe" - f.email "jane@doe.org" - f.timestamps - f.association(:addresses, count: 2) - end + context "when count is > 0" do + before do + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "jane@doe.org" + f.timestamps + f.association(:addresses, count: 2) + end - factories.define(:user_address) do |f| - f.association(:user) - f.association(:address) - f.timestamps + factories.define(:user_address) do |f| + f.association(:user) + f.association(:address) + f.timestamps + end + + factories.define(:address) do |f| + f.sequence(:full_address) { |n| "Address #{n}" } + end end - factories.define(:address) do |f| - f.sequence(:full_address) { |n| "Address #{n}" } + it "creates associated records" do + user = factories[:user] + + expect(user.addresses.count).to be(2) + + a1, a2 = user.addresses + + expect(a1.user_id).to be(user.id) + expect(a1.full_address).to eql("Address 1") + + expect(a2.user_id).to be(user.id) + expect(a2.full_address).to eql("Address 2") end end - it "creates associated records" do - user = factories[:user] + context "when count is 0" do + before do + factories.define(:user) do |f| + f.first_name "Jane" + f.last_name "Doe" + f.email "jane@doe.org" + f.timestamps + f.association(:addresses, count: 0) + end + + factories.define(:address) do |f| + f.sequence(:full_address) { |n| "Address #{n}" } + end + end - expect(user.addresses.count).to be(2) + it "doesn't build associated records" do + user = factories.structs[:user] - a1, a2 = user.addresses + expect(user.addresses).to be_empty + end - expect(a1.user_id).to be(user.id) - expect(a1.full_address).to eql("Address 1") + it "doesn't create associated records" do + user = factories[:user] - expect(a2.user_id).to be(user.id) - expect(a2.full_address).to eql("Address 2") + expect(user.addresses).to be_empty + end end end From 5be2dc289fb922fd945fed70490c20175234e021 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 15 Dec 2023 16:44:55 +0000 Subject: [PATCH 13/13] Build parent structs when their FK is provided --- lib/rom/factory/attributes/association.rb | 9 ++++++--- lib/rom/factory/tuple_evaluator.rb | 24 ++++++++++++----------- spec/integration/rom/factory_spec.rb | 7 +++++++ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/lib/rom/factory/attributes/association.rb b/lib/rom/factory/attributes/association.rb index 2d48a53..029a186 100644 --- a/lib/rom/factory/attributes/association.rb +++ b/lib/rom/factory/attributes/association.rb @@ -77,11 +77,14 @@ def call(attrs, persist: true) assoc.associate(attrs, attrs[name]) elsif assoc_data.is_a?(ROM::Struct) assoc.associate(attrs, assoc_data) - elsif !attrs[foreign_key] - parent = if persist + else + parent = if persist && !attrs[foreign_key] builder.persistable.create(*parent_traits, **assoc_data) else - builder.struct(*parent_traits, **assoc_data) + builder.struct( + *parent_traits, + **assoc_data.merge(assoc.target.primary_key => attrs[foreign_key]) + ) end tuple = {name => parent} diff --git a/lib/rom/factory/tuple_evaluator.rb b/lib/rom/factory/tuple_evaluator.rb index fa24235..4d0aeff 100644 --- a/lib/rom/factory/tuple_evaluator.rb +++ b/lib/rom/factory/tuple_evaluator.rb @@ -68,20 +68,22 @@ def struct(*traits, **attrs) attributes = merged_attrs.reject(&is_callable) materialized_callables = {} - callables.each do |_name, callable| + callables.each_value do |callable| materialized_callables.merge!(callable.call(attributes, persist: false)) end attributes.merge!(materialized_callables) - assoc_attrs = attributes.slice(*assoc_names(traits)).merge(assoc_names(traits) - .select { |key| - build_assoc?(key, attributes) - } - .map { |key| - [key, build_assoc_attrs(key, attributes[relation.primary_key], attributes[key])] - } - .to_h) + assoc_attrs = attributes.slice(*assoc_names(traits)).merge( + assoc_names(traits) + .select { |key| + build_assoc?(key, attributes) + } + .map { |key| + [key, build_assoc_attrs(key, attributes[relation.primary_key], attributes[key])] + } + .to_h + ) model_attrs = relation.output_schema[attributes] model_attrs.update(assoc_attrs) @@ -91,8 +93,8 @@ def struct(*traits, **attrs) raise TupleEvaluatorError.new(relation, e, attrs, traits, assoc_attrs) end - def build_assoc?(key, attributes) - attributes.key?(key) && attributes[key] != [] && !attributes[key].nil? + def build_assoc?(name, attributes) + attributes.key?(name) && attributes[name] != [] && !attributes[name].nil? end def build_assoc_attrs(key, fk, value) diff --git a/spec/integration/rom/factory_spec.rb b/spec/integration/rom/factory_spec.rb index 65c63e0..eb57120 100644 --- a/spec/integration/rom/factory_spec.rb +++ b/spec/integration/rom/factory_spec.rb @@ -1146,6 +1146,13 @@ class Admin < ROM::Struct expect(rom.relations[:tasks].count).to be(0) end + it "respects FK" do + task = factories.structs[:task, user_id: 312] + + expect(task.user_id).to be(312) + expect(task.user.id).to be(312) + end + it "raises UnknownFactoryAttributes when unknown attributes are used" do expect { factories.structs[:user, name: "Joe"] } .to raise_error(ROM::Factory::UnknownFactoryAttributes, /name/)