mongoid 9.1.0 → 9.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +49 -0
- data/lib/mongoid/association/accessors.rb +9 -1
- data/lib/mongoid/association/depending.rb +14 -10
- data/lib/mongoid/association/eager.rb +9 -0
- data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
- data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
- data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
- data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
- data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
- data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
- data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
- data/lib/mongoid/association/eager_loadable.rb +57 -100
- data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
- data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
- data/lib/mongoid/association/macros.rb +13 -1
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/nested/one.rb +6 -1
- data/lib/mongoid/association/options.rb +17 -0
- data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
- data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
- data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
- data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
- data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
- data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
- data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
- data/lib/mongoid/association/referenced/has_one.rb +1 -0
- data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
- data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
- data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
- data/lib/mongoid/association/referenced.rb +2 -0
- data/lib/mongoid/association/relatable.rb +40 -0
- data/lib/mongoid/association.rb +7 -0
- data/lib/mongoid/clients/factory.rb +7 -13
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/defaults.rb +0 -1
- data/lib/mongoid/config/encryption.rb +36 -19
- data/lib/mongoid/config.rb +56 -5
- data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/includable.rb +35 -11
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
- data/lib/mongoid/deprecable.rb +45 -2
- data/lib/mongoid/encryptable.rb +46 -0
- data/lib/mongoid/errors/config_redactor.rb +43 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
- data/lib/mongoid/errors/no_client_database.rb +1 -1
- data/lib/mongoid/errors/no_client_hosts.rb +1 -1
- data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
- data/lib/mongoid/errors/readonly_association.rb +29 -0
- data/lib/mongoid/errors.rb +4 -0
- data/lib/mongoid/extensions/vector.rb +64 -0
- data/lib/mongoid/extensions.rb +1 -0
- data/lib/mongoid/field_readable.rb +69 -0
- data/lib/mongoid/indexable.rb +38 -26
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
- data/lib/mongoid/matcher/regex.rb +11 -12
- data/lib/mongoid/matcher/regexp_budget.rb +372 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistable/creatable.rb +1 -1
- data/lib/mongoid/persistable/updatable.rb +9 -11
- data/lib/mongoid/persistence_context.rb +35 -0
- data/lib/mongoid/search_indexable.rb +64 -23
- data/lib/mongoid/serializable.rb +12 -4
- data/lib/mongoid/tasks/database.rb +23 -9
- data/lib/mongoid/threaded.rb +36 -0
- data/lib/mongoid/timestamps/timeless.rb +84 -10
- data/lib/mongoid/traversable.rb +2 -2
- data/lib/mongoid/validatable/associated.rb +18 -2
- data/lib/mongoid/version.rb +1 -1
- data/spec/integration/app_spec.rb +7 -1
- data/spec/integration/associations/belongs_to_spec.rb +13 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +151 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
- data/spec/mongoid/association/eager_spec.rb +2 -2
- data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
- data/spec/mongoid/association/fallback_spec.rb +425 -0
- data/spec/mongoid/association/macros_spec.rb +26 -0
- data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
- data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
- data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
- data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
- data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
- data/spec/mongoid/attributes/nested_spec.rb +249 -8
- data/spec/mongoid/config/defaults_spec.rb +33 -0
- data/spec/mongoid/config/encryption_spec.rb +228 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/includable_spec.rb +875 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
- data/spec/mongoid/criteria_spec.rb +3 -0
- data/spec/mongoid/encryptable_spec.rb +61 -0
- data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
- data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
- data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
- data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
- data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
- data/spec/mongoid/extensions/vector_spec.rb +175 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/search_indexable_spec.rb +295 -9
- data/spec/mongoid/tasks/database_spec.rb +18 -0
- data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
- data/spec/mongoid/touchable_spec.rb +25 -0
- data/spec/mongoid/touchable_spec_models.rb +10 -0
- data/spec/mongoid/validatable/associated_spec.rb +19 -0
- data/spec/spec_helper.rb +5 -1
- data/spec/support/crypt/models.rb +157 -0
- metadata +52 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mongoid/association/eager_load/discriminated_inclusion'
|
|
4
|
+
require 'mongoid/association/eager_load/inclusion'
|
|
5
|
+
|
|
6
|
+
module Mongoid
|
|
7
|
+
module Association
|
|
8
|
+
module EagerLoad
|
|
9
|
+
# The tree of nested inclusions an eager load asks to load. Built from the
|
|
10
|
+
# criteria's inclusions, it contributes each root node's stages to the
|
|
11
|
+
# pipeline, with each node already carrying its own children.
|
|
12
|
+
#
|
|
13
|
+
# Each root branch is built from its own copy of the inclusions, and an
|
|
14
|
+
# inclusion is removed as it is placed, so it lands once per branch even if
|
|
15
|
+
# more than one parent in that branch points at it, and a circular chain of
|
|
16
|
+
# inclusions can't loop forever.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class InclusionTree
|
|
20
|
+
class << self
|
|
21
|
+
# Builds the tree for the criteria's inclusions.
|
|
22
|
+
#
|
|
23
|
+
# @param [ Array<Mongoid::Association::Relatable> ] inclusions The inclusions.
|
|
24
|
+
# @param [ LookupPipeline ] pipeline The pipeline being built.
|
|
25
|
+
#
|
|
26
|
+
# @return [ InclusionTree ] The tree.
|
|
27
|
+
def from(inclusions, pipeline)
|
|
28
|
+
new(inclusions, pipeline, inclusions.to_h { |association| [ association.name, association ] })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private :new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(inclusions, pipeline, by_name)
|
|
35
|
+
@inclusions = inclusions
|
|
36
|
+
@pipeline = pipeline
|
|
37
|
+
@by_name = by_name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Contribute each root inclusion's stages to the pipeline. Each root carries
|
|
41
|
+
# its own children, so the whole tree is appended by recursion from the
|
|
42
|
+
# roots downward.
|
|
43
|
+
#
|
|
44
|
+
# @param [ Array<Hash> ] destination The pipeline the stages are appended to.
|
|
45
|
+
def contribute_to(destination)
|
|
46
|
+
roots.each { |root| root.contribute(destination, []) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# A name that more than one subclass defines (with different targets) can't
|
|
52
|
+
# share one $lookup field, so its nodes -- each carrying its own children --
|
|
53
|
+
# are grouped and routed by the discriminator instead of becoming separate,
|
|
54
|
+
# overwriting roots.
|
|
55
|
+
def roots
|
|
56
|
+
top_level.group_by(&:name).map do |_name, associations|
|
|
57
|
+
nodes = associations.map { |association| node(association, @inclusions.dup) }
|
|
58
|
+
nodes.one? ? nodes.first : DiscriminatedInclusion.new(nodes)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The inclusions that no other inclusion is the parent of.
|
|
63
|
+
def top_level
|
|
64
|
+
@inclusions.reject do |association|
|
|
65
|
+
association.parent_inclusions.any? { |name| @by_name.key?(name) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def node(association, available)
|
|
70
|
+
children = take_children(association, available).map { |child| node(child, available) }
|
|
71
|
+
AssociationInclusion.for(association, @pipeline, children)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The still-available inclusions parented to +association+, removed as they
|
|
75
|
+
# are taken so each lands once on this branch. A child belongs here when it
|
|
76
|
+
# names this association as its parent and its owner shares the target's
|
|
77
|
+
# class hierarchy, which tells apart children of two unrelated subclasses
|
|
78
|
+
# that share an association name.
|
|
79
|
+
def take_children(association, available)
|
|
80
|
+
children = available.select do |candidate|
|
|
81
|
+
candidate.parent_inclusions.include?(association.name) &&
|
|
82
|
+
same_hierarchy?(association.klass, candidate.inverse_class)
|
|
83
|
+
end
|
|
84
|
+
available.reject! { |candidate| children.include?(candidate) }
|
|
85
|
+
children
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether two classes belong to the same inheritance chain (one is the
|
|
89
|
+
# other, an ancestor of it, or a descendant of it).
|
|
90
|
+
def same_hierarchy?(one, other)
|
|
91
|
+
one <= other || other <= one
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mongoid/association/eager_load/embedded_distributor'
|
|
4
|
+
require 'mongoid/association/eager_load/inclusion_tree'
|
|
5
|
+
|
|
6
|
+
module Mongoid
|
|
7
|
+
module Association
|
|
8
|
+
module EagerLoad
|
|
9
|
+
# Builds the aggregation pipeline that eager-loads a criteria's inclusions
|
|
10
|
+
# with $lookup.
|
|
11
|
+
#
|
|
12
|
+
# It starts with the criteria's own match/sort/skip/limit, then lets each
|
|
13
|
+
# root of the inclusion tree contribute its stages. This object owns the
|
|
14
|
+
# stage-building helpers; how each inclusion contributes is the inclusion's
|
|
15
|
+
# own business (see Inclusion).
|
|
16
|
+
#
|
|
17
|
+
# For Band.eager_load(albums: :tracks) the result is roughly:
|
|
18
|
+
#
|
|
19
|
+
# [ <criteria match / sort / skip / limit>,
|
|
20
|
+
# { '$lookup' => { # JoinedInclusion(:albums)
|
|
21
|
+
# 'from' => 'albums',
|
|
22
|
+
# 'localField' => '_id',
|
|
23
|
+
# 'foreignField' => 'band_id',
|
|
24
|
+
# 'as' => 'albums',
|
|
25
|
+
# 'pipeline' => [
|
|
26
|
+
# { '$sort' => {
|
|
27
|
+
# '_id' => 1
|
|
28
|
+
# } },
|
|
29
|
+
# { '$lookup' => { # JoinedInclusion(:tracks), nested
|
|
30
|
+
# 'as' => 'tracks',
|
|
31
|
+
# 'pipeline' => [
|
|
32
|
+
# { '$sort' => {
|
|
33
|
+
# '_id' => 1
|
|
34
|
+
# } }
|
|
35
|
+
# ]
|
|
36
|
+
# } }
|
|
37
|
+
# ]
|
|
38
|
+
# } } ]
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
class LookupPipeline
|
|
42
|
+
def initialize(criteria)
|
|
43
|
+
@criteria = criteria
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [ Array<Hash> ] The aggregation pipeline stages.
|
|
47
|
+
def stages
|
|
48
|
+
pipeline = @criteria.selector.to_pipeline
|
|
49
|
+
pipeline.concat(@criteria.options.to_pipeline_for_lookup)
|
|
50
|
+
InclusionTree.from(@criteria.inclusions, self).contribute_to(pipeline)
|
|
51
|
+
pipeline
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The $lookup stage for a referenced inclusion: its key fields, a
|
|
55
|
+
# discriminator match when the target shares its collection with sibling
|
|
56
|
+
# subclasses, and an order. Children are added by the inclusion itself.
|
|
57
|
+
#
|
|
58
|
+
# @param [ Mongoid::Association::Relatable ] association The inclusion.
|
|
59
|
+
#
|
|
60
|
+
# @return [ Hash ] The $lookup stage.
|
|
61
|
+
def lookup_stage_for(association)
|
|
62
|
+
local_field, foreign_field = lookup_fields(association)
|
|
63
|
+
sub_pipeline = []
|
|
64
|
+
sub_pipeline << discriminator_match(association) if association.klass.hereditary?
|
|
65
|
+
sub_pipeline << order(association)
|
|
66
|
+
{ '$lookup' => {
|
|
67
|
+
'from' => association.klass.collection.name,
|
|
68
|
+
'localField' => local_field,
|
|
69
|
+
'foreignField' => foreign_field,
|
|
70
|
+
'as' => association.name.to_s,
|
|
71
|
+
'pipeline' => sub_pipeline
|
|
72
|
+
} }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Builds the stages that distribute a referenced inclusion living inside an
|
|
76
|
+
# embedded document onto that document.
|
|
77
|
+
#
|
|
78
|
+
# @param [ Mongoid::Association::Relatable ] association The inclusion.
|
|
79
|
+
# @param [ Array<Mongoid::Association::Relatable> ] chain The embedded path.
|
|
80
|
+
# @param [ Hash ] lookup_stage The $lookup stage for the association.
|
|
81
|
+
#
|
|
82
|
+
# @return [ Array<Hash> ] The stages to append.
|
|
83
|
+
def distribute(association, chain, lookup_stage)
|
|
84
|
+
EmbeddedDistributor.for(association: association, chain: chain, lookup_stage: lookup_stage).stages
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# When the association stores the foreign key on the current document
|
|
90
|
+
# (belongs_to, has_and_belongs_to_many) the local field is that key; for
|
|
91
|
+
# the others (has_many, has_one) the key is on the related document.
|
|
92
|
+
def lookup_fields(association)
|
|
93
|
+
if association.stores_foreign_key?
|
|
94
|
+
[ association.foreign_key, association.primary_key ]
|
|
95
|
+
else
|
|
96
|
+
[ association.primary_key, association.foreign_key ]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def discriminator_match(association)
|
|
101
|
+
target = association.klass
|
|
102
|
+
{ '$match' => {
|
|
103
|
+
target.discriminator_key => { '$in' => target._types }
|
|
104
|
+
} }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def order(association)
|
|
108
|
+
unless association.order
|
|
109
|
+
return { '$sort' => {
|
|
110
|
+
'_id' => 1
|
|
111
|
+
} }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
ordering = association.order
|
|
115
|
+
ordering = { ordering => 1 } unless ordering.is_a?(Hash)
|
|
116
|
+
{ '$sort' => ordering }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mongoid/association/eager_load/polymorphic_targets'
|
|
4
|
+
|
|
5
|
+
module Mongoid
|
|
6
|
+
module Association
|
|
7
|
+
module EagerLoad
|
|
8
|
+
# Resolves a polymorphic belongs_to onto already-materialized root documents.
|
|
9
|
+
#
|
|
10
|
+
# A polymorphic belongs_to can't be expressed as a $lookup: its target
|
|
11
|
+
# collection varies per document. So once the roots are materialized, the
|
|
12
|
+
# foreign keys are grouped by type, PolymorphicTargets resolves the documents
|
|
13
|
+
# for those keys, and the result is set on each document.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class PolymorphicPreloader
|
|
17
|
+
def initialize(association, root_class)
|
|
18
|
+
@association = association
|
|
19
|
+
@root_class = root_class
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Resolve and assign the polymorphic target on each of the documents.
|
|
23
|
+
#
|
|
24
|
+
# @param [ Array<Mongoid::Document> ] documents The materialized root documents.
|
|
25
|
+
def preload_into(documents)
|
|
26
|
+
targets = PolymorphicTargets.for(@association, keys_by_type(documents), @root_class)
|
|
27
|
+
assign(documents, targets)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# The foreign keys on the documents grouped by polymorphic type,
|
|
33
|
+
# e.g. { "Printer" => [ id1 ], "Scanner" => [ id2 ] }.
|
|
34
|
+
def keys_by_type(documents)
|
|
35
|
+
documents.each_with_object({}) do |document, grouped|
|
|
36
|
+
type, key = reference_on(document)
|
|
37
|
+
(grouped[type] ||= []) << key if type && key
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def assign(documents, targets)
|
|
42
|
+
documents.each do |document|
|
|
43
|
+
type, key = reference_on(document)
|
|
44
|
+
target = targets.dig(type, key) if type && key
|
|
45
|
+
document.set_relation(@association.name, target)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The [ type, key ] reference stored on the document for this association,
|
|
50
|
+
# or an empty pair when the document holds no such reference.
|
|
51
|
+
def reference_on(document)
|
|
52
|
+
type_field = @association.inverse_type
|
|
53
|
+
key_field = @association.foreign_key
|
|
54
|
+
return [] unless document.respond_to?(type_field) && document.respond_to?(key_field)
|
|
55
|
+
|
|
56
|
+
[ document.send(type_field), document.send(key_field) ]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mongoid
|
|
4
|
+
module Association
|
|
5
|
+
module EagerLoad
|
|
6
|
+
# The targets of a polymorphic belongs_to, indexed as
|
|
7
|
+
# { type => { primary_key => document } }. Each subclass reaches the types that
|
|
8
|
+
# live in one place (the root's database or elsewhere); .for resolves the whole
|
|
9
|
+
# set, routing each type to the subclass that can reach it.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class PolymorphicTargets
|
|
13
|
+
class << self
|
|
14
|
+
# Resolve every polymorphic target for the foreign keys grouped by type.
|
|
15
|
+
# The types whose documents share the root's database are fetched together
|
|
16
|
+
# in one $facet; those living elsewhere are read through their own models.
|
|
17
|
+
#
|
|
18
|
+
# @param [ Mongoid::Association::Relatable ] association The polymorphic inclusion.
|
|
19
|
+
# @param [ Hash ] keys_by_type The foreign keys grouped by type.
|
|
20
|
+
# @param [ Class ] root_class The class being queried.
|
|
21
|
+
#
|
|
22
|
+
# @return [ Hash ] The targets, as { type => { primary_key => document } }.
|
|
23
|
+
def for(association, keys_by_type, root_class)
|
|
24
|
+
here, elsewhere = keys_by_type.partition do |type, _keys|
|
|
25
|
+
in_root_database?(association, type, root_class)
|
|
26
|
+
end
|
|
27
|
+
same_database = SameDatabaseTargets.new(association, here.to_h, root_class)
|
|
28
|
+
other_databases = OtherDatabaseTargets.new(association, elsewhere.to_h)
|
|
29
|
+
same_database.fetch.merge(other_databases.fetch)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Whether the type's model shares the root's database (and client): exactly
|
|
35
|
+
# what a $lookup from the root collection can reach.
|
|
36
|
+
def in_root_database?(association, type, root_class)
|
|
37
|
+
model = association.resolver.model_for(type)
|
|
38
|
+
model.client_name == root_class.client_name &&
|
|
39
|
+
model.database_name == root_class.database_name
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(association, keys_by_type)
|
|
44
|
+
@association = association
|
|
45
|
+
@keys_by_type = keys_by_type
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [ Hash ] The targets, as { type => { primary_key => document } }.
|
|
49
|
+
def fetch
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def primary_key
|
|
56
|
+
@association.primary_key
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def model_for(type)
|
|
60
|
+
@association.resolver.model_for(type)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The raw documents instantiated and indexed by their primary key.
|
|
64
|
+
def indexed(documents, model)
|
|
65
|
+
documents.map { |document| Factory.from_db(model, document) }
|
|
66
|
+
.index_by { |document| document.send(primary_key) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Targets that live in the root's own database. A $lookup can reach them, so
|
|
71
|
+
# every type is fetched together in one $facet aggregation against the root
|
|
72
|
+
# collection.
|
|
73
|
+
#
|
|
74
|
+
# For { 'Printer' => [ id1 ], 'Scanner' => [ id2 ] } it runs:
|
|
75
|
+
#
|
|
76
|
+
# [
|
|
77
|
+
# { '$limit' => 1 }, # one input doc, so each facet branch runs once
|
|
78
|
+
# { '$facet' => { # run one $lookup per type within a single query
|
|
79
|
+
# 'Printer' => [ { '$lookup' => { 'from' => 'printers', ... } }, ... ],
|
|
80
|
+
# 'Scanner' => [ { '$lookup' => { 'from' => 'scanners', ... } }, ... ]
|
|
81
|
+
# } }
|
|
82
|
+
# ]
|
|
83
|
+
#
|
|
84
|
+
# @api private
|
|
85
|
+
class SameDatabaseTargets < PolymorphicTargets
|
|
86
|
+
def initialize(association, keys_by_type, root_class)
|
|
87
|
+
super(association, keys_by_type)
|
|
88
|
+
@root_class = root_class
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [ Hash ] The targets, as { type => { primary_key => document } }.
|
|
92
|
+
def fetch
|
|
93
|
+
return {} if @keys_by_type.empty?
|
|
94
|
+
|
|
95
|
+
aggregated = @root_class.collection.aggregate([ { '$limit' => 1 }, { '$facet' => facets } ]).first
|
|
96
|
+
aggregated.to_h do |type, branch|
|
|
97
|
+
# $limit => 1 makes each branch yield a single wrapper holding the matches.
|
|
98
|
+
[ type, indexed(branch.first['matches'], model_for(type)) ]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def facets
|
|
105
|
+
@keys_by_type.to_h do |type, keys|
|
|
106
|
+
[ type, branch_for(model_for(type).collection.name, keys) ]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# One $facet branch: the documents in +collection_name+ whose primary key
|
|
111
|
+
# is among +keys+, exposed under "matches".
|
|
112
|
+
def branch_for(collection_name, keys)
|
|
113
|
+
[
|
|
114
|
+
{ '$lookup' => {
|
|
115
|
+
'from' => collection_name,
|
|
116
|
+
'pipeline' => [
|
|
117
|
+
{ '$match' => {
|
|
118
|
+
primary_key => { '$in' => keys.uniq }
|
|
119
|
+
} }
|
|
120
|
+
],
|
|
121
|
+
'as' => 'matches'
|
|
122
|
+
} },
|
|
123
|
+
{ '$project' => {
|
|
124
|
+
'_id' => 0,
|
|
125
|
+
'matches' => 1
|
|
126
|
+
} }
|
|
127
|
+
]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Targets kept in another database (or cluster), which a $lookup cannot
|
|
132
|
+
# reach. Each type is read directly through its own model, which connects
|
|
133
|
+
# with that model's client.
|
|
134
|
+
#
|
|
135
|
+
# For { 'Scanner' => [ id2 ] } it runs, on the Scanner model's own client:
|
|
136
|
+
# scanners.find('_id' => { '$in' => [ id2 ] })
|
|
137
|
+
#
|
|
138
|
+
# @api private
|
|
139
|
+
class OtherDatabaseTargets < PolymorphicTargets
|
|
140
|
+
# @return [ Hash ] The targets, as { type => { primary_key => document } }.
|
|
141
|
+
def fetch
|
|
142
|
+
@keys_by_type.to_h do |type, keys|
|
|
143
|
+
model = model_for(type)
|
|
144
|
+
documents = model.collection.find(primary_key => { '$in' => keys.uniq })
|
|
145
|
+
[ type, indexed(documents, model) ]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'mongoid/association/eager'
|
|
4
|
+
require 'mongoid/association/eager_load/embedded_distributor'
|
|
5
|
+
require 'mongoid/association/eager_load/polymorphic_preloader'
|
|
6
|
+
require 'mongoid/association/eager_load/lookup_pipeline'
|
|
4
7
|
|
|
5
8
|
module Mongoid
|
|
6
9
|
module Association
|
|
@@ -27,24 +30,49 @@ module Mongoid
|
|
|
27
30
|
|
|
28
31
|
# Load the associations for the given documents using $lookup.
|
|
29
32
|
#
|
|
30
|
-
# If any of the associated collections reside in a different cluster
|
|
31
|
-
# the root class, falls back to the #includes behavior and
|
|
33
|
+
# If any of the associated collections reside in a different cluster or
|
|
34
|
+
# database than the root class, falls back to the #includes behavior and
|
|
35
|
+
# logs a warning.
|
|
32
36
|
#
|
|
33
37
|
# @return [ Array<Mongoid::Document> ] The given documents.
|
|
34
38
|
def eager_load_with_lookup
|
|
35
|
-
offenders =
|
|
39
|
+
offenders = inclusions_unreachable_by_lookup
|
|
36
40
|
if offenders.any?
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
offender_descriptions = offenders.map do |offender|
|
|
42
|
+
"#{offender.name} (client: #{offender.klass.client_name}, database: #{offender.klass.database_name})"
|
|
43
|
+
end
|
|
39
44
|
Mongoid.logger.warn(
|
|
40
45
|
'eager_load cannot use $lookup aggregation because the following associations ' \
|
|
41
|
-
"reside in a different cluster than #{klass}
|
|
42
|
-
"#{
|
|
46
|
+
"reside in a different cluster or database than #{klass} " \
|
|
47
|
+
"(client: #{klass.client_name}, database: #{klass.database_name}): " \
|
|
48
|
+
"#{offender_descriptions.join(', ')}. Falling back to #includes behavior."
|
|
49
|
+
)
|
|
50
|
+
return eager_load(docs_for_lookup_fallback)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
through_inclusions = criteria.inclusions.select do |association|
|
|
54
|
+
association.is_a?(Association::Referenced::HasOneThrough) ||
|
|
55
|
+
association.is_a?(Association::Referenced::HasManyThrough)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if through_inclusions.any?
|
|
59
|
+
through_names = through_inclusions.map { |association| ":#{association.name}" }
|
|
60
|
+
Mongoid.logger.warn(
|
|
61
|
+
"#{through_names.join(', ')} are :through associations and do not support " \
|
|
62
|
+
'$lookup-based eager loading. All inclusions for this query will be preloaded ' \
|
|
63
|
+
'using separate queries.'
|
|
43
64
|
)
|
|
44
65
|
return eager_load(docs_for_lookup_fallback)
|
|
45
66
|
end
|
|
46
67
|
|
|
47
|
-
preload_for_lookup(criteria)
|
|
68
|
+
documents = preload_for_lookup(criteria)
|
|
69
|
+
# A polymorphic belongs_to cannot be expressed as a $lookup: its target
|
|
70
|
+
# collection varies per document. It is resolved after the roots are
|
|
71
|
+
# materialized, each inclusion by its own preloader.
|
|
72
|
+
criteria.inclusions.select(&:polymorphic?).each do |association|
|
|
73
|
+
EagerLoad::PolymorphicPreloader.new(association, klass).preload_into(documents)
|
|
74
|
+
end
|
|
75
|
+
documents
|
|
48
76
|
end
|
|
49
77
|
|
|
50
78
|
# Load the associations for the given documents. This will be done
|
|
@@ -82,39 +110,16 @@ module Mongoid
|
|
|
82
110
|
end
|
|
83
111
|
end
|
|
84
112
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
113
|
+
# Materialize the root documents with their inclusions eager-loaded by a
|
|
114
|
+
# single $lookup aggregation. The pipeline is built by LookupPipeline; the
|
|
115
|
+
# polymorphic inclusions it leaves out are resolved by the caller.
|
|
88
116
|
#
|
|
89
|
-
# @param [
|
|
90
|
-
#
|
|
91
|
-
# @
|
|
117
|
+
# @param [ Mongoid::Criteria ] criteria The criteria to load.
|
|
118
|
+
#
|
|
119
|
+
# @return [ Array<Mongoid::Document> ] The materialized root documents.
|
|
92
120
|
def preload_for_lookup(criteria)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# match first
|
|
96
|
-
pipeline = criteria.selector.to_pipeline
|
|
97
|
-
# then sort, skip, limit
|
|
98
|
-
pipeline.concat(criteria.options.to_pipeline_for_lookup)
|
|
99
|
-
|
|
100
|
-
# account for single-collection inheritance
|
|
101
|
-
root_class = klass.root_class
|
|
102
|
-
|
|
103
|
-
if assoc_map[klass.to_s]
|
|
104
|
-
assoc_map[klass.to_s].each do |assoc|
|
|
105
|
-
# Create a copy of the mapping for each top-level association to avoid mutation issues
|
|
106
|
-
pipeline << create_pipeline(assoc, assoc_map.dup)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
if klass != root_class && assoc_map[root_class.to_s]
|
|
111
|
-
assoc_map[root_class.to_s].each do |assoc|
|
|
112
|
-
# Create a copy of the mapping for each top-level association to avoid mutation issues
|
|
113
|
-
pipeline << create_pipeline(assoc, assoc_map.dup)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
Eager.new(criteria.inclusions, [], true, pipeline).run
|
|
121
|
+
pipeline = EagerLoad::LookupPipeline.new(criteria).stages
|
|
122
|
+
Eager.run(criteria.inclusions, [], true, pipeline)
|
|
118
123
|
end
|
|
119
124
|
|
|
120
125
|
private
|
|
@@ -128,71 +133,23 @@ module Mongoid
|
|
|
128
133
|
raise NotImplementedError, "#{self.class} must implement #docs_for_lookup_fallback"
|
|
129
134
|
end
|
|
130
135
|
|
|
131
|
-
# Returns the inclusions whose target class
|
|
132
|
-
#
|
|
136
|
+
# Returns the inclusions whose target class can't be reached by a $lookup
|
|
137
|
+
# from the root class, which joins only within the same client and database.
|
|
133
138
|
#
|
|
134
139
|
# @return [ Array<Mongoid::Association::Relatable> ] The offending inclusions.
|
|
135
|
-
def
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
public
|
|
141
|
-
|
|
142
|
-
def switch_local_and_foreign_fields?(association)
|
|
143
|
-
association.is_a?(Mongoid::Association::Referenced::BelongsTo) ||
|
|
144
|
-
association.is_a?(Mongoid::Association::Referenced::HasAndBelongsToMany)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def create_pipeline(current_assoc, mapping)
|
|
148
|
-
# Build nested pipeline for children and ordering
|
|
149
|
-
pipeline_stages = []
|
|
150
|
-
|
|
151
|
-
# For belongs_to and has_and_belongs_to_many, the foreign key is on the current document
|
|
152
|
-
# For has_many/has_one, the foreign key is on the related document
|
|
153
|
-
if switch_local_and_foreign_fields?(current_assoc)
|
|
154
|
-
local_field = current_assoc.foreign_key
|
|
155
|
-
foreign_field = current_assoc.primary_key
|
|
156
|
-
else
|
|
157
|
-
local_field = current_assoc.primary_key
|
|
158
|
-
foreign_field = current_assoc.foreign_key
|
|
140
|
+
def inclusions_unreachable_by_lookup
|
|
141
|
+
# Polymorphic associations have no single resolvable klass and are not
|
|
142
|
+
# loaded via $lookup, so they are never offenders.
|
|
143
|
+
criteria.inclusions.reject do |association|
|
|
144
|
+
association.polymorphic? || reachable_by_lookup?(association.klass)
|
|
159
145
|
end
|
|
146
|
+
end
|
|
160
147
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
'from' => current_assoc.klass.collection.name,
|
|
167
|
-
'localField' => local_field,
|
|
168
|
-
'foreignField' => foreign_field,
|
|
169
|
-
'as' => as_field
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
# Add ordering if defined on the association, or default to _id for consistent order
|
|
174
|
-
if current_assoc.order
|
|
175
|
-
sort_spec = current_assoc.order.is_a?(Hash) ? current_assoc.order : { current_assoc.order => 1 }
|
|
176
|
-
pipeline_stages << { '$sort' => sort_spec }
|
|
177
|
-
else
|
|
178
|
-
# Default to sorting by _id to maintain insertion order consistency
|
|
179
|
-
pipeline_stages << { '$sort' => { '_id' => 1 } }
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Add nested lookups for child associations
|
|
183
|
-
# Child associations don't need the embedded_path prefix since they're referenced from the looked-up document
|
|
184
|
-
# Remove this class from the mapping to prevent infinite loops with circular references
|
|
185
|
-
class_name = current_assoc.klass.to_s
|
|
186
|
-
if child_assocs = mapping.delete(class_name)
|
|
187
|
-
child_assocs.each do |child|
|
|
188
|
-
pipeline_stages << create_pipeline(child, mapping)
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Always add pipeline since we always have at least $sort
|
|
193
|
-
stage['$lookup']['pipeline'] = pipeline_stages
|
|
194
|
-
|
|
195
|
-
stage
|
|
148
|
+
# Whether a $lookup from a query on the root class can reach the model: it
|
|
149
|
+
# must live in the same client and database.
|
|
150
|
+
def reachable_by_lookup?(model)
|
|
151
|
+
model.client_name == klass.client_name &&
|
|
152
|
+
model.database_name == klass.database_name
|
|
196
153
|
end
|
|
197
154
|
end
|
|
198
155
|
end
|
|
@@ -601,7 +601,12 @@ module Mongoid
|
|
|
601
601
|
# @api private
|
|
602
602
|
def update_attributes_hash
|
|
603
603
|
if _target.empty?
|
|
604
|
-
|
|
604
|
+
# Only remove the key if it is absent or nil. If it is already
|
|
605
|
+
# an empty array, the caller explicitly set it to [] (e.g. via
|
|
606
|
+
# a raw write_attribute call), and that intent must be preserved
|
|
607
|
+
# so the empty array is persisted to the database.
|
|
608
|
+
stored = _association.store_as
|
|
609
|
+
_base.attributes.delete(stored) unless _base.attributes[stored] == []
|
|
605
610
|
else
|
|
606
611
|
_base.attributes.merge!(_association.store_as => _target.map(&:attributes))
|
|
607
612
|
end
|
|
@@ -218,7 +218,19 @@ module Mongoid
|
|
|
218
218
|
private
|
|
219
219
|
|
|
220
220
|
def define_association!(macro_name, name, options = {}, &block)
|
|
221
|
-
|
|
221
|
+
klass = if options[:through]
|
|
222
|
+
Association::THROUGH_MACRO_MAPPING[macro_name] ||
|
|
223
|
+
raise(
|
|
224
|
+
Errors::InvalidRelationOption.new(
|
|
225
|
+
self, name, :through,
|
|
226
|
+
Association::THROUGH_MACRO_MAPPING.keys
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
else
|
|
230
|
+
Association::MACRO_MAPPING[macro_name]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
klass.new(self, name, options, &block).tap do |assoc|
|
|
222
234
|
assoc.setup!
|
|
223
235
|
self.relations = relations.merge(name => assoc)
|
|
224
236
|
if assoc.embedded? && assoc.respond_to?(:store_as) && assoc.store_as != name
|