Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions lib/faker_maker/factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand All @@ -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|
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/faker_maker/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module FakerMaker
VERSION = '5.0.2'
VERSION = '5.0.3'
end
151 changes: 151 additions & 0 deletions spec/faker_maker/factory_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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

Expand Down
Loading