Skip to content

trinistr/object_forge

Repository files navigation

ObjectForge

Gem Version CI

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.

Table of contents

Motivation (why another another factory gem?)

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.

Installation

Install with gem:

gem install object_forge

Or, if using Bundler, add to your Gemfile:

gem "object_forge"

Usage

Note

  • Latest documentation from main branch is automatically deployed to GitHub Pages.
  • Documentation for published versions is available on RubyDoc.

Basics

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.

Separate forgeyards and forges

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>

Molds: customized forging

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 #callable objects (including Procs!) 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) — calls new(attributes), suitable for Dry::Struct, for example;
  • ObjectForge::Molds::KeywordsMold — calls new(**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 of keyword_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.

Performance tips

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 plain name inside attribute definitions does not engage dynamic method dispatch, which should be faster. However, micro-benchmarking does not show conclusive results.

Differences and limitations (compared to FactoryBot)

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 your spec_helper.rb (or rails_helper.rb).
  • Forgeyard.define is the forge definition block, you don't need to nest it inside another factory 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 prohibiting Enumerator. This may be relaxed in the future.

Current and planned features (roadmap)

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]
Loading

Development

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.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/trinistr/object_forge.

Checklist for a new or updated feature:

  • 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.

License

The gem is available as open source under the terms of the MIT License, see LICENSE.txt.

About

A simple factory for objects with minimal assumptions.

Resources

License

Stars

Watchers

Forks

Languages