Skip to content

Commit

Permalink
MONGOID-5734 Custom polymorphic types (#5845)
Browse files Browse the repository at this point in the history
* first pass at a global resolver registry

* tests

* fix problem with interpreting nested attribute data

* need to register subclasses, too

* raise custom exceptions when failing to resolve models

* fix specs to implement functional around(:context)

* trailing white space
  • Loading branch information
jamis committed Jul 31, 2024
1 parent 8e2b57b commit 607a199
Show file tree
Hide file tree
Showing 25 changed files with 791 additions and 42 deletions.
16 changes: 16 additions & 0 deletions lib/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,22 @@ en:
resolution: "The _type field is a reserved one used by Mongoid to determine the
class for instantiating an object. Please don't save data in this field or ensure
that any values in this field correspond to valid models."
unrecognized_model_alias:
message: "Cannot find any model with type %{model_alias}"
summary: "A document is trying to load a polymorphic association, but the data refers to a type of object that can't be resolved (%{model_alias}). It might be that you've renamed the target class."
resolution: "Register the old name as an alias on the refactored target object, using `identify_as`. This will allow Mongoid to find the target type even if the name no longer matches what was stored in the database."
unrecognized_resolver:
message: "The model resolver %{resolver} was referenced, but never registered."
summary: "A polymorphic association has been configured to use a resolver
named %{resolver}, but that resolver has not yet been registered. This
might be a typo. Currently registered resolvers are: %{resolvers}."
resolution: "Register custom resolvers with
`Mongoid::ModelResolver.register_resolver` before attempting to query
a polymorphic association."
unregistered_class:
message: "The class %{klass} is not registered with the resolver %{resolver}."
summary: "A polymorphic association using the resolver %{resolver} has tried to link to a model of type %{klass}, but the resolver has no knowledge of any such model. This can happen if the association is configured to use a different resolver than the target mode."
resolution: "Make sure the target model is registered with the same resolver as the polymorphic association, using `identify_as`."
unsaved_document:
message: "Attempted to save %{document} before the parent %{base}."
summary: "You cannot call create or create! through the
Expand Down
3 changes: 2 additions & 1 deletion lib/mongoid/association/accessors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ def __build__(name, object, association, selected_fields = nil)
#
# @return [ Proxy ] The association.
def create_relation(object, association, selected_fields = nil)
type = @attributes[association.inverse_type]
key = @attributes[association.inverse_type]
type = key ? association.resolver.model_for(key) : nil
target = if t = association.build(self, object, type, selected_fields)
association.create_relation(self, t)
else
Expand Down
15 changes: 14 additions & 1 deletion lib/mongoid/association/nested/one.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,25 @@ def initialize(association, attributes, options)
@attributes = attributes.with_indifferent_access
@association = association
@options = options
@class_name = options[:class_name] ? options[:class_name].constantize : association.klass
@class_name = class_from(options[:class_name])
@destroy = @attributes.delete(:_destroy)
end

private

# Coerces the argument into a class, or defaults to the association's class.
#
# @param [ String | Mongoid::Document | nil ] name_or_class the value to coerce
#
# @return [ Mongoid::Document ] the resulting class
def class_from(name_or_class)
case name_or_class
when nil, false then association.klass
when String then name_or_class.constantize
else name_or_class
end
end

# Extracts and converts the id to the expected type.
#
# @return [ BSON::ObjectId | String | Object | nil ] The converted id,
Expand Down
15 changes: 15 additions & 0 deletions lib/mongoid/association/referenced/belongs_to.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ def polymorphic?
@polymorphic ||= !!@options[:polymorphic]
end

# Returns the object responsible for converting polymorphic type references into
# class objects, and vice versa. This is obtained via the `:polymorphic` option
# that was given when the association was defined.
#
# See Mongoid::ModelResolver.resolver for how the `:polymorphic` option is
# interpreted here.
#
# @raise KeyError if no such resolver has been registered under the given
# identifier.
#
# @return [ nil | Mongoid::ModelResolver ] the resolver to use
def resolver
@resolver ||= Mongoid::ModelResolver.resolver(@options[:polymorphic])
end

# The name of the field used to store the type of polymorphic association.
#
# @return [ String ] The field used to store the type of polymorphic association.
Expand Down
8 changes: 7 additions & 1 deletion lib/mongoid/association/referenced/belongs_to/binding.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ def bind_one
binding do
check_polymorphic_inverses!(_target)
bind_foreign_key(_base, record_id(_target))
bind_polymorphic_inverse_type(_base, _target.class.name)

# set the inverse type (e.g. "#{name}_type") for new polymorphic associations
if _association.inverse_type && !_base.frozen?
key = _association.resolver.default_key_for(_target)
bind_polymorphic_inverse_type(_base, key)
end

if inverse = _association.inverse(_target)
if set_base_association
if _base.referenced_many?
Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/association/referenced/belongs_to/buildable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def execute_query(object, type)
end

def query_criteria(object, type)
cls = type ? type.constantize : relation_class
cls = type ? (type.is_a?(String) ? type.constantize : type) : relation_class
crit = cls.criteria
crit = crit.apply_scope(scope)
crit.where(primary_key => object)
Expand Down
17 changes: 9 additions & 8 deletions lib/mongoid/association/referenced/has_many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require 'mongoid/association/referenced/has_many/proxy'
require 'mongoid/association/referenced/has_many/enumerable'
require 'mongoid/association/referenced/has_many/eager'
require 'mongoid/association/referenced/with_polymorphic_criteria'

module Mongoid
module Association
Expand All @@ -15,6 +16,7 @@ module Referenced
class HasMany
include Relatable
include Buildable
include WithPolymorphicCriteria

# The options available for this type of association, in addition to the
# common ones.
Expand Down Expand Up @@ -131,13 +133,20 @@ def type
# @param [ Class ] object_class The object class.
#
# @return [ Mongoid::Criteria ] The criteria object.
#
# @deprecated in 9.0.x
#
# It appears as if this method is an artifact left over from a refactoring that renamed it
# `with_polymorphic_criterion`, and made it private. Regardless, this method isn't referenced
# anywhere else, and is unlikely to be useful to external clients. We should remove it.
def add_polymorphic_criterion(criteria, object_class)
if polymorphic?
criteria.where(type => object_class.name)
else
criteria
end
end
Mongoid.deprecate(self, :add_polymorphic_criterion)

# Is this association polymorphic?
#
Expand Down Expand Up @@ -222,14 +231,6 @@ def query_criteria(object, base)
with_ordering(crit)
end

def with_polymorphic_criterion(criteria, base)
if polymorphic?
criteria.where(type => base.class.name)
else
criteria
end
end

def with_ordering(criteria)
if order
criteria.order_by(order)
Expand Down
11 changes: 3 additions & 8 deletions lib/mongoid/association/referenced/has_one/buildable.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# frozen_string_literal: true
# rubocop:todo all

require 'mongoid/association/referenced/with_polymorphic_criteria'

module Mongoid
module Association
module Referenced
class HasOne

# The Builder behavior for has_one associations.
module Buildable
include WithPolymorphicCriteria

# This method either takes an _id or an object and queries for the
# inverse side using the id or sets the object after clearing the
Expand Down Expand Up @@ -57,14 +60,6 @@ def execute_query(object, base)
query_criteria(object, base).take
end

def with_polymorphic_criterion(criteria, base)
if polymorphic?
criteria.where(type => base.class.name)
else
criteria
end
end

def query?(object)
object && !object.is_a?(Mongoid::Document)
end
Expand Down
41 changes: 41 additions & 0 deletions lib/mongoid/association/referenced/with_polymorphic_criteria.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Mongoid
module Association
module Referenced
# Implements the `with_polymorphic_criteria` shared behavior.
#
# @api private
module WithPolymorphicCriteria
# If the receiver represents a polymorphic association, applies
# the polymorphic search criteria to the given `criteria` object.
#
# @param [ Mongoid::Criteria ] criteria the criteria to append to
# if receiver is polymorphic.
# @param [ Mongoid::Document ] base the document to use when resolving
# the polymorphic type keys.
#
# @return [ Mongoid::Criteria] the resulting criteria, which may be
# the same as the input.
def with_polymorphic_criterion(criteria, base)
if polymorphic?
# 1. get the resolver for the inverse association
resolver = klass.reflect_on_association(as).resolver

# 2. look up the list of keys from the resolver, given base
keys = resolver.keys_for(base)

# 3. use equality if there is just one key, `in` if there are multiple
if keys.many?
criteria.where(type => { :$in => keys })
else
criteria.where(type => keys.first)
end
else
criteria
end
end
end
end
end
end
3 changes: 2 additions & 1 deletion lib/mongoid/attributes/nested.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ def accepts_nested_attributes_for(*args)
re_define_method(meth) do |attrs|
_assigning do
if association.polymorphic? and association.inverse_type
options = options.merge!(:class_name => self.send(association.inverse_type))
klass = association.resolver.model_for(send(association.inverse_type))
options = options.merge!(:class_name => klass)
end
association.nested_builder(attrs, options).build(self)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/mongoid/composable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "mongoid/collection_configurable"
require "mongoid/encryptable"
require "mongoid/findable"
require 'mongoid/identifiable'
require "mongoid/indexable"
require "mongoid/inspectable"
require "mongoid/interceptable"
Expand Down Expand Up @@ -44,6 +45,7 @@ module Composable
include Attributes
include Evolvable
include Fields
include Identifiable
include Indexable
include Inspectable
include Matchable
Expand Down
2 changes: 2 additions & 0 deletions lib/mongoid/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require 'mongoid/association'
require 'mongoid/composable'
require 'mongoid/touchable'
require 'mongoid/model_resolver'

module Mongoid
# This is the base module for all domain objects that need to be persisted to
Expand All @@ -31,6 +32,7 @@ module Document

included do
Mongoid.register_model(self)
Mongoid::ModelResolver.register(self)
end

# Regex for matching illegal BSON keys.
Expand Down
3 changes: 3 additions & 0 deletions lib/mongoid/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@
require "mongoid/errors/transactions_not_supported"
require "mongoid/errors/unknown_attribute"
require "mongoid/errors/unknown_model"
require 'mongoid/errors/unrecognized_model_alias'
require 'mongoid/errors/unrecognized_resolver'
require 'mongoid/errors/unregistered_class'
require "mongoid/errors/unsaved_document"
require "mongoid/errors/unsupported_javascript"
require "mongoid/errors/validations"
Expand Down
53 changes: 53 additions & 0 deletions lib/mongoid/errors/unrecognized_model_alias.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module Mongoid
module Errors
# Raised when a polymorphic association is queried, but the type of the
# association cannot be resolved. This usually happens when the data in
# the database references a type that no longer exists.
#
# For example, consider the following model:
#
# class Manager
# include Mongoid::Document
# belongs_to :unit, polymorphic: true
# end
#
# Imagine there is a document in the `managers` collection that looks
# something like this:
#
# { _id: ..., unit_id: ..., unit_type: 'Department::Engineering' }
#
# If, at some point in your refactoring, you rename the `Department::Engineering`
# model to something else, Mongoid will no longer be able to resolve the
# type of this association, and asking for `manager.unit` will raise this
# exception.
#
# To fix this exception, you can add an alias to the model class so that it
# can still be found, even after renaming it:
#
# module Engineering
# class Department
# include Mongoid::Document
#
# identify_as 'Department::Engineering'
#
# # ...
# end
# end
#
# Better practice would be to use unique strings instead of class names to
# identify these polymorphic types in the database (e.g. 'dept' instead of
# 'Department::Engineering').
class UnrecognizedModelAlias < MongoidError
def initialize(model_alias)
super(
compose_message(
'unrecognized_model_alias',
model_alias: model_alias.inspect
)
)
end
end
end
end
27 changes: 27 additions & 0 deletions lib/mongoid/errors/unrecognized_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Mongoid
module Errors
# Raised when a model resolver is referenced, but not registered.
#
# class Manager
# include Mongoid::Document
# belongs_to :unit, polymorphic: :org
# end
#
# If `:org` has not previously been registered as a model resolver,
# Mongoid will raise UnrecognizedResolver when it tries to resolve
# a manager's unit.
class UnrecognizedResolver < MongoidError
def initialize(resolver)
super(
compose_message(
'unrecognized_resolver',
resolver: resolver.inspect,
resolvers: [ :default, *Mongoid::ModelResolver.resolvers.keys ].inspect
)
)
end
end
end
end
Loading

0 comments on commit 607a199

Please sign in to comment.