Skip to content

Evaluate attributes with a single Evaluator - #1852

Draft
neilvcarvalho wants to merge 1 commit into
nc/compiled-factoryfrom
nc/single-evaluator
Draft

neilvcarvalho wants to merge 1 commit into
nc/compiled-factoryfrom
nc/single-evaluator

Conversation

@neilvcarvalho

@neilvcarvalho neilvcarvalho commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Fourth step of the sequence of refactors toward a compiled, non-cloning core. Stacked on #1851 (step 3), #1848 (step 2) and #1847 (step 1); merge those first.

What changed

Each factory used to define an anonymous Evaluator subclass per snapshot, inheriting from the parent's, with one method per attribute and a singleton method per override, so that Ruby method lookup performed attribute inheritance.

  • CompiledFactory now carries an ordered Hash of attributes instead of an evaluator class. Factory#attributes_over merges the parent's Hash, the factory's own definition and the requested traits. A later definition wins but keeps the first definition's position, which reproduces the assignment order of the class chain, and a name declared transient anywhere stays transient, which the suite pins in spec/acceptance/transient_attributes_spec.rb ("redefining a transient attribute without the transient block"). Attribute#as_transient produces the copy used for that case, alongside the transient reader that Refactor remaining internal ignored state to transient terminology #1834 renamed from ignored.
  • Evaluator is a single BasicObject over that Hash. method_missing looks up the memo, then the attributes, then the instance, then SyntaxRunner. It is a BasicObject so attribute names such as hash, display, method, format and system are not shadowed by Object; inside the class, constants are written as ::FactoryBot::... and ::Kernel.raise. respond_to?, send, public_send, nil? and inspect are defined explicitly since callbacks receive the evaluator.
  • A hash-mode flag replaces NullObject: under attributes_for there is no instance, and a method the build class defines answers nil, exactly what the null object did.
  • The memo is a copy of the overrides instead of the same Hash. Before, an attribute evaluated by a before callback leaked into __override_names__ and into the alias logic.
  • The initialize_with helpers new and attributes still shadow attributes of the same name only while constructing, now as plain methods that fall through to method_missing otherwise.
  • AttributeAssigner takes the attribute Hash instead of reading evaluator.class.attribute_list.

Deleted

  • lib/factory_bot/evaluator_class_definer.rb, lib/factory_bot/null_object.rb, their requires and their specs.
  • Evaluator.define_attribute, Evaluator.attribute_list, the attribute_lists and attribute_blocks class attributes, the Construction prepend module, the per-override singleton methods, and AttributeAssigner#build_hash and #hash_instance_methods_to_respond_to.

Acceptance edit

One acceptance example changes, as the plan notes: spec/acceptance/callbacks_spec.rb read the strategy through context.instance_values["build_strategy"], which a BasicObject does not have. It now reads context.instance_eval { @build_strategy }. Nothing else in the acceptance suite or the features changed.

Behavior

Beyond the suite, I compared this branch with main on attribute names that collide with Object methods under build and attributes_for, blocks referencing build class methods under both strategies, an unknown method under attributes_for, send, respond_to? and nil? on the evaluator inside a callback, and the initialize_with edge cases from step 2. All match. One visible difference, which is the aliasing fix above: a factory declaring both user and user_id whose before(:build) callback reads evaluator.user now assigns user_id; on main the read made user look like an override and user_id was skipped.

Verification

All on Ruby 4.0.1.

  • bundle exec standardrb: no offenses.
  • bundle exec rake all_specs (default Gemfile): 324 unit examples, 445 acceptance examples, 4 Cucumber scenarios, all passing.
  • BUNDLE_GEMFILE=gemfiles/7.0.gemfile bundle exec rake all_specs: same counts, all passing.
  • BUNDLE_GEMFILE=gemfiles/main.gemfile bundle exec rake all_specs: same counts, all passing.

Each factory defined an anonymous Evaluator subclass per snapshot,
inheriting from the parent's, with one method per attribute and a
singleton method per override, so that Ruby method lookup performed
attribute inheritance. That needed EvaluatorClassDefiner, the
class_attribute of attribute lists, the undef dance in
define_attribute, and the attribute block registry. Under
attributes_for a NullObject stood in for the instance so that blocks
referencing build class methods would answer nil.

The snapshot now carries an ordered Hash of attributes, merged from the
parent's snapshot, the factory's own definition and the requested
traits. A later definition wins but keeps the first definition's
position, and a name declared transient anywhere stays transient, which
reproduces the assignment order and transient handling of the class
chain. Evaluator becomes a single BasicObject over that Hash:
method_missing looks up the memo, then the attributes, then the
instance, then SyntaxRunner. A hash mode flag replaces NullObject: with
no instance, a method the build class defines answers nil. The memo is
a copy of the overrides instead of the same Hash, so attributes
evaluated before assignment no longer count as overrides in the alias
logic. The initialize_with helpers new and attributes shadow attributes
of the same name only while constructing, as before.

Deletes evaluator_class_definer.rb and null_object.rb with their specs.
The one acceptance example that inspected the evaluator through
instance_values now reads the strategy with instance_eval, as the plan
notes, since a BasicObject has no instance_values.

Behavior is unchanged for every other acceptance example.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant