diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index fecd04e..b2ae0d3 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ["3.0", "3.1", "3.2", "3.3", "3.4"] + ruby-version: ["3.4", "4.0"] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index f5dc66e..6d3cfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ /tmp/ Gemfile.lock + +/hack/ +TODO.md + # rspec failure tracking .rspec_status diff --git a/.rubocop.yml b/.rubocop.yml index 64eab7b..77b9220 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -52,4 +52,5 @@ Metrics/PerceivedComplexity: Max: 10 AllCops: + TargetRubyVersion: 4.0 NewCops: enable diff --git a/Gemfile b/Gemfile index 03b6049..6e7b67f 100644 --- a/Gemfile +++ b/Gemfile @@ -10,3 +10,5 @@ gemspec gem 'bundler-audit', '~> 0.9.2' gem 'irb', '~> 1.15' gem 'rdoc', '~> 6.13' + +gem 'awesome_print', '~> 1.9' diff --git a/faker_maker.gemspec b/faker_maker.gemspec index 827c7d1..46ba190 100644 --- a/faker_maker.gemspec +++ b/faker_maker.gemspec @@ -43,7 +43,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'activesupport', '>= 5.2', '< 9' - spec.add_development_dependency 'bundler', '~> 2' + spec.add_development_dependency 'bundler', '>= 2' spec.add_development_dependency 'faker', '~> 3.2' spec.add_development_dependency 'guard', '~> 2.16' spec.add_development_dependency 'guard-bundler', '~> 3.0' diff --git a/lib/faker_maker/attribute.rb b/lib/faker_maker/attribute.rb index 9a3a5bd..ac6c945 100644 --- a/lib/faker_maker/attribute.rb +++ b/lib/faker_maker/attribute.rb @@ -3,7 +3,7 @@ module FakerMaker # Attributes describe the fields of classes class Attribute - attr_reader :name, :block, :translation, :required, :optional, :optional_weighting, :embedded_factories + attr_reader :name, :block, :translation, :required, :optional, :optional_weighting DEFAULT_OPTIONAL_WEIGHTING = 0.5 @@ -25,6 +25,15 @@ def initialize( name, block = nil, options = {} ) end end + # Return an array of factory instances + def embedded_factories + @embedded_factories.map { |name| FakerMaker[name] } + end + + def embedded_factories? + @embedded_factories.any? + end + def array? forced_array? || @array end diff --git a/lib/faker_maker/base.rb b/lib/faker_maker/base.rb index b59e891..5d42946 100644 --- a/lib/faker_maker/base.rb +++ b/lib/faker_maker/base.rb @@ -3,12 +3,12 @@ module FakerMaker # Base module for defining the DSL module Base - def factory(name, options = {}, &block) + def factory(name, options = {}, &) factory = FakerMaker.find_factory(name) if factory.nil? factory = FakerMaker::Factory.new name, options proxy = DefinitionProxy.new factory - proxy.instance_eval( &block ) if block_given? + proxy.instance_eval( & ) if block_given? FakerMaker.register_factory factory else factory diff --git a/lib/faker_maker/definition_proxy.rb b/lib/faker_maker/definition_proxy.rb index 81fa426..371f92c 100644 --- a/lib/faker_maker/definition_proxy.rb +++ b/lib/faker_maker/definition_proxy.rb @@ -13,8 +13,8 @@ def faker_maker_factory @factory end - def method_missing(name, *args, &block) - attribute = FakerMaker::Attribute.new name, block, *args + def method_missing(name, *, &block) + attribute = FakerMaker::Attribute.new(name, block, *) @factory.attach_attribute attribute end diff --git a/lib/faker_maker/factory.rb b/lib/faker_maker/factory.rb index 25eb56b..b023c02 100644 --- a/lib/faker_maker/factory.rb +++ b/lib/faker_maker/factory.rb @@ -5,8 +5,26 @@ module FakerMaker # Factories construct instances of a fake class Factory include Auditable + attr_reader :name, :class_name, :parent, :chaos_selected_attributes + # Create a new +Factory+ object. + # + # This method does not automatically register the factory,see + # FakerMaker#register_factory + # + # Options: + # - +:class_name+ - override the default class name that FakerMaker will generate. + # This is useful in the case of collisions with existing classes or keywords. + # - +:parent+ - the parent factory from which this factory inherits attributes. + # Instances built by this factory will have a class which inherits from the parent's + # class. + # - +:naming+ - one of: + # - +nil+ (default) - use field names as the method name and in JSON conversion + # - +:json+ - use field names as the method name but convert when rendering JSON, e.g. + # +hello_world+ becomes +helloWorld+ + # - +:json_capitalised+ (or +:json_capitalized+) - as +:json+ but with the first letter + # captialised, e.g. +hello_world+ becomes +HelloWorld+ def initialize( name, options = {} ) assert_valid_options options @name = name.respond_to?(:to_sym) ? name.to_sym : name.to_s.underscore.to_sym @@ -26,6 +44,7 @@ def initialize( name, options = {} ) @parent = options[:parent] end + # Get the Class of the parent for this factory def parent_class if @parent FakerMaker::Factory.const_get( FakerMaker[@parent].class_name ) @@ -34,6 +53,7 @@ def parent_class end end + # Attach a FakerMaker::Attribute to this Factory def attach_attribute( attribute ) @attributes << attribute end @@ -42,14 +62,11 @@ def instance @instance ||= instantiate end - def build( attributes: {}, chaos: false, **kwargs ) - if kwargs.present? - validate_deprecated_build(kwargs) - attributes = kwargs - end - + 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 @@ -57,13 +74,19 @@ def build( attributes: {}, chaos: false, **kwargs ) optional_attributes required_attributes - populate_instance instance, attributes, chaos + populate_instance(instance, attributes, chaos:) yield instance if block_given? + after_build if respond_to? :after_build audit(@instance) if FakerMaker.configuration.audit? instance end + # Construct a Class object which will become the parent type of objects built + # by this factory. + # + # The Class object will be created and the attributes added to it. The returned value + # is a Ruby Class which can be instantiated. def assemble if @klass.nil? @klass = Class.new parent_class @@ -91,7 +114,7 @@ def json_key_map unless @json_key_map @json_key_map = {}.with_indifferent_access @json_key_map.merge!( FakerMaker[parent].json_key_map ) if parent? - attributes.each_with_object( @json_key_map ) do |attr, map| + attributes(include_embeddings: false).each_with_object( @json_key_map ) do |attr, map| key = if attr.translation? attr.translation elsif @naming_strategy @@ -106,29 +129,88 @@ def json_key_map @json_key_map end - def attribute_names( collection = [] ) - collection |= FakerMaker[parent].attribute_names( collection ) if parent? - collection | @attributes.map( &:name ) + # Returns a transformed list of attribute names from the `attributes` array. + # For each item in the array: + # - If the item is a Hash, recursively transforms its keys and values, + # replacing keys with their `name` and applying the same transformation to values. + # - Otherwise, replaces the item with its `name`. + # + # @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) end - def attributes( collection = [] ) + # Returns a collection of attributes for the factory, optionally including embedded factory attributes. + # + # @param collection [Array] an optional array of attributes to start with (default: empty array) + # @param include_embeddings [Boolean] whether to include attributes from embedded factories (default: true) + # @return [Array] the collection of attributes, possibly including embedded factory attributes as hashes + # + # If the factory has a parent, its attributes are merged in. Attributes without embedded factories are added + # directly. If `include_embeddings` is true, attributes with embedded factories are added as hashes mapping + # the attribute to the flattened attributes of its embedded factories. If false, only the attribute itself + # is added. + def attributes( collection = [], include_embeddings: true ) collection |= FakerMaker[parent].attributes( collection ) if parent? - collection | @attributes + collection |= @attributes.reject { |attr| attr.embedded_factories.any? } + + # if there is an embedded factory(-ies) and we are including the embedded factory's + # fields, we are going to return a hash + if include_embeddings + @attributes.select { |attr| attr.embedded_factories.any? }.each do |attr| + collection << { attr => attr.embedded_factories.flat_map(&:attributes) } + end + # if there is an embedded factory(-ies) and we are not including the embedded factory's + # fields, just add the attribute into the set of returned fields + else + collection |= @attributes.select { |attr| attr.embedded_factories.any? } + end + + collection end + # Finds and returns the first attribute matching the given name. + # + # This method searches through the attributes (excluding embeddings) and returns the first attribute + # whose name, translation, or the result of applying the naming strategy to its name matches the provided `name`. + # + # @param name [String] The name to search for among the attributes. Defaults to an empty string. + # @return [Object, nil] The first matching attribute object, or nil if no match is found. def find_attribute( name = '' ) - attributes.filter { |a| [a.name, a.translation, @naming_strategy&.name(a.name)].include? name }.first + attributes(include_embeddings: false).filter do |a| + [a.name, a.translation, @naming_strategy&.name(a.name)].include? name + end.first end protected - def populate_instance( instance, attr_override_values, chaos ) - FakerMaker[parent].populate_instance instance, attr_override_values, chaos if parent? + # Populates the given instance with attribute values, optionally applying chaos/randomization. + # + # @param instance [Object] The object instance to populate with attribute values. + # @param attr_override_values [Hash] A hash of attribute names and their override values. + # @param chaos [Boolean, Integer, nil] If truthy, enables chaos mode which may randomize or select a subset + # of attributes. + # @return [void] + # + # If the factory has a parent, its attributes are populated first. + # Each attribute is assigned a value, either from the override values or generated. + # The factory instance is set on the populated object for reference. + def populate_instance( instance, attr_override_values, chaos: false ) + FakerMaker[parent].populate_instance(instance, attr_override_values, chaos:) if parent? attributes = chaos ? chaos_select(chaos) : @attributes attributes.each do |attribute| - value = value_for_attribute( instance, attribute, attr_override_values ) + value = value_for_attribute( instance, attribute, attr_override_values, chaos: ) instance.send "#{attribute.name}=", value end instance.instance_variable_set( :@fm_factory, self ) @@ -137,7 +219,10 @@ def populate_instance( instance, attr_override_values, chaos ) private def assert_only_known_attributes_for_override( attr_override_values ) - unknown_attrs = attr_override_values.keys - attribute_names + unknown_attrs = attr_override_values.keys - attribute_names.flat_map do |item| + item.is_a?(Hash) ? item.keys : item + end + issue = "Can't build an instance of '#{class_name}' " \ "setting '#{unknown_attrs.join( ', ' )}', no such attribute(s)" raise FakerMaker::NoSuchAttributeError, issue unless unknown_attrs.empty? @@ -156,34 +241,44 @@ def assert_only_known_and_optional_attributes_for_chaos( chaos_attr_values ) raise FakerMaker::ChaosConflictingAttributeError, issue unless conflicting_attributes.empty? end - def attribute_hash_overridden_value?( attr, attr_override_values ) + def overridden_value?( attr, attr_override_values ) attr_override_values.keys.include?( attr.name ) end - def value_for_attribute( instance, attr, attr_override_values ) - if attribute_hash_overridden_value?( attr, attr_override_values ) + def value_for_attribute( instance, attr, attr_override_values, chaos: false ) + if !attr.embedded_factories? && overridden_value?( attr, attr_override_values ) attr_override_values[attr.name] elsif attr.array? [].tap do |a| attr.cardinality.times do - manufacture = manufacture_from_embedded_factory( attr ) - # if manufacture has been build and there is a block, instance_exec the block + manufacture = manufacture_from_embedded_factory( attr, attr_override_values[attr.name.to_sym], chaos: ) + # if manufacture has been built and there is a block, instance_exec the block # otherwise just add the manufacture to the array a << (attr.block ? instance.instance_exec(manufacture, &attr.block) : manufacture) end end else - manufacture = manufacture_from_embedded_factory( attr ) + manufacture = manufacture_from_embedded_factory( attr, attr_override_values[attr.name.to_sym], chaos: ) attr.block ? instance.instance_exec(manufacture, &attr.block) : manufacture end end - def manufacture_from_embedded_factory( attr ) + 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_name = attr.embedded_factories.sample + embedded_factory = attr.embedded_factories.sample + + # 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| pp f.attributes.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. - embedded_factory_name ? FakerMaker[embedded_factory_name].build : nil + embedded_factory&.build(attributes:, chaos:) end def instantiate @@ -264,13 +359,6 @@ def chaos_select( chaos_attrs = [] ) .concat(selected_attrs).uniq! @chaos_selected_attributes end - - def validate_deprecated_build(kwargs) - usage = kwargs.each_with_object([]) { |kwarg, result| result << "#{kwarg.first}: #{kwarg.last}" }.join(', ') - - warn "[DEPRECATION] `FM[:#{name}].build(#{usage})` is deprecated. " \ - "Please use `FM[:#{name}].build(attributes: { #{usage} })` instead." - end end end # rubocop:enable Metrics/ClassLength diff --git a/lib/faker_maker/version.rb b/lib/faker_maker/version.rb index b32fc18..a497f67 100644 --- a/lib/faker_maker/version.rb +++ b/lib/faker_maker/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FakerMaker - VERSION = '3.0.0' + VERSION = '4.0.0' end diff --git a/spec/faker_maker/attribute_spec.rb b/spec/faker_maker/attribute_spec.rb index 2783e96..632a48e 100644 --- a/spec/faker_maker/attribute_spec.rb +++ b/spec/faker_maker/attribute_spec.rb @@ -17,13 +17,17 @@ end it 'can reference an embedded factory' do + FakerMaker::Factory.new(:my_factory).then { |f| FakerMaker.register_factory(f) } + attr = FakerMaker::Attribute.new( :my_name, nil, factory: :my_factory ) - expect( attr.embedded_factories ).to eq [:my_factory] + expect( attr.embedded_factories ).to eq [FakerMaker[:my_factory]] end it 'can reference multiple embedded factories' do + FakerMaker::Factory.new(:my_factory).then { |f| FakerMaker.register_factory(f) } + FakerMaker::Factory.new(:my_other_factory).then { |f| FakerMaker.register_factory(f) } attr = FakerMaker::Attribute.new( :my_name, nil, factory: %i[my_factory my_other_factory] ) - expect( attr.embedded_factories ).to eq %i[my_factory my_other_factory] + expect( attr.embedded_factories ).to eq [FakerMaker[:my_factory], FakerMaker[:my_other_factory]] end it 'can have a JSON alias' do diff --git a/spec/faker_maker/factory_spec.rb b/spec/faker_maker/factory_spec.rb index 865ddff..4c519da 100644 --- a/spec/faker_maker/factory_spec.rb +++ b/spec/faker_maker/factory_spec.rb @@ -68,7 +68,7 @@ factory.attach_attribute( attr2 ) FakerMaker.register_factory( factory ) - sample = factory.build( second: 'overridden' ) + sample = factory.build( attributes: { second: 'overridden' } ) expect( sample.first ).to eq 'sample' expect( sample.second ).to eq 'overridden' end @@ -84,7 +84,7 @@ child_attributes.each { |a| child.attach_attribute( a ) } FakerMaker.register_factory( child ) - fake = child.build( author: 'Teresa Greene', title: 'A Title' ) + fake = child.build( attributes: { author: 'Teresa Greene', title: 'A Title' } ) expect( fake.author ).to eq 'Teresa Greene' expect( fake.title ).to eq 'A Title' end @@ -95,7 +95,7 @@ factory.attach_attribute( attr1 ) FakerMaker.register_factory( factory ) - sample = factory.build( first: nil ) + sample = factory.build( attributes: { first: nil } ) expect( sample.first ).to be nil end diff --git a/usefakermaker.com.site/site/src/docs/usage/building-instances/index.page.md b/usefakermaker.com.site/site/src/docs/usage/building-instances/index.page.md index a99ac70..1a226f8 100644 --- a/usefakermaker.com.site/site/src/docs/usage/building-instances/index.page.md +++ b/usefakermaker.com.site/site/src/docs/usage/building-instances/index.page.md @@ -10,10 +10,10 @@ result = FakerMaker[:basket].build will generate a new instance using the Basket factory. Because an actual class is defined (since v3.0.0 Classes generated by FakerMaker are in the `FakerMaker::Factory` namespace), you can instantiate an object directly through `Basket.new` but that will not populate any of the attributes. -It's possible to override attributes at build-time, either by passing values as a hash: +It's possible to override attributes at build-time, either by passing values as a hash (preferred): ```ruby -result = FakerMaker[:item].build( name: 'Electric Blanket' ) +result = FakerMaker[:item].build( attributes: { name: 'Electric Blanket' } ) ``` or by passing in a block: @@ -33,7 +33,7 @@ end if you're crazy enough to want to do both styles during creation, the values in the block will be preserved, e.g. ```ruby -result = FakerMaker[:item].build( name: 'Electric Blanket' ) do |i| +result = FakerMaker[:item].build( attributes: { name: 'Electric Blanket' } ) do |i| i.name = 'Electric Sheep' end ``` @@ -55,3 +55,5 @@ As another convenience, `FakerMaker` is also assigned to the variable `FM` to it ```ruby result = FM[:basket].build ``` + +**For more complex instance building with embedded factories, see [Embedding Factories](docs/usage/embedding-factories/).** \ No newline at end of file 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 b695db8..62d6790 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 @@ -28,11 +28,11 @@ FakerMaker.factory :item do end FakerMaker.factory :basket do - items( has: 10, factory: [:item, :discount] ) + items( has: 10, factory: [:item, :coupon] ) # either `item` or `coupon` will be randomly selected for each member end ``` -In this example, through 10 iterations, one of `item` and `discount` factories will be called to build their objects. +In this example, through 10 iterations, a random choice of `item` and `discount` factories will be called to build their objects. Blocks can still be provided and the referenced factory built object will be passed to the block: @@ -46,11 +46,48 @@ FakerMaker.factory :basket do items( has: 10, factory: :item ) { |item| item.price = 10.99 ; item} end ``` + +## Overriding values for nested factories in the enclosing factory + **Important:** the value for the attribute will be the value returned from the block. If you want to modify the contents of the referenced factory's object, don't forget to return it at the end of the block (as above). +## Overriding values for nested factories during build + +If we look carefully at this factory + +```ruby +FakerMaker.factory :inventory do + item( factory: :item ) + quantity { 10 } +end +``` + +This will build a object of the form (in its `as_json` guise): + +```ruby +{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: + +```ruby +FM[:inventory].build( attributes: { item: { name: 'floor cleaner' } } ) +``` + +When you allow Faker Maker to make a choice of factory by giving it an array: + +```ruby +FakerMaker.factory :inventory do + item( factory: [:item, :coupon] ) + quantity { 10 } +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. + ## Alternative method -There is an alternative style which might be of use: +There is an alternative style which might be of use, **but** you have less control using build-time overrides for values (you can't set nested values). *This is no longer a recommended pattern*. ```ruby FakerMaker.factory :item do diff --git a/usefakermaker.com/pages/about/index.md b/usefakermaker.com/pages/about/index.md new file mode 100644 index 0000000..6c71cbc --- /dev/null +++ b/usefakermaker.com/pages/about/index.md @@ -0,0 +1,20 @@ +--- +title: "About" +layout: single +permalink: /pages/about/ +author_profile: true +--- + +Faker Maker was designed to be a trivial way to create data factories that could throw JSON payloads at an API endpoint. It has grown well beyond that original purpose but still remains a thing for building things that give you data. + +It is much beloved by me. Although it's a personal project, it's used extensively by my employer and influenced by the needs of my colleagues. I hope it will be useful to you as well. I am very open to ideas, feedback and contributions. + +Faker Maker is licenced under the [MIT licence](https://raw.githubusercontent.com/BillyRuffian/faker_maker/refs/heads/master/LICENSE.txt). Do with it what you will and have fun. + +### What's the Billy Ruffian thing? + +HMS Bellerophon was a 74-gun third-rate ship of the line of the Royal Navy. Launched in 1786, she served during the French Revolutionary and Napoleonic Wars, mostly on blockades or convoy escort duties. She fought in three fleet actions: the Glorious First of June, the Battle of the Nile and the Battle of Trafalgar. She became famous as the ship upon which Napoleon surrendered and which transported him into exile in 1815. + +Her sailors, not being educated in the Classics, struggled to pronounce her name and so she became known as the Billy Ruffian and her crew as the "Billy Ruffians". The name stuck and was used as a nickname for the ship for the rest of her career. + +Since no one in coffee shops can spell my name, I adopted 'Billy' which turned into 'Billy Ruffian'. It's also a bloody good story.