diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index 0ea2536..818eec3 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 @@ -238,7 +237,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 +254,12 @@ 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 + 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| @@ -275,15 +279,11 @@ 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) - # filter out attributes for non-chosen embedded factories to avoid triggering - # the NoSuchAttribute exception - 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) } } + if !embedded_factory && !attr.embedded_factories.empty? + raise NoSuchFactoryError, "Unable to match given attributes to an embedded factory. Atributes: #{attributes.keys}" + end # The object that is being manufactured by the factory. # If an embedded factory name is provided, it builds the object using FakerMaker. @@ -293,6 +293,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 @@ -341,6 +358,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/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 62d6790..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 @@ -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 @@ -83,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