Skip to content

New :fallback option to handle nil for singular associations#6149

Open
cperezabo wants to merge 7 commits into
mongodb:masterfrom
cperezabo:null-object
Open

New :fallback option to handle nil for singular associations#6149
cperezabo wants to merge 7 commits into
mongodb:masterfrom
cperezabo:null-object

Conversation

@cperezabo
Copy link
Copy Markdown
Contributor

Hi folks, this PR adds a :fallback option to belongs_to, has_one, and embeds_one.

You hand it a Proc, and whenever the association would be nil, Mongoid returns whatever the Proc gives back (a null object).

class Anonymous
  def attribution
    "Composer unknown"
  end
end

class Composer
  include Mongoid::Document
  field :name

  def attribution
    "Composed by #{name}"
  end
end

class Symphony
  include Mongoid::Document
  belongs_to :composer, fallback: -> { Anonymous.new }
end

Symphony.create!.composer.attribution
# => "Composer unknown"

Symphony.create!(composer: Composer.create!(name: "Mahler")).composer.attribution
# => "Composed by Mahler"

Works the same on has_one and embeds_one.

Things worth knowing:

  • Proc runs on every access — fresh instance each time. If you want identity, memoize in the Proc.
  • Null object never persists. Anything you assign that isn't an instance of the association's class (or a subclass) becomes nil — whether via setter or constructor.
  • A real document always wins — if the FK is set, the Proc never runs.
  • Can't combine with :autobuild (they want opposite things). Raises InvalidRelationOption.

@cperezabo cperezabo requested a review from a team as a code owner May 18, 2026 16:50
@cperezabo cperezabo requested a review from jamis May 18, 2026 16:50
Introduces a :fallback option on belongs_to, has_one, and embeds_one
that takes a Proc invoked whenever the association resolves to nil,
returning a null object stand-in. Direct assignment of an instance
that does not match the association's class is treated as nil so
the null object never reaches the database.
@davidfabbretti
Copy link
Copy Markdown

Wow! Nice feature! 🚀

Copy link
Copy Markdown
Contributor

@jamis jamis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cperezabo thank you for this PR -- it looks like a convenient feature.

However, there are some subtle potential side-effects that I'm a bit worried about:

  • Associations with dependent: set may cause problems. If you look at any of the _dependent_* helper methods (in association/depending.rb, you'll see they call send(association.name) with no without_autobuild wrapper. This leaves them vulnerable to returning the fallback object, if one is defined---which could result in NoMethodError being raised when destroy or nullify (etc.) is called on that fallback.
  • Relying on association.relation_class is a bit fraught. If you look at the comment in Relatable#relation_class, you'll see calling this method on a polymorphic association will generally fail with a NameError or produce misleading results. Polymorphic associations may need to be treated specially in the guard you added in define_setter!.
  • Nested attributes (specifically in association/nested/one.rb) call parent.send(association.name), which may return the fallback object. Looking in the build method in that file, there are multiple opportunities for NoMethodError exceptions to be raised when processing a nested attribute update.

Comment thread lib/mongoid/association/relatable.rb Outdated
end

if @options.key?(:autobuild)
raise Errors::InvalidRelationOption.new(@owner_class, name, :fallback, self.class::VALID_OPTIONS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvalidRelationOption is mostly intended for reporting an option that is not recognized. It might be better to simply raise ArgumentError here? Because the message with this exception won't say anything about :autobuild being invalid when :fallback is specified, which is the actual issue here.

cperezabo added 6 commits May 29, 2026 18:08
The _dependent_*! helpers read the association with a bare send(name),
so an association with a :fallback returned the null object instead of
nil when no real document existed. Destroying the owner then raised
NoMethodError (destroy/delete_all/nullify) or a false DeleteRestriction
and aborted the destroy (restrict_with_*).

Read through without_autobuild in all five helpers so the cascade sees
the real value (nil when absent), matching how the rest of Mongoid's
internals read associations.
The association setter guarded the :fallback null object with
object.is_a?(relation_class). On a polymorphic belongs_to, relation_class
cannot resolve a target class from the association name and raised
NameError on assignment.

Check against Mongoid::Document for polymorphic associations (any
document is a valid target) and keep the relation_class check for the
rest. A non-document assignment such as the null object is treated as
nil so it never reaches the database.
Association::Nested::One#build reads the existing association with
parent.send(name) to decide whether to update, replace, or delete the
nested document. With a :fallback that returned the null object instead
of nil, so build never took the replace branch and called document
methods (_id, using_object_ids?, ...) on the null object, raising
NoMethodError.

When the association has a :fallback, read the real value through
without_autobuild so build sees nil and builds the nested document.
Autobuilding associations are left untouched.
Combining :fallback with :autobuild previously raised
InvalidRelationOption, whose message lists the valid options and does not
explain that the real problem is the combination. Raise ArgumentError
with a message naming both options instead, matching the ArgumentError
already raised for a non-callable :fallback.
serializable_hash serializes included associations through
serialize_relations, which read the association with a bare send(name)
outside the without_autobuild block that already wraps field
serialization. With a :fallback that returned the null object, and
serializable_hash(include: ...) then called serializable_hash on it,
raising NoMethodError.

When the association has a :fallback, read the value through
without_autobuild so a nil association is omitted from the output.
Autobuilding associations are left untouched.
The counter cache after_create/after_update/before_destroy callbacks read
the parent with __send__(name) to adjust its counter. With a :fallback
that returned the null object when no parent was set, and the callbacks
then indexed into it (record[cache_column]), raising NoMethodError on
create, update, or destroy.

Read through without_autobuild so the callbacks operate on the real
parent and no-op when there is none, matching how the cascade helpers in
Depending read associations.
@cperezabo
Copy link
Copy Markdown
Contributor Author

cperezabo commented May 29, 2026

Hi @jamis, I've addressed each of your points. I also scanned the repository with Claude to look for any other spots with the same problem, and it found two — I've added tests for those as well. There's a commit per point so they're easy to review. You can squash the branch or leave it as is; I don't mind either way!

Just to clarify, I always follow the TDD approach, so the tests were added one by one, along with the production code.

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.

3 participants