Tip
You may be viewing documentation for an older (or newer) version of the gem than intended. Look at Changelog to see all versions, including unreleased changes.
ObjectForge provides a familiar way to build objects in any context with minimal assumptions about usage environment.
- It is not connected to any framework and, indeed, has nothing to do with a database.
- To use, just define some factories and call them wherever you need, be it in tests, console, or application code.
- If you need, almost any part of the process can be easily replaced with a custom solution.
- Motivation (why another another factory gem?)
- Installation
- Usage
- Differences and limitations (compared to FactoryBot)
- Current and planned features (roadmap)
- Development
- Contributing
- License
There are a bunch of gems that provide object generation functionality, chief among them FactoryBot and Fabrication. However, such gems make a lot of assumptions about why, how and what for they will be used, making them complicated and, at the same time, severely limited. Such assumptions commonly are:
- assuming that every Ruby project is a Rails project;
- assuming that "generating objects" equates "saving records to database";
- assuming that objects are mutable and provide attribute writers;
- assuming that streamlined object generation is only useful for testing;
- (related to the previous point) assuming that there will never be a need to have more than one configuration of a library in the same project;
- assuming that adding global methods or objects is a good idea.
I notice that there is also a problem of thinking that Rails's "convention-over-configuration" approach is always appropriate, but then making configuration convoluted, instead of making it easy for the user to do the things they want in the way they want in the first place.
There are some projects that tried to address these issues, like Progenitor (the closest to ObjectForge) and Workbench, but they still didn't manage to go around the pitfalls. Most factory projects are also quite dead, having not been updated in many years.
Install with gem
:
gem install object_forge
Or, if using Bundler, add to your Gemfile:
gem "object_forge"
Note
- Latest documentation from
main
branch is automatically deployed to GitHub Pages. - Documentation for published versions is available on RubyDoc.
In the simplest cases, ObjectForge can be used much like other factory libraries, with definitions living in a global object (ObjectForge::DEFAULT_YARD
).
Forges are defined using a DSL:
# Example class:
Point = Struct.new(:id, :x, :y)
ObjectForge.define(:point, Point) do |f|
# Attributes can be defined using `#attribute` method:
f.attribute(:x) do
# Inside attribute definitions, other attributes can be referenced by name, in any order!
rand(-delta..delta)
end
# `#[]` is an alias of `#attribute`:
f[:y] { rand(-delta..delta) }
# There is also the familiar shortcut using `method_missing`:
f.delta { 0.5 * amplitude }
# Notice how transient attributes don't require any special syntax:
f.amplitude { 1 }
# `#sequence` defines a sequenced attribute (starting with 1 by default):
f.sequence(:id, "a")
# Traits allow to group and reuse related values:
f.trait :z do
f.amplitude { 0 }
# Sequence values are forge-global, but traits can redefine blocks:
f.sequence(:id) { |id| "Z_#{id}" }
end
# Trait's block can receive DSL object as a parameter:
f.trait :invalid do |tf|
tf.y { Float::NAN }
# `#[]` method inside attribute definition can be used to reference attributes:
tf.id { self[:x] }
end
end
A forge builds objects, using attributes hash:
ObjectForge[:point]
# => #<Point:0x00007f6109dcad40 @id="a", @x=0.17176955469852973, @y=0.3423901951181103>
# Positional arguments define used traits:
ObjectForge.build(:point, :z)
# => #<Point:0x00007f61099e7980 @id="Z_b", @x=0.0, @y=0.0>
# Attributes can be overridden with keyword arguments:
ObjectForge.forge(:point, x: 10)
# => #<Point:0x00007f6109aabf88 @id="c", @x=10, @y=-0.3458802496120402>
# Traits and overrides are combined in the given order:
ObjectForge[:point, :z, :invalid, id: "NaN"]
# => #<Point:0x00007f6109b82e48 @id="NaN", @x=0.0, @y=NaN>
# A Proc override behaves the same as an attribute definition:
ObjectForge[:point, :z, x: -> { rand(100..200) + delta }]
# => #<Point:0x00007f6109932418 @id="Z_d", @x=135.0, @y=0.0>
# A block can be passed to do something with the created object:
ObjectForge[:point, :z] { puts "#{_1.id}: #{_1.x},#{_1.y}" }
# outputs "Z_e: 0.0,0.0"
Tip
Forging can be done through any of #[]
, #forge
, or #build
methods, they are aliases.
It is possible and encouraged to create multiple forgeyards, each with its own set of forges:
forgeyard = ObjectForge::Forgeyard.new
forgeyard.define(:point, Point) do |f|
f.sequence(:id, "a")
f.x { rand(-radius..radius) }
f.y { rand(-radius..radius) }
f.radius { 0.5 }
f.trait :z do f.radius { 0 } end
end
Now, this forgeyard can be used just like the default one:
forgeyard[:point, :z, id: "0"]
# => #<Point:0x00007f6109b719e0 @id="0", @x=0, @y=0>
Note how the forge isn't registered in the default forgeyard:
ObjectForge[:point]
# ArgumentError: unknown forge: point
If you find it more convenient not to use a forgeyard (for example, if you only need a single forge for your service), you can create individual forges:
forge = ObjectForge::Forge.define(Point) do |f|
f.sequence(:id, "a")
f.x { rand(-radius..radius) }
f.y { rand(-radius..radius) }
f.radius { 0.5 }
f.trait :z do f.radius { 0 } end
end
forge[:z, id: "0"]
# => #<Point:0x00007f6109b719e0 @id="0", @x=0, @y=0>
Forge has the same building interface as a Forgeyard, but it doesn't have the name argument:
forge.build
# => #<Point:0x00007f610deae578 @id="a", @x=0.3317733939650964, @y=-0.1363936629550252>
forge.forge(:z)
# => #<Point:0x00007f61099f6520 @id="b", @x=0, @y=0>
forge[radius: 500]
# => #<Point:0x00007f6109960048 @id="c", @x=-141, @y=109>
If you use core Ruby data containers, such as Struct
, Data
or even Hash
, they will "just work". However, if a custom class is used, forging will probably fail, unless your class happens to take a hash of attributes in #initialize
. It would be really bad if ObjectForge placed requirements on your classes, and indeed there is a solution.
Whenever you need to change how your objects are built, you specify a mold. Molds are just #call
able objects (including Proc
s!) with specific arguments. They are specified in forge definition:
forge = ObjectForge::Forge.define(Point) do |f|
f.mold = ->(forged:, attributes:, **) do
puts "Pointing at #{attributes[:x]},#{attributes[:y]}"
forged.new(attributes[:id], attributes[:x], attributes[:y])
end
#... rest of the definition
end
Now the specified mold will be called to build your objects:
forge.forge
# Pointing at 0.3317733939650964,-0.1363936629550252
# => #<Point:0x00007f610deae578 @id="a", @x=0.3317733939650964, @y=-0.1363936629550252>
Of course, you can abuse this to your heart's content. Look at the documentation for ObjectForge::Molds
for inspiration.
ObjectForge comes pre-equipped with a selection of molds for common cases:
ObjectForge::Molds::SingleArgumentMold
(the default) — callsnew(attributes)
, suitable for Dry::Struct, for example;ObjectForge::Molds::KeywordsMold
— callsnew(**attributes)
, suitable for Data and similar classes;ObjectForge::Molds::HashMold
allows building Hash (including subclasses), providing a way to easily use hashes to carry data;ObjectForge::Molds::StructMold
handles all possible cases ofkeyword_init
for Struct subclasses.
Tip
HashMold and StructMold will be used automatically, based on the forged class, if you don't specify any mold.
There is an additional special mold: ObjectForge::Molds::WrappedMold
allows to use classes by building a new instance and calling it every time. DSL handles this case for you, auto-wrapping the class:
forge = ObjectForge::Forge.define(Point) do |f|
f.mold = MyPointBuilder
# ...
end
forge.parameters.mold
# => #<ObjectForge::Molds::WrappedMold:0x00007fd1079774f0 @wrapped_mold=MyPointBuilder>
Note
I strongly recommend directly using mold instances and not classes. Doing that prevents memory churn which leads to performance issues. Not only that, but having a stateful mold is a code smell and probably represents a significant design issue.
ObjectForge is pretty fast for what it is. However, if you are worried, there are certain things that can be done to make it faster.
- The easiest thing is to enable YJIT. It will probably speed up your whole application, but be aware that it is not always suitable and may even degrade performance on some workloads. It is considered production-ready though.
- Calling Forge directly, instead of through Forgeyard, is faster due to not needing argument forwarding. This is consistent (but check on your system anyway!).
- Using
self[:name]
instead of plainname
inside attribute definitions does not engage dynamic method dispatch, which should be faster. However, micro-benchmarking does not show conclusive results.
If you are used to FactoryBot, be aware that there are quite a few differences in specifics.
General:
- The user (you) is responsible for loading forge definitions, there are no search paths. If ObjectForge is used in tests, it should be enough to add something like
Dir["spec/forges/**/*.rb].each { require _1 }
to yourspec_helper.rb
(orrails_helper.rb
). Forgeyard.define
is the forge definition block, you don't need to nest it inside anotherfactory
block.- There is no forge inheritance or nesting, though it may be added in the future.
Forge definition:
- Class specification for a forge is non-optional, there is no assumption about the class name.
- If DSL block declares a block argument,
self
context is not changed, so DSL methods can't be called with an implicit receiver.
Attributes:
- For now, transient attributes have no difference to regular ones, they just aren't set in the final object.
- There are no associations. If nested objects are required, they should be created and set in the block for the attribute.
Traits:
- Traits can't be defined inside of other traits. (I feel that nesting is needlessly confusing.)
- Traits can't be called from other traits. This may change in the future.
- There are no default traits.
Sequences:
- There is no way to define shared sequences, unless you pass the same object yourself to multiple
sequence
calls. - Sequences work with values implementing
#succ
, not#next
, expressly prohibitingEnumerator
. This may be relaxed in the future.
kanban
[✅ Done]
[FactoryBot-like DSL: attributes, traits, sequences]
[Independent forges]
[Independent forgeyards]
[Default global forgeyard]
[Thread-safe behavior]
[Tapping into built objects for post-processing]
[Custom builders / molds]
[Built-in Hash, Struct, Data builders / molds]
[⚗️ To do]
[Ability to replace resolver]
[After-build hook]
[❔Under consideration]
[Calling traits from traits]
[Default traits]
[Forge inheritance]
[Premade performance forge: static DSL, epsilon resolver]
[Enumerator compatibility in sequences]
After checking out the repo, run bundle install
to install dependencies. Then, run rake spec
to run the tests, rake rubocop
to lint code and check style compliance, rake rbs
to validate signatures or just rake
to do everything above. There is also rake steep
to check typing, and rake docs
to generate YARD documentation.
You can also run bin/console
for an interactive prompt that will allow you to experiment, or bin/benchmark
to run a benchmark script and generate a StackProf flamegraph.
To install this gem onto your local machine, run rake install
. To release a new version, run rake version:{major|minor|patch}
, and then run rake release
, which will push git commits and the created tag, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/trinistr/object_forge.
- Tests cover the behavior and its interactions.
- Running
rspec
reports 100% coverage (unless it's impossible to achieve in one run). - Running
rubocop
reports no offenses. - Running
rake steep
reports no new warnings or errors. - Documentation is up-to-date: generate it with
rake docs
and read it. CHANGELOG.md
lists the change if it has impact on users.README.md
is updated if the feature should be visible there, including the Kanban board.
The gem is available as open source under the terms of the MIT License, see LICENSE.txt.