Skip to content

MONGOID-5128 Scoped associations #5017

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 23, 2021
55 changes: 55 additions & 0 deletions docs/reference/associations.txt
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,61 @@ the model. However, since Mongoid doesn't know what type of data should be
allowed in the field, the field is created with a type of Object. It is a
good idea to explicitly define the field with the appropriate type.


.. _association-scope:

Custom Scopes
-------------

You may set a specific scope on an association using the ``:scope`` parameter.
The scope is an additional filter that restricts which objects are considered
to be a part of the association - a scoped association will return only
documents which satisfy the scope condition.. The scope may be either:

- a ``Proc`` with arity zero, or
- a ``Symbol`` which references a :ref:`named scope <named-scopes>` on the
associated model.

.. code-block:: ruby
class Trainer
has_many :pets, scope: -> { where(species: 'dog') }
has_many :toys, scope: :rubber
end

class Pet
belongs_to :trainer
end

class Toy
scope :rubber, where(material: 'rubber')
belongs_to :trainer
end

.. note::

It is possible to add documents that do not satisfy an association's scope
to that association. In this case, such documents will appear associated
in memory, and will be saved to the database, but will not be present when
the association is queried in the future. For example:

.. code-block:: ruby

trainer = Trainer.create!
dog = Pet.create!(trainer: trainer, species: 'dog')
cat = Pet.create!(trainer: trainer, species: 'cat')

trainer.pets #=> [dog, cat]

trainer.reload.pets #=> [dog]

.. note::

Mongoid's syntax for scoped association differs from that of Active Record.
Mongoid uses the ``:scope`` keyword argument for consistency with other
association options, whereas in Active Record the scope is a positional
argument.


Validations
-----------

Expand Down
7 changes: 6 additions & 1 deletion docs/reference/queries.txt
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,8 @@ as its ``id`` alias) cannot be omitted via ``without``:
# embedded: false>


.. _ordering:

Ordering
========

Expand Down Expand Up @@ -1032,7 +1034,7 @@ to be combined with :ref:`ordering <ordering>` to ensure consistent results.
.. _batch-size:

``batch_size``
--------
--------------

When executing large queries, or when iterating over query results with an enumerator method such as
``Criteria#each``, Mongoid automatically uses the `MongoDB getMore command
Expand Down Expand Up @@ -1829,6 +1831,9 @@ Scoping
Scopes provide a convenient way to reuse common criteria with more
business domain style syntax.


.. _named-scopes:

Named Scopes
------------

Expand Down
7 changes: 7 additions & 0 deletions docs/release-notes/mongoid-7.4.txt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ Mongoid 7.4 and later will inherit the new implementation provided by
implementation returning a hash of ``{"$oid" => "..."}``.


Scoped Associations
-------------------

Associations now support the ``:scope`` argument, yielding
:ref:`scoped associations <association-scope>`.


``distinct`` and ``pluck`` Respect Field Aliases In Embedded Documents
----------------------------------------------------------------------

Expand Down
10 changes: 9 additions & 1 deletion lib/mongoid/association/referenced/belongs_to.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class BelongsTo
:primary_key,
:touch,
:optional,
:required
:required,
:scope,
].freeze

# The complete list of valid options for this association, including
Expand Down Expand Up @@ -130,6 +131,13 @@ def path(document)
Mongoid::Atomic::Paths::Root.new(document)
end

# Get the scope to be applied when querying the association.
#
# @return [ Proc | Symbol | nil ] The association scope, if any.
def scope
@options[:scope]
end

private

def setup_instance_methods!
Expand Down
6 changes: 4 additions & 2 deletions lib/mongoid/association/referenced/belongs_to/buildable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ def execute_query(object, type)
end

def query_criteria(object, type)
model = type ? type.constantize : relation_class
model.where(primary_key => object)
cls = type ? type.constantize : relation_class
crit = cls.criteria
crit = crit.apply_scope(scope)
crit.where(primary_key => object)
end

def query?(object)
Expand Down
4 changes: 3 additions & 1 deletion lib/mongoid/association/referenced/eager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ def each_loaded_document(&block)
# Upstream code is responsible for eliminating nils from keys.
return cls.none if keys.empty?

criteria = cls.any_in(key => keys)
criteria = cls.criteria
criteria = criteria.apply_scope(@association.scope)
criteria = criteria.any_in(key => keys)
criteria.inclusions = criteria.inclusions - [@association]
criteria.each do |doc|
yield doc
Expand Down
12 changes: 11 additions & 1 deletion lib/mongoid/association/referenced/has_and_belongs_to_many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class HasAndBelongsToMany
:primary_key,
:inverse_primary_key,
:inverse_foreign_key,
:scope,
].freeze

# The complete list of valid options for this association, including
Expand Down Expand Up @@ -163,6 +164,13 @@ def path(document)
Mongoid::Atomic::Paths::Root.new(document)
end

# Get the scope to be applied when querying the association.
#
# @return [ Proc | Symbol | nil ] The association scope, if any.
def scope
@options[:scope]
end

private

def setup_instance_methods!
Expand Down Expand Up @@ -251,7 +259,9 @@ def with_ordering(criteria)
end

def query_criteria(id_list)
crit = relation_class.all_of(primary_key => {"$in" => id_list || []})
crit = relation_class.criteria
crit = crit.apply_scope(scope)
crit = crit.all_of(primary_key => {"$in" => id_list || []})
with_ordering(crit)
end
end
Expand Down
14 changes: 12 additions & 2 deletions lib/mongoid/association/referenced/has_many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class HasMany
:dependent,
:foreign_key,
:order,
:primary_key
:primary_key,
:scope,
].freeze

# The complete list of valid options for this association, including
Expand Down Expand Up @@ -176,6 +177,13 @@ def path(document)
Mongoid::Atomic::Paths::Root.new(document)
end

# Get the scope to be applied when querying the association.
#
# @return [ Proc | Symbol | nil ] The association scope, if any.
def scope
@options[:scope]
end

private

def default_foreign_key_field
Expand Down Expand Up @@ -204,7 +212,9 @@ def default_primary_key
end

def query_criteria(object, base)
crit = klass.where(foreign_key => object)
crit = klass.criteria
crit = crit.apply_scope(scope)
crit = crit.where(foreign_key => object)
crit = with_polymorphic_criterion(crit, base)
crit.association = self
crit.parent_document = base
Expand Down
10 changes: 9 additions & 1 deletion lib/mongoid/association/referenced/has_one.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ class HasOne
:autosave,
:dependent,
:foreign_key,
:primary_key
:primary_key,
:scope,
].freeze

# The complete list of valid options for this association, including
Expand Down Expand Up @@ -129,6 +130,13 @@ def path(document)
Mongoid::Atomic::Paths::Root.new(document)
end

# Get the scope to be applied when querying the association.
#
# @return [ Proc | Symbol | nil ] The association scope, if any.
def scope
@options[:scope]
end

private

# Setup the instance methods on the class having this association type.
Expand Down
4 changes: 3 additions & 1 deletion lib/mongoid/association/referenced/has_one/buildable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ def clear_associated(object)
end

def query_criteria(object, base)
crit = klass.where(foreign_key => object)
crit = klass.criteria
crit = crit.apply_scope(scope)
crit = crit.where(foreign_key => object)
with_polymorphic_criterion(crit, base)
end

Expand Down
25 changes: 25 additions & 0 deletions lib/mongoid/criteria/scopable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ def apply_default_scope
self.scoping_options = true, false
end

# Applies a scope to the current criteria.
#
# This method does not modify the receiver but it may return a new
# object or the receiver depending on the argument: if the +scope+
# argument is nil, the receiver is returned without modification,
# otherwise a new criteria object is returned.
#
# @param [ Proc | Symbol | Criteria | nil ] scope The scope to apply.
#
# @return [ Criteria ] The criteria with the scope applied.
#
# @api private
def apply_scope(scope)
case scope
when Proc
instance_exec(&scope)
when Symbol
send(scope)
when Criteria
merge(scope)
else
self
end
end

# Given another criteria, remove the other criteria's scoping from this
# criteria.
#
Expand Down
102 changes: 102 additions & 0 deletions spec/integration/associations/scope_option_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true
# encoding: utf-8

require 'spec_helper'
require_relative '../../mongoid/association/referenced/has_and_belongs_to_many_models'
require_relative '../../mongoid/association/referenced/has_many_models'
require_relative '../../mongoid/association/referenced/has_one_models'

describe 'association :scope option' do

context 'has_many and belongs_to' do
let!(:trainer1) { HmmTrainer.create!(name: 'Dave') }
let!(:trainer2) { HmmTrainer.create!(name: 'Ash') }
let!(:animal1) { HmmAnimal.create!(taxonomy: 'reptile', trainer: trainer1) }
let!(:animal2) { HmmAnimal.create!(taxonomy: 'bird', trainer: trainer1) }
let!(:animal3) { HmmAnimal.create!(taxonomy: 'mammal', trainer: trainer2) }

it 'initially associates the documents in-memory' do
expect(trainer1.animals).to eq [animal1, animal2]
expect(trainer2.animals).to eq [animal3]
expect(animal1.trainer).to eq trainer1
expect(animal2.trainer).to eq trainer1
expect(animal3.trainer).to eq trainer2
end

it 'loads correct documents when queried' do
expect(trainer1.reload.animals).to eq [animal1]
expect(trainer2.reload.animals).to eq []
expect(animal1.reload.trainer).to eq trainer1
expect(animal2.reload.trainer).to eq trainer1
expect(animal3.reload.trainer).to be_nil
end

it 'eager loads correct documents' do
expect(HmmTrainer.includes(:animals).find(trainer1._id).animals).to eq [animal1]
expect(HmmTrainer.includes(:animals).find(trainer2._id).animals).to eq []
expect(HmmAnimal.includes(:trainer).find(animal1._id).trainer).to eq trainer1
expect(HmmAnimal.includes(:trainer).find(animal2._id).trainer).to eq trainer1
expect(HmmAnimal.includes(:trainer).find(animal3._id).trainer).to be_nil
end
end

context 'has_one and belongs_to' do
let!(:trainer1) { HomTrainer.create!(name: 'Dave') }
let!(:trainer2) { HomTrainer.create!(name: 'Ash') }
let!(:animal1) { HomAnimal.create!(taxonomy: 'reptile', trainer: trainer1) }
let!(:animal2) { HomAnimal.create!(taxonomy: 'bird', trainer: trainer1) }
let!(:animal3) { HomAnimal.create!(taxonomy: 'mammal', trainer: trainer2) }

it 'initially associates the documents in-memory' do
expect(trainer1.animal).to eq animal2
expect(trainer2.animal).to eq animal3
expect(animal1.trainer).to eq trainer1
expect(animal2.trainer).to eq trainer1
expect(animal3.trainer).to eq trainer2
end

it 'loads correct documents when queried' do
expect(trainer1.reload.animal).to eq animal1
expect(trainer2.reload.animal).to be_nil
expect(animal1.reload.trainer).to eq trainer1
expect(animal2.reload.trainer).to eq trainer1
expect(animal3.reload.trainer).to be_nil
end

it 'eager loads correct documents' do
expect(HomTrainer.includes(:animal).find(trainer1._id).animal).to eq animal1
expect(HomTrainer.includes(:animal).find(trainer2._id).animal).to be_nil
expect(HomAnimal.includes(:trainer).find(animal1._id).trainer).to eq trainer1
expect(HomAnimal.includes(:trainer).find(animal2._id).trainer).to eq trainer1
expect(HomAnimal.includes(:trainer).find(animal3._id).trainer).to be_nil
end
end

context 'has_and_belongs_to_many' do
let!(:trainer1) { HabtmmTrainer.create!(name: 'Dave') }
let!(:trainer2) { HabtmmTrainer.create!(name: 'Ash') }
let!(:animal1) { HabtmmAnimal.create!(taxonomy: 'reptile', trainers: [trainer1, trainer2]) }
let!(:animal2) { HabtmmAnimal.create!(taxonomy: 'bird', trainers: [trainer1, trainer2]) }

it 'initially associates the documents in-memory' do
expect(trainer1.animals).to eq [animal1]
expect(trainer2.animals).to eq [animal1]
expect(animal1.trainers).to eq [trainer1, trainer2]
expect(animal2.trainers).to eq [trainer1, trainer2]
end

it 'loads correct documents when queried' do
expect(trainer1.reload.animals).to eq [animal1]
expect(trainer2.reload.animals).to eq [animal1]
expect(animal1.reload.trainers).to eq [trainer1]
expect(animal2.reload.trainers).to eq [trainer1]
end

it 'eager loads correct documents' do
expect(HabtmmTrainer.includes(:animals).find(trainer1._id).animals).to eq [animal1]
expect(HabtmmTrainer.includes(:animals).find(trainer2._id).animals).to eq [animal1]
expect(HabtmmAnimal.includes(:trainers).find(animal1._id).trainers).to eq [trainer1]
expect(HabtmmAnimal.includes(:trainers).find(animal2._id).trainers).to eq [trainer1]
end
end
end
Loading