From c6c4bc554c4686e19adf0272c58b6e644519a37a Mon Sep 17 00:00:00 2001 From: Mark Isaac Date: Mon, 6 Jul 2026 15:27:08 +0100 Subject: [PATCH 1/2] add: public required/optional attribute methods with nested factory support --refactor: rename private attribute methods to top_level_ prefix --- lib/faker_maker/factory.rb | 110 +++++-- .../required_and_optional_attributes_spec.rb | 305 ++++++++++++++++++ 2 files changed, 393 insertions(+), 22 deletions(-) create mode 100644 spec/faker_maker/required_and_optional_attributes_spec.rb diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index 0c877bc..f6999cf 100644 --- a/lib/faker_maker/factory.rb +++ b/lib/faker_maker/factory.rb @@ -71,9 +71,6 @@ def build( attributes: {}, chaos: false ) assert_chaos_options chaos if chaos - optional_attributes - required_attributes - populate_instance(instance, attributes, chaos:) yield instance if block_given? @@ -137,16 +134,43 @@ def json_key_map # # @return [Array] An array (possibly nested) of attribute names, with hashes' keys replaced by their `name`. def attribute_names - transform = lambda do |arr| - arr.map do |item| - if item.is_a?(Hash) - item.transform_keys(&:name).transform_values { |v| transform.call(v) } - else - item.name - end - end - end - transform.call(attributes) + transform_to_names( attributes ) + end + + # Returns the required attributes for this factory, recursing into nested factories + # where the parent attribute is also required. Optional parent attributes are ignored. + # Mirrors the return structure of `attributes`. + # + # @return [Array] the required attributes, possibly nested. + def required_attributes + collect_filtered_attributes( :required ) + end + + # Returns the required attribute names for this factory, recursing into nested factories + # where the parent attribute is also required. Optional parent attributes are ignored. + # Mirrors the return structure of `attributes`. + # + # @return [Array] the required attribute names, possibly nested. + def required_attribute_names + transform_to_names( required_attributes ) + end + + # Returns the optional attributes for this factory, recursing into nested factories + # where the parent attribute is also optional. Required parent attributes are ignored. + # Mirrors the return structure of `attributes`. + # + # @return [Array] the optional attributes, possibly nested. + def optional_attributes + collect_filtered_attributes( :optional ) + end + + # Returns the optional attribute names for this factory, recursing into nested factories + # where the parent attribute is also optional. Required parent attributes are ignored. + # Mirrors the return structure of `attributes`. + # + # @return [Array] the optional attribute names, possibly nested. + def optional_attribute_names + transform_to_names( optional_attributes ) end # Returns a collection of attributes for the factory, optionally including embedded factory attributes. @@ -245,7 +269,7 @@ def assert_only_known_and_optional_attributes_for_chaos( chaos_attr_values ) raise FakerMaker::NoSuchAttributeError, issue unless unknown_attrs.empty? # Are any chaos attributes marked as required? - conflicting_attributes = chaos_attr_values.select { |attr| required_attributes.map(&:name).include? attr } + conflicting_attributes = chaos_attr_values.select { |attr| top_level_required_attributes.map(&:name).include? attr } issue = "Can't use chaos on a required attribute: '#{conflicting_attributes}'" raise FakerMaker::ChaosConflictingAttributeError, issue unless conflicting_attributes.empty? end @@ -331,14 +355,56 @@ def assert_chaos_options( chaos ) end end - # Selects required @attributes - def required_attributes - @required_attributes ||= @attributes.select { |attr| attr.required.eql? true } + # Selects required attributes defined directly on this factory (non-recursive). + # Used internally by chaos logic which handles nesting via recursive build calls. + def top_level_required_attributes + @top_level_required_attributes ||= @attributes.select { |attr| attr.required.eql? true } end - # Selects optional @attributes - def optional_attributes - @optional_attributes ||= @attributes.select(&:optional) + # Selects optional attributes defined directly on this factory (non-recursive). + # Used internally by chaos logic which handles nesting via recursive build calls. + def top_level_optional_attributes + @top_level_optional_attributes ||= @attributes.select(&:optional) + end + + # Recursively transforms a collection of attributes into their symbol names. + # + # @param collection [Array] the attributes to transform. + # + # @return [Array] the attribute names with nesting preserved. + def transform_to_names( collection ) + collection.map do |item| + if item.is_a?( Hash ) + item.transform_keys( &:name ).transform_values { |v| transform_to_names( v ) } + else + item.name + end + end + end + + # Collects attributes filtered by the given type (`:required` or `:optional`), recursing + # into embedded factories only when the parent attribute matches the same filter. + # + # @param filter [Symbol] either `:required` or `:optional`. + # + # @return [Array] the filtered attributes, possibly nested. + def collect_filtered_attributes( filter ) + collection = [] + collection |= FakerMaker[parent].send( :collect_filtered_attributes, filter ) if parent? + + matching_attrs = @attributes.select { |attr| attr.public_send( filter ).eql?( true ) } + + matching_attrs.each do |attr| + if attr.embedded_factories? + nested = attr.embedded_factories.flat_map { |f| f.send( :collect_filtered_attributes, filter ) } + collection << { attr => nested } if nested.any? + collection << attr unless nested.any? + else + collection << attr + end + end + + collection end # Randomly selects optional attributes @@ -347,7 +413,7 @@ def optional_attributes # @return [Array] def chaos_select( chaos_attrs = [] ) selected_attrs = [] - optional_attrs = optional_attributes.dup + optional_attrs = top_level_optional_attributes.dup # Filter specific optional attributes if present if chaos_attrs.is_a?(Array) && chaos_attrs.size.positive? @@ -366,7 +432,7 @@ def chaos_select( chaos_attrs = [] ) end # Concat required, selected and parent attributes - @chaos_selected_attributes.concat(required_attributes) + @chaos_selected_attributes.concat(top_level_required_attributes) .concat(selected_inherited_attr) .concat(selected_attrs).uniq! @chaos_selected_attributes diff --git a/spec/faker_maker/required_and_optional_attributes_spec.rb b/spec/faker_maker/required_and_optional_attributes_spec.rb new file mode 100644 index 0000000..b9031ce --- /dev/null +++ b/spec/faker_maker/required_and_optional_attributes_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +RSpec.describe FakerMaker::Factory do + describe '#required_attributes' do + it 'returns required attributes for a single-level factory' do + factory = FakerMaker::Factory.new( :flat_required ) + req_attr = FakerMaker::Attribute.new( :name, proc { 'Jane' }, required: true ) + opt_attr = FakerMaker::Attribute.new( :nickname, proc { 'Janey' } ) + factory.attach_attribute( req_attr ) + factory.attach_attribute( opt_attr ) + FakerMaker.register_factory( factory ) + + expect( factory.required_attributes ).to eq [req_attr] + end + + it 'returns an empty array when no attributes are required' do + factory = FakerMaker::Factory.new( :no_required ) + opt_attr = FakerMaker::Attribute.new( :flavour, proc { 'vanilla' } ) + factory.attach_attribute( opt_attr ) + FakerMaker.register_factory( factory ) + + expect( factory.required_attributes ).to eq [] + end + + it 'recurses into embedded factories when the parent attribute is required' do + child_factory = FakerMaker::Factory.new( :req_child ) + child_req = FakerMaker::Attribute.new( :capacity, proc { '2.0L' }, required: true ) + child_opt = FakerMaker::Attribute.new( :turbo, proc { false } ) + child_factory.attach_attribute( child_req ) + child_factory.attach_attribute( child_opt ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :req_parent ) + parent_req = FakerMaker::Attribute.new( :engine, nil, required: true, factory: :req_child ) + parent_factory.attach_attribute( parent_req ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.required_attributes + expect( result.length ).to eq 1 + expect( result.first ).to be_a Hash + expect( result.first.keys ).to eq [parent_req] + expect( result.first[parent_req] ).to eq [child_req] + end + + it 'ignores children of optional parent attributes' do + child_factory = FakerMaker::Factory.new( :ignored_child ) + child_req = FakerMaker::Attribute.new( :important, proc { 'yes' }, required: true ) + child_factory.attach_attribute( child_req ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :opt_parent_req_child ) + parent_opt = FakerMaker::Attribute.new( :extras, nil, factory: :ignored_child ) + parent_req = FakerMaker::Attribute.new( :id, proc { '123' }, required: true ) + parent_factory.attach_attribute( parent_opt ) + parent_factory.attach_attribute( parent_req ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.required_attributes + expect( result ).to eq [parent_req] + end + + it 'shows all possible factories when multiple are defined' do + factory_a = FakerMaker::Factory.new( :multi_a ) + attr_a = FakerMaker::Attribute.new( :speed, proc { 'fast' }, required: true ) + factory_a.attach_attribute( attr_a ) + FakerMaker.register_factory( factory_a ) + + factory_b = FakerMaker::Factory.new( :multi_b ) + attr_b = FakerMaker::Attribute.new( :power, proc { '150kW' }, required: true ) + factory_b.attach_attribute( attr_b ) + FakerMaker.register_factory( factory_b ) + + parent_factory = FakerMaker::Factory.new( :multi_parent ) + parent_attr = FakerMaker::Attribute.new( :drive, nil, required: true, factory: %i[multi_a multi_b] ) + parent_factory.attach_attribute( parent_attr ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.required_attributes + expect( result.length ).to eq 1 + expect( result.first ).to be_a Hash + expect( result.first[parent_attr] ).to contain_exactly( attr_a, attr_b ) + end + + it 'includes parent factory required attributes via inheritance' do + base_factory = FakerMaker::Factory.new( :base_req ) + base_attr = FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) + base_opt = FakerMaker::Attribute.new( :updated_at, proc { Time.now } ) + base_factory.attach_attribute( base_attr ) + base_factory.attach_attribute( base_opt ) + FakerMaker.register_factory( base_factory ) + + derived_factory = FakerMaker::Factory.new( :derived_req, parent: :base_req ) + derived_attr = FakerMaker::Attribute.new( :name, proc { 'Jane' }, required: true ) + derived_factory.attach_attribute( derived_attr ) + FakerMaker.register_factory( derived_factory ) + + result = derived_factory.required_attributes + expect( result ).to contain_exactly( base_attr, derived_attr ) + end + + it 'recurses multiple levels deep' do + leaf_factory = FakerMaker::Factory.new( :deep_leaf ) + leaf_attr = FakerMaker::Attribute.new( :provider, proc { 'TomTom' }, required: true ) + leaf_factory.attach_attribute( leaf_attr ) + FakerMaker.register_factory( leaf_factory ) + + mid_factory = FakerMaker::Factory.new( :deep_mid ) + mid_attr = FakerMaker::Attribute.new( :nav, nil, required: true, factory: :deep_leaf ) + mid_factory.attach_attribute( mid_attr ) + FakerMaker.register_factory( mid_factory ) + + top_factory = FakerMaker::Factory.new( :deep_top ) + top_attr = FakerMaker::Attribute.new( :dash, nil, required: true, factory: :deep_mid ) + top_factory.attach_attribute( top_attr ) + FakerMaker.register_factory( top_factory ) + + result = top_factory.required_attributes + expect( result.length ).to eq 1 + expect( result.first ).to be_a Hash + expect( result.first[top_attr].length ).to eq 1 + expect( result.first[top_attr].first ).to be_a Hash + expect( result.first[top_attr].first[mid_attr] ).to eq [leaf_attr] + end + + it 'returns the attribute directly when embedded factory has no matching nested attributes' do + child_factory = FakerMaker::Factory.new( :all_opt_child ) + child_opt = FakerMaker::Attribute.new( :colour, proc { 'red' } ) + child_factory.attach_attribute( child_opt ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :req_parent_opt_children ) + parent_attr = FakerMaker::Attribute.new( :widget, nil, required: true, factory: :all_opt_child ) + parent_factory.attach_attribute( parent_attr ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.required_attributes + expect( result ).to eq [parent_attr] + end + end + + describe '#required_attribute_names' do + it 'returns symbols for a flat factory' do + factory = FakerMaker::Factory.new( :flat_req_names ) + factory.attach_attribute( FakerMaker::Attribute.new( :name, proc { 'Jane' }, required: true ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :age, proc { 30 }, required: true ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :nickname, proc { 'J' } ) ) + FakerMaker.register_factory( factory ) + + expect( factory.required_attribute_names ).to eq %i[name age] + end + + it 'returns nested hashes with symbol keys for embedded factories' do + child_factory = FakerMaker::Factory.new( :names_child ) + child_factory.attach_attribute( FakerMaker::Attribute.new( :fuel_type, proc { 'petrol' }, required: true ) ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :names_parent ) + parent_factory.attach_attribute( FakerMaker::Attribute.new( :reg, proc { 'AB12' }, required: true ) ) + parent_factory.attach_attribute( FakerMaker::Attribute.new( :engine, nil, required: true, factory: :names_child ) ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.required_attribute_names + expect( result ).to eq [:reg, { engine: [:fuel_type] }] + end + end + + describe '#optional_attributes' do + it 'returns optional attributes for a single-level factory' do + factory = FakerMaker::Factory.new( :flat_optional ) + req_attr = FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) + opt_attr = FakerMaker::Attribute.new( :nickname, proc { 'Janey' } ) + factory.attach_attribute( req_attr ) + factory.attach_attribute( opt_attr ) + FakerMaker.register_factory( factory ) + + expect( factory.optional_attributes ).to eq [opt_attr] + end + + it 'returns an empty array when no attributes are optional' do + factory = FakerMaker::Factory.new( :all_required ) + req_attr = FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) + factory.attach_attribute( req_attr ) + FakerMaker.register_factory( factory ) + + expect( factory.optional_attributes ).to eq [] + end + + it 'recurses into embedded factories when the parent attribute is optional' do + child_factory = FakerMaker::Factory.new( :opt_child ) + child_opt = FakerMaker::Attribute.new( :brand, proc { 'Bose' } ) + child_req = FakerMaker::Attribute.new( :model, proc { 'QC45' }, required: true ) + child_factory.attach_attribute( child_opt ) + child_factory.attach_attribute( child_req ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :opt_parent ) + parent_opt = FakerMaker::Attribute.new( :stereo, nil, factory: :opt_child ) + parent_factory.attach_attribute( parent_opt ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.optional_attributes + expect( result.length ).to eq 1 + expect( result.first ).to be_a Hash + expect( result.first.keys ).to eq [parent_opt] + expect( result.first[parent_opt] ).to eq [child_opt] + end + + it 'ignores children of required parent attributes' do + child_factory = FakerMaker::Factory.new( :req_parent_child ) + child_opt = FakerMaker::Attribute.new( :turbo, proc { false } ) + child_factory.attach_attribute( child_opt ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :req_parent_ignores_opt ) + parent_req = FakerMaker::Attribute.new( :engine, nil, required: true, factory: :req_parent_child ) + parent_opt = FakerMaker::Attribute.new( :colour, proc { 'blue' } ) + parent_factory.attach_attribute( parent_req ) + parent_factory.attach_attribute( parent_opt ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.optional_attributes + expect( result ).to eq [parent_opt] + end + + it 'shows all possible factories when multiple are defined' do + factory_a = FakerMaker::Factory.new( :opt_multi_a ) + attr_a = FakerMaker::Attribute.new( :screen_size, proc { '7 inch' } ) + factory_a.attach_attribute( attr_a ) + FakerMaker.register_factory( factory_a ) + + factory_b = FakerMaker::Factory.new( :opt_multi_b ) + attr_b = FakerMaker::Attribute.new( :resolution, proc { '1080p' } ) + factory_b.attach_attribute( attr_b ) + FakerMaker.register_factory( factory_b ) + + parent_factory = FakerMaker::Factory.new( :opt_multi_parent ) + parent_attr = FakerMaker::Attribute.new( :display, nil, factory: %i[opt_multi_a opt_multi_b] ) + parent_factory.attach_attribute( parent_attr ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.optional_attributes + expect( result.length ).to eq 1 + expect( result.first ).to be_a Hash + expect( result.first[parent_attr] ).to contain_exactly( attr_a, attr_b ) + end + + it 'includes parent factory optional attributes via inheritance' do + base_factory = FakerMaker::Factory.new( :base_opt ) + base_opt = FakerMaker::Attribute.new( :updated_at, proc { Time.now } ) + base_req = FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) + base_factory.attach_attribute( base_opt ) + base_factory.attach_attribute( base_req ) + FakerMaker.register_factory( base_factory ) + + derived_factory = FakerMaker::Factory.new( :derived_opt, parent: :base_opt ) + derived_opt = FakerMaker::Attribute.new( :nickname, proc { 'J' } ) + derived_factory.attach_attribute( derived_opt ) + FakerMaker.register_factory( derived_factory ) + + result = derived_factory.optional_attributes + expect( result ).to contain_exactly( base_opt, derived_opt ) + end + + it 'returns the attribute directly when embedded factory has no matching nested attributes' do + child_factory = FakerMaker::Factory.new( :all_req_child ) + child_req = FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) + child_factory.attach_attribute( child_req ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :opt_parent_req_children ) + parent_attr = FakerMaker::Attribute.new( :widget, nil, factory: :all_req_child ) + parent_factory.attach_attribute( parent_attr ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.optional_attributes + expect( result ).to eq [parent_attr] + end + end + + describe '#optional_attribute_names' do + it 'returns symbols for a flat factory' do + factory = FakerMaker::Factory.new( :flat_opt_names ) + factory.attach_attribute( FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :nickname, proc { 'J' } ) ) + factory.attach_attribute( FakerMaker::Attribute.new( :colour, proc { 'blue' } ) ) + FakerMaker.register_factory( factory ) + + expect( factory.optional_attribute_names ).to eq %i[nickname colour] + end + + it 'returns nested hashes with symbol keys for embedded factories' do + child_factory = FakerMaker::Factory.new( :opt_names_child ) + child_factory.attach_attribute( FakerMaker::Attribute.new( :brand, proc { 'Pirelli' } ) ) + FakerMaker.register_factory( child_factory ) + + parent_factory = FakerMaker::Factory.new( :opt_names_parent ) + parent_factory.attach_attribute( FakerMaker::Attribute.new( :id, proc { '1' }, required: true ) ) + parent_factory.attach_attribute( FakerMaker::Attribute.new( :wheels, nil, factory: :opt_names_child ) ) + FakerMaker.register_factory( parent_factory ) + + result = parent_factory.optional_attribute_names + expect( result ).to eq [{ wheels: [:brand] }] + end + end +end From bf0b493b865374d0e5463ff04e70a33c2af1ebf9 Mon Sep 17 00:00:00 2001 From: Mark Isaac Date: Mon, 6 Jul 2026 15:58:01 +0100 Subject: [PATCH 2/2] refactor: duplication in comments --update: minor version --- lib/faker_maker/factory.rb | 27 ++++++++++++--------------- lib/faker_maker/version.rb | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index f6999cf..317df04 100644 --- a/lib/faker_maker/factory.rb +++ b/lib/faker_maker/factory.rb @@ -66,7 +66,8 @@ def build( attributes: {}, chaos: false ) @instance = nil before_build if respond_to? :before_build - # TODO: make this cleverer to handle nested attributes + # Only top-level attributes are validated at this point. Nested attribute + # overrides are validated by the child factory's own build call. assert_only_known_attributes_for_override( attributes ) assert_chaos_options chaos if chaos @@ -137,38 +138,34 @@ def attribute_names transform_to_names( attributes ) end - # Returns the required attributes for this factory, recursing into nested factories - # where the parent attribute is also required. Optional parent attributes are ignored. - # Mirrors the return structure of `attributes`. + # Returns required attributes, recursing into nested factories where the parent attribute + # is also required. Optional parent attributes are ignored. # # @return [Array] the required attributes, possibly nested. def required_attributes collect_filtered_attributes( :required ) end - # Returns the required attribute names for this factory, recursing into nested factories - # where the parent attribute is also required. Optional parent attributes are ignored. - # Mirrors the return structure of `attributes`. + # The same as `required_attributes`, only with attribute names (as symbols) returned instead + # of attribute objects. # - # @return [Array] the required attribute names, possibly nested. + # @return [Array] def required_attribute_names transform_to_names( required_attributes ) end - # Returns the optional attributes for this factory, recursing into nested factories - # where the parent attribute is also optional. Required parent attributes are ignored. - # Mirrors the return structure of `attributes`. + # Returns optional attributes, recursing into nested factories where the parent attribute + # is also optional. Required parent attributes are ignored. # # @return [Array] the optional attributes, possibly nested. def optional_attributes collect_filtered_attributes( :optional ) end - # Returns the optional attribute names for this factory, recursing into nested factories - # where the parent attribute is also optional. Required parent attributes are ignored. - # Mirrors the return structure of `attributes`. + # The same as `optional_attributes`, only with attribute names (as symbols) returned instead + # of attribute objects. # - # @return [Array] the optional attribute names, possibly nested. + # @return [Array] def optional_attribute_names transform_to_names( optional_attributes ) end diff --git a/lib/faker_maker/version.rb b/lib/faker_maker/version.rb index 312220b..29367ad 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.1' + VERSION = '5.1.0' end