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
2 changes: 1 addition & 1 deletion .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
/tmp/

Gemfile.lock

/hack/
TODO.md

# rspec failure tracking
.rspec_status

Expand Down
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,5 @@ Metrics/PerceivedComplexity:
Max: 10

AllCops:
TargetRubyVersion: 4.0
NewCops: enable
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ gemspec
gem 'bundler-audit', '~> 0.9.2'
gem 'irb', '~> 1.15'
gem 'rdoc', '~> 6.13'

gem 'awesome_print', '~> 1.9'
2 changes: 1 addition & 1 deletion faker_maker.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 10 additions & 1 deletion lib/faker_maker/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/faker_maker/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/faker_maker/definition_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
156 changes: 122 additions & 34 deletions lib/faker_maker/factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 )
Expand All @@ -34,6 +53,7 @@ def parent_class
end
end

# Attach a FakerMaker::Attribute to this Factory
def attach_attribute( attribute )
@attributes << attribute
end
Expand All @@ -42,28 +62,31 @@ 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

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
Expand Down Expand Up @@ -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
Expand All @@ -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 )
Expand All @@ -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?
Expand All @@ -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
Expand Down Expand Up @@ -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
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 = '3.0.0'
VERSION = '4.0.0'
end
8 changes: 6 additions & 2 deletions spec/faker_maker/attribute_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading