From 106228f7e4d046b088612fb119fa7d2c109059f2 Mon Sep 17 00:00:00 2001 From: Nigel Brookes-Thomas Date: Fri, 24 Jul 2026 09:50:44 +0100 Subject: [PATCH 1/3] Explicitly allow the setting of an empty Hash object to an attribute so that invalid JSON Schema messages can be sent --- lib/faker_maker/factory.rb | 16 ++++++++++++++-- lib/faker_maker/version.rb | 2 +- .../docs/usage/embedding-factories/index.page.md | 4 +++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index 0ea2536..e6eb26e 100644 --- a/lib/faker_maker/factory.rb +++ b/lib/faker_maker/factory.rb @@ -238,7 +238,7 @@ def assert_only_known_attributes_for_override( attr_override_values ) def assert_only_known_and_optional_attributes_for_chaos( chaos_attr_values ) chaos_attr_values = chaos_attr_values.map(&:to_sym) unknown_attrs = chaos_attr_values - attribute_names.flat_map do |item| - item.is_a?(Hash) ? item.keys : item + non_empty_hash?(item) ? item.keys : item end issue = "Can't build an instance of '#{class_name}' " \ "setting '#{unknown_attrs.join( ', ' )}', no such attribute(s)" @@ -255,7 +255,13 @@ def overridden_value?( attr, attr_override_values ) end def value_for_attribute( instance, attr, attr_override_values, chaos: false ) - if overridden_value?( attr, attr_override_values ) && !attr_override_values[attr.name].is_a?( Hash ) + # Note: This is a behaviour change on the 5.x branch + # If the attribute is overriden, that value will be supplied UNLESS + # - the value is a Hash, in which case FM will attempt to set nested parameters + # EXCEPT if the hash is empty, in which case the assumptions is that the user intends to always + # return an empty hash value + # binding.irb + if overridden_value?( attr, attr_override_values ) && !non_empty_hash?(attr_override_values[attr.name]) attr_override_values[attr.name] elsif attr.array? [].tap do |a| @@ -341,6 +347,12 @@ def optional_attributes @optional_attributes ||= @attributes.select(&:optional) end + # Return true is the item is a Hash object and it is non-empty. + # Convenience method to improve readability elsewhere + def non_empty_hash?(item) + item.is_a?(Hash) && !item.empty? + end + # Randomly selects optional attributes # Attributes selected from parent will also be selected for the child # @param [Array || TrueClass] chaos_attrs diff --git a/lib/faker_maker/version.rb b/lib/faker_maker/version.rb index d01f334..b574efa 100644 --- a/lib/faker_maker/version.rb +++ b/lib/faker_maker/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FakerMaker - VERSION = '5.0.2' + VERSION = '5.0.3' end diff --git a/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md b/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md index 62d6790..df3808c 100644 --- a/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md +++ b/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md @@ -65,7 +65,7 @@ end This will build a object of the form (in its `as_json` guise): ```ruby -{item: {name: "toothpaste", price: 0.99}, quantity: 10} +{item: {name: "toothpaste", price: 0.99}, quantity: 10} ``` When it comes to overriding values at build time, a hash can be passed to set the nested values: @@ -74,6 +74,8 @@ When it comes to overriding values at build time, a hash can be passed to set th FM[:inventory].build( attributes: { item: { name: 'floor cleaner' } } ) ``` +There is one exception to this. Passing in an empty Hash will always set the attribute to an empty Hash. This is by-design to support the testing of invalid message formats with JSON APIs. + When you allow Faker Maker to make a choice of factory by giving it an array: ```ruby From fd3021a33e0058ebe52cc730fe7e3509f1061c54 Mon Sep 17 00:00:00 2001 From: Nigel Brookes-Thomas Date: Fri, 24 Jul 2026 14:23:48 +0100 Subject: [PATCH 2/3] fix: embedded factories: FM will attempt to choose an embedded factory that contains all of the overrides that have been passed. If more than one match, a random choice will be made, if no factories match, a NoSuchFactoryError will be raised. If there are no overridess then behaviour will continue as before and a random factory will be chosen. This does not walk the embedding tree!! Only the top level embedding will be considered for matching --- lib/faker_maker/factory.rb | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index e6eb26e..7b71c56 100644 --- a/lib/faker_maker/factory.rb +++ b/lib/faker_maker/factory.rb @@ -66,7 +66,6 @@ def build( attributes: {}, chaos: false ) @instance = nil before_build if respond_to? :before_build - # TODO: make this cleverer to handle nested attributes assert_only_known_attributes_for_override( attributes ) assert_chaos_options chaos if chaos @@ -260,7 +259,6 @@ def value_for_attribute( instance, attr, attr_override_values, chaos: false ) # - the value is a Hash, in which case FM will attempt to set nested parameters # EXCEPT if the hash is empty, in which case the assumptions is that the user intends to always # return an empty hash value - # binding.irb if overridden_value?( attr, attr_override_values ) && !non_empty_hash?(attr_override_values[attr.name]) attr_override_values[attr.name] elsif attr.array? @@ -281,10 +279,15 @@ def value_for_attribute( instance, attr, attr_override_values, chaos: false ) def manufacture_from_embedded_factory( attr, attributes = {}, chaos: false ) attributes ||= {} # The name of the embedded factory randomly selected from the list of embedded factories. - embedded_factory = attr.embedded_factories.sample + embedded_factory = select_embedded_factory_or_sample(attr, attributes) + + if !embedded_factory && !attr.embedded_factories.empty? + raise NoSuchFactoryError, "Unable to match given attributes to an embedded factory. Atributes: #{attributes.keys}" + end # filter out attributes for non-chosen embedded factories to avoid triggering # the NoSuchAttribute exception + # NBT: I think the next pipeline is redundant attributes = attr .embedded_factories .reject { |e| e == embedded_factory } @@ -299,6 +302,23 @@ def manufacture_from_embedded_factory( attr, attributes = {}, chaos: false ) embedded_factory&.build(attributes:, chaos: embedded_chaos) end + # Given an attribute, see if there are one or more factory embeddings to choose from. + # If there are more than one, examine the fields and select the most appropriate facotry + # otherwise return a random factory + def select_embedded_factory_or_sample(attr, attribute_overrides) + factory_options = attr.embedded_factories + return nil unless !factory_options&.empty? + + factory_options.filter { |factory_option| + attribute_overrides + .keys + .all? { |attribute_name| + factory_option + .attributes(include_embeddings: false) + .map(&:name).include?(attribute_name) } + }.sample + end + def instantiate assemble.new end From 78729f2101c3c91332f04ea80eabfc038a857586 Mon Sep 17 00:00:00 2001 From: Nigel Brookes-Thomas Date: Mon, 27 Jul 2026 13:11:54 +0100 Subject: [PATCH 3/3] test: add specs for embedded factory selection and empty hash overrides Cover the override-based factory selection logic (select_embedded_factory_or_sample) and the empty hash bypass behaviour. Remove the redundant attribute-filtering pipeline in manufacture_from_embedded_factory that was stripping valid overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/faker_maker/factory.rb | 9 -- spec/faker_maker/factory_spec.rb | 151 ++++++++++++++++++ .../usage/embedding-factories/index.page.md | 6 +- 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index 7b71c56..818eec3 100644 --- a/lib/faker_maker/factory.rb +++ b/lib/faker_maker/factory.rb @@ -285,15 +285,6 @@ def manufacture_from_embedded_factory( attr, attributes = {}, chaos: false ) raise NoSuchFactoryError, "Unable to match given attributes to an embedded factory. Atributes: #{attributes.keys}" end - # filter out attributes for non-chosen embedded factories to avoid triggering - # the NoSuchAttribute exception - # NBT: I think the next pipeline is redundant - attributes = attr - .embedded_factories - .reject { |e| e == embedded_factory } - .flat_map { |f| f.attributes(include_embeddings: false).map(&:name) } - .then { |excl| attributes.delete_if { |k, _v| excl.include?(k) } } - # The object that is being manufactured by the factory. # If an embedded factory name is provided, it builds the object using FakerMaker. # Chaos is converted to a boolean so that child factories inherit random chaos diff --git a/spec/faker_maker/factory_spec.rb b/spec/faker_maker/factory_spec.rb index 3a27de6..a091cc7 100644 --- a/spec/faker_maker/factory_spec.rb +++ b/spec/faker_maker/factory_spec.rb @@ -435,6 +435,157 @@ expect( fake.address.street ).to eq '456 Low Rd' expect( fake.address.city ).to eq 'Swansea' end + + it 'allows an empty Hash to be passed as an override, bypassing the embedded factory' do + embed = FakerMaker::Factory.new( :override_embed ) + embed.attach_attribute( FakerMaker::Attribute.new( :street, proc { '123 High St' } ) ) + embed.attach_attribute( FakerMaker::Attribute.new( :city, proc { 'Swansea' } ) ) + FakerMaker.register_factory( embed ) + + factory = FakerMaker::Factory.new( :override_parent ) + factory.attach_attribute( FakerMaker::Attribute.new( :name, proc { 'Alice' } ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :address, nil, factory: :override_embed ) ) + FakerMaker.register_factory( factory ) + + fake = factory.build( attributes: { address: {} } ) + expect( fake.address ).to eq( {} ) + end + + it 'renders an empty Hash override as {} in JSON output' do + embed = FakerMaker::Factory.new( :override_embed ) + embed.attach_attribute( FakerMaker::Attribute.new( :street, proc { '123 High St' } ) ) + embed.attach_attribute( FakerMaker::Attribute.new( :city, proc { 'Swansea' } ) ) + FakerMaker.register_factory( embed ) + + factory = FakerMaker::Factory.new( :override_parent ) + factory.attach_attribute( FakerMaker::Attribute.new( :name, proc { 'Alice' } ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :address, nil, factory: :override_embed ) ) + FakerMaker.register_factory( factory ) + + fake = factory.build( attributes: { address: {} } ) + expect( fake.as_json[:address] ).to eq( {} ) + end + end + + describe 'selecting embedded factories based on overrides' do + it 'selects the factory whose attributes match the override keys' do + address = FakerMaker::Factory.new( :sel_address ) + address.attach_attribute( FakerMaker::Attribute.new( :street, proc { '1 High St' } ) ) + address.attach_attribute( FakerMaker::Attribute.new( :city, proc { 'London' } ) ) + FakerMaker.register_factory( address ) + + billing = FakerMaker::Factory.new( :sel_billing ) + billing.attach_attribute( FakerMaker::Attribute.new( :card_number, proc { '4111' } ) ) + billing.attach_attribute( FakerMaker::Attribute.new( :expiry, proc { '12/30' } ) ) + FakerMaker.register_factory( billing ) + + factory = FakerMaker::Factory.new( :sel_customer ) + factory.attach_attribute( FakerMaker::Attribute.new( :name, proc { 'Alice' } ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :info, nil, factory: %i[sel_address sel_billing] ) ) + FakerMaker.register_factory( factory ) + + 20.times do + fake = factory.build( attributes: { info: { card_number: '9999' } } ) + expect( fake.info ).to respond_to( :card_number ) + expect( fake.info ).not_to respond_to( :street ) + expect( fake.info.card_number ).to eq '9999' + end + end + + it 'selects from among multiple matching factories' do + uk_addr = FakerMaker::Factory.new( :sel_uk_address ) + uk_addr.attach_attribute( FakerMaker::Attribute.new( :street, proc { 'High St' } ) ) + uk_addr.attach_attribute( FakerMaker::Attribute.new( :postcode, proc { 'SW1A 1AA' } ) ) + FakerMaker.register_factory( uk_addr ) + + us_addr = FakerMaker::Factory.new( :sel_us_address ) + us_addr.attach_attribute( FakerMaker::Attribute.new( :street, proc { 'Main St' } ) ) + us_addr.attach_attribute( FakerMaker::Attribute.new( :zip_code, proc { '10001' } ) ) + FakerMaker.register_factory( us_addr ) + + phone = FakerMaker::Factory.new( :sel_phone ) + phone.attach_attribute( FakerMaker::Attribute.new( :number, proc { '555-0100' } ) ) + FakerMaker.register_factory( phone ) + + factory = FakerMaker::Factory.new( :sel_contact ) + factory.attach_attribute( FakerMaker::Attribute.new( :label, proc { 'home' } ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :detail, nil, factory: %i[sel_uk_address sel_us_address sel_phone] ) ) + FakerMaker.register_factory( factory ) + + results = 30.times.map { factory.build( attributes: { detail: { street: '1 Elm Rd' } } ) } + + results.each do |fake| + expect( fake.detail ).to respond_to( :street ) + expect( fake.detail ).not_to respond_to( :number ) + end + expect( results.any? { |f| f.detail.respond_to?( :postcode ) } ).to be true + expect( results.any? { |f| f.detail.respond_to?( :zip_code ) } ).to be true + end + + it 'raises NoSuchFactoryError when overrides match no embedded factory' do + alpha = FakerMaker::Factory.new( :sel_alpha ) + alpha.attach_attribute( FakerMaker::Attribute.new( :a_val, proc { 'a' } ) ) + FakerMaker.register_factory( alpha ) + + beta = FakerMaker::Factory.new( :sel_beta ) + beta.attach_attribute( FakerMaker::Attribute.new( :b_val, proc { 'b' } ) ) + FakerMaker.register_factory( beta ) + + factory = FakerMaker::Factory.new( :sel_no_match ) + factory.attach_attribute( FakerMaker::Attribute.new( :content, nil, factory: %i[sel_alpha sel_beta] ) ) + FakerMaker.register_factory( factory ) + + expect { factory.build( attributes: { content: { unknown_key: 'x' } } ) } + .to raise_error( FakerMaker::NoSuchFactoryError, /Unable to match given attributes/ ) + end + + it 'randomly selects from all factories when no overrides are given' do + opt_x = FakerMaker::Factory.new( :sel_opt_x ) + opt_x.attach_attribute( FakerMaker::Attribute.new( :x_val, proc { 'x' } ) ) + FakerMaker.register_factory( opt_x ) + + opt_y = FakerMaker::Factory.new( :sel_opt_y ) + opt_y.attach_attribute( FakerMaker::Attribute.new( :y_val, proc { 'y' } ) ) + FakerMaker.register_factory( opt_y ) + + factory = FakerMaker::Factory.new( :sel_random_pick ) + factory.attach_attribute( FakerMaker::Attribute.new( :choice, nil, factory: %i[sel_opt_x sel_opt_y] ) ) + FakerMaker.register_factory( factory ) + + results = 30.times.map { factory.build } + expect( results.any? { |f| f.choice.respond_to?( :x_val ) } ).to be true + expect( results.any? { |f| f.choice.respond_to?( :y_val ) } ).to be true + end + + it 'narrows to the single factory matching all override keys' do + broad = FakerMaker::Factory.new( :sel_broad ) + broad.attach_attribute( FakerMaker::Attribute.new( :alpha, proc { 'a' } ) ) + broad.attach_attribute( FakerMaker::Attribute.new( :beta, proc { 'b' } ) ) + broad.attach_attribute( FakerMaker::Attribute.new( :gamma, proc { 'g' } ) ) + FakerMaker.register_factory( broad ) + + narrow = FakerMaker::Factory.new( :sel_narrow ) + narrow.attach_attribute( FakerMaker::Attribute.new( :alpha, proc { 'a' } ) ) + narrow.attach_attribute( FakerMaker::Attribute.new( :beta, proc { 'b' } ) ) + FakerMaker.register_factory( narrow ) + + other = FakerMaker::Factory.new( :sel_other ) + other.attach_attribute( FakerMaker::Attribute.new( :delta, proc { 'd' } ) ) + FakerMaker.register_factory( other ) + + factory = FakerMaker::Factory.new( :sel_multi_key ) + factory.attach_attribute( FakerMaker::Attribute.new( :item, nil, factory: %i[sel_broad sel_narrow sel_other] ) ) + FakerMaker.register_factory( factory ) + + 20.times do + fake = factory.build( attributes: { item: { alpha: 'A', beta: 'B', gamma: 'G' } } ) + expect( fake.item ).to respond_to( :gamma ) + expect( fake.item ).not_to respond_to( :delta ) + expect( fake.item.alpha ).to eq 'A' + expect( fake.item.beta ).to eq 'B' + expect( fake.item.gamma ).to eq 'G' + end + end end describe '#instance' do diff --git a/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md b/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md index df3808c..2e095c4 100644 --- a/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md +++ b/usefakermaker.com.site/site/src/docs/usage/embedding-factories/index.page.md @@ -74,7 +74,7 @@ When it comes to overriding values at build time, a hash can be passed to set th FM[:inventory].build( attributes: { item: { name: 'floor cleaner' } } ) ``` -There is one exception to this. Passing in an empty Hash will always set the attribute to an empty Hash. This is by-design to support the testing of invalid message formats with JSON APIs. +There is **one exception** to this. Passing in an empty Hash will always set the attribute to an empty Hash. This is by-design to support the testing of invalid message formats with JSON APIs. When you allow Faker Maker to make a choice of factory by giving it an array: @@ -85,7 +85,9 @@ FakerMaker.factory :inventory do end ``` -...either the `item` or `coupon` fields could be added to each build of the `inventory` factory. Faker Maker will ignore any fields for the non-chosen factory if they are paseed in the overrides hash. This means that a `NoSuchAttribute` error will not be raised. +...either the `item` or `coupon` fields could be added to each build of the `inventory` factory. + +**Since v5.0.3** when passing override values to `#build` and where the factory has a choice of embedded factory, as in the example above where an inventory may contain either an item or a coupon, Faker Maker will attempt to locate the embedded factory which most matches the values give in the override. For example, if the attributes given to build the `item` field most match `coupon` fields, that embedded factory will be chosen. If there are still several choices which match the given attributes, a random selection will be made. ## Alternative method