/
mergeable.rb
434 lines (411 loc) · 15.4 KB
/
mergeable.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# frozen_string_literal: true
module Mongoid
class Criteria
module Queryable
# Contains behavior for merging existing selection with new selection.
module Mergeable
# @attribute [rw] strategy The name of the current strategy.
attr_accessor :strategy
# Instruct the next mergeable call to use intersection.
#
# @example Use intersection on the next call.
# mergeable.intersect.in(field: [ 1, 2, 3 ])
#
# @return [ Mergeable ] The intersect flagged mergeable.
def intersect
use(:__intersect__)
end
# Instruct the next mergeable call to use override.
#
# @example Use override on the next call.
# mergeable.override.in(field: [ 1, 2, 3 ])
#
# @return [ Mergeable ] The override flagged mergeable.
def override
use(:__override__)
end
# Instruct the next mergeable call to use union.
#
# @example Use union on the next call.
# mergeable.union.in(field: [ 1, 2, 3 ])
#
# @return [ Mergeable ] The union flagged mergeable.
def union
use(:__union__)
end
# Clear the current strategy and negating flag, used after cloning.
#
# @example Reset the strategies.
# mergeable.reset_strategies!
#
# @return [ Criteria ] self.
def reset_strategies!
self.strategy = nil
self.negating = nil
self
end
# Merge criteria with operators using the and operator.
#
# @param [ Hash ] criterion The criterion to add to the criteria.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Criteria ] The resulting criteria.
def and_with_operator(criterion, operator)
crit = self
if criterion
criterion.each_pair do |field, value|
val = prepare(field, operator, value)
# The prepare method already takes the negation into account. We
# set negating to false here so that ``and`` doesn't also apply
# negation and we have a double negative.
crit.negating = false
crit = crit.and(field => val)
end
end
crit
end
private
# Adds the criterion to the existing selection.
#
# @api private
#
# @example Add the criterion.
# mergeable.__add__({ name: 1 }, "$in")
#
# @param [ Hash ] criterion The criteria.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Mergeable ] The new mergeable.
def __add__(criterion, operator)
with_strategy(:__add__, criterion, operator)
end
# Adds the criterion to the existing selection.
#
# @api private
#
# @example Add the criterion.
# mergeable.__expanded__([ 1, 10 ], "$within", "$center")
#
# @param [ Hash ] criterion The criteria.
# @param [ String ] outer The outer MongoDB operator.
# @param [ String ] inner The inner MongoDB operator.
#
# @return [ Mergeable ] The new mergeable.
def __expanded__(criterion, outer, inner)
selection(criterion) do |selector, field, value|
selector.store(field, { outer => { inner => value }})
end
end
# Perform a straight merge of the criterion into the selection and let the
# symbol overrides do all the work.
#
# @api private
#
# @example Straight merge the expanded criterion.
# mergeable.__merge__(location: [ 1, 10 ])
#
# @param [ Hash ] criterion The criteria.
#
# @return [ Mergeable ] The cloned object.
def __merge__(criterion)
selection(criterion) do |selector, field, value|
selector.merge!(field.__expr_part__(value))
end
end
# Adds the criterion to the existing selection.
#
# @api private
#
# @example Add the criterion.
# mergeable.__intersect__([ 1, 2 ], "$in")
#
# @param [ Hash ] criterion The criteria.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Mergeable ] The new mergeable.
def __intersect__(criterion, operator)
with_strategy(:__intersect__, criterion, operator)
end
# Adds $and/$or/$nor criteria to a copy of this selection.
#
# Each of the criteria can be a Hash of key/value pairs or MongoDB
# operators (keys beginning with $), or a Selectable object
# (which typically will be a Criteria instance).
#
# @api private
#
# @example Add the criterion.
# mergeable.__multi__([ 1, 2 ], "$in")
#
# @param [ Array<Hash | Criteria> ] criteria Multiple key/value pair
# matches or Criteria objects.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Mergeable ] The new mergeable.
def __multi__(criteria, operator)
clone.tap do |query|
sel = query.selector
criteria.flatten.each do |expr|
next unless expr
result_criteria = sel[operator] || []
if expr.is_a?(Selectable)
expr = expr.selector
end
normalized = _mongoid_expand_keys(expr)
sel.store(operator, result_criteria.push(normalized))
end
end
end
# Combines criteria into a MongoDB selector.
#
# Criteria is an array of criterion objects which will be flattened.
#
# Each criterion can be:
# - A hash
# - A Criteria instance
# - nil, in which case it is ignored
#
# @api private
private def _mongoid_add_top_level_operation(operator, criteria)
# Flatten the criteria. The idea is that predicates in MongoDB
# are always hashes and are never arrays. This method additionally
# allows Criteria instances as predicates.
# The flattening is existing Mongoid behavior but we could possibly
# get rid of it as applications can splat their predicates, or
# flatten if needed.
clone.tap do |query|
sel = query.selector
_mongoid_flatten_arrays(criteria).each do |criterion|
if criterion.is_a?(Selectable)
expr = _mongoid_expand_keys(criterion.selector)
else
expr = _mongoid_expand_keys(criterion)
end
if sel.empty?
sel.store(operator, [expr])
elsif sel.keys == [operator]
sel.store(operator, sel[operator] + [expr])
else
operands = [sel.dup] + [expr]
sel.clear
sel.store(operator, operands)
end
end
end
end
# Calling .flatten on an array which includes a Criteria instance
# evaluates the criteria, which we do not want. Hence this method
# explicitly only expands Array objects and Array subclasses.
private def _mongoid_flatten_arrays(array)
out = []
pending = array.dup
until pending.empty?
item = pending.shift
if item.nil?
# skip
elsif item.is_a?(Array)
pending += item
else
out << item
end
end
out
end
# Takes a criteria hash and expands Key objects into hashes containing
# MQL corresponding to said key objects. Also converts the input to
# BSON::Document to permit indifferent access.
#
# The argument must be a hash containing key-value pairs of the
# following forms:
# - {field_name: value}
# - {'field_name' => value}
# - {key_instance: value}
# - {:$operator => operator_value_expression}
# - {'$operator' => operator_value_expression}
#
# Ruby does not permit multiple symbol operators. For example,
# {:foo.gt => 1, :foo.gt => 2} is collapsed to {:foo.gt => 2} by the
# language. Therefore this method never has to deal with multiple
# identical operators.
#
# Similarly, this method should never need to expand a literal value
# and an operator at the same time.
#
# This method effectively converts symbol keys to string keys in
# the input +expr+, such that the downstream code can assume that
# conditions always contain string keys.
#
# @param [ Hash ] expr Criteria including Key instances.
#
# @return [ BSON::Document ] The expanded criteria.
private def _mongoid_expand_keys(expr)
unless expr.is_a?(Hash)
raise ArgumentError, 'Argument must be a Hash'
end
result = BSON::Document.new
expr.each do |field, value|
field.__expr_part__(value.__expand_complex__, negating?).each do |k, v|
if existing = result[k]
if existing.is_a?(Hash)
# Existing value is an operator.
# If new value is also an operator, ensure there are no
# conflicts and add
if v.is_a?(Hash)
# The new value is also an operator.
# If there are no conflicts, combine the hashes, otherwise
# add new conditions to top level with $and.
if (v.keys & existing.keys).empty?
existing.update(v)
else
raise NotImplementedError, 'Ruby does not allow same symbol operator with different values'
result['$and'] ||= []
result['$and'] << {k => v}
end
else
# The new value is a simple value.
# Transform the implicit equality to either $eq or $regexp
# depending on the type of the argument. See
# https://www.mongodb.com/docs/manual/reference/operator/query/eq/#std-label-eq-usage-examples
# for the description of relevant server behavior.
op = case v
when Regexp, BSON::Regexp::Raw
'$regex'
else
'$eq'
end
# If there isn't an $eq/$regex operator already in the
# query, transform the new value into an operator
# expression and add it to the existing hash. Otherwise
# add the new condition with $and to the top level.
if existing.key?(op)
raise NotImplementedError, 'Ruby does not allow same symbol operator with different values'
result['$and'] ||= []
result['$and'] << {k => v}
else
existing.update(op => v)
end
end
else
# Existing value is a simple value.
# See the notes above about transformations to $eq/$regex.
op = case existing
when Regexp, BSON::Regexp::Raw
'$regex'
else
'$eq'
end
if v.is_a?(Hash) && !v.key?(op)
result[k] = {op => existing}.update(v)
else
raise NotImplementedError, 'Ruby does not allow same symbol operator with different values'
result['$and'] ||= []
result['$and'] << {k => v}
end
end
else
result[k] = v
end
end
end
result
end
# Adds the criterion to the existing selection.
#
# @api private
#
# @example Add the criterion.
# mergeable.__override__([ 1, 2 ], "$in")
#
# @param [ Hash | Criteria ] criterion The criteria.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Mergeable ] The new mergeable.
def __override__(criterion, operator)
if criterion.is_a?(Selectable)
criterion = criterion.selector
end
selection(criterion) do |selector, field, value|
expression = prepare(field, operator, value)
existing = selector[field]
if existing.respond_to?(:merge!)
selector.store(field, existing.merge!(expression))
else
selector.store(field, expression)
end
end
end
# Adds the criterion to the existing selection.
#
# @api private
#
# @example Add the criterion.
# mergeable.__union__([ 1, 2 ], "$in")
#
# @param [ Hash ] criterion The criteria.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Mergeable ] The new mergeable.
def __union__(criterion, operator)
with_strategy(:__union__, criterion, operator)
end
# Use the named strategy for the next operation.
#
# @api private
#
# @example Use intersection.
# mergeable.use(:__intersect__)
#
# @param [ Symbol ] strategy The strategy to use.
#
# @return [ Mergeable ] The existing mergeable.
def use(strategy)
tap do |mergeable|
mergeable.strategy = strategy
end
end
# Add criterion to the selection with the named strategy.
#
# @api private
#
# @example Add criterion with a strategy.
# mergeable.with_strategy(:__union__, {field_name: [ 1, 2, 3 ]}, "$in")
#
# @param [ Symbol ] strategy The name of the strategy method.
# @param [ Object ] criterion The criterion to add.
# @param [ String ] operator The MongoDB operator.
#
# @return [ Mergeable ] The cloned query.
def with_strategy(strategy, criterion, operator)
selection(criterion) do |selector, field, value|
selector.store(
field,
selector[field].send(strategy, prepare(field, operator, value))
)
end
end
# Prepare the value for merging.
#
# @api private
#
# @example Prepare the value.
# mergeable.prepare("field", "$gt", 10)
#
# @param [ String ] field The name of the field.
# @param [ Object ] value The value.
#
# @return [ Object ] The serialized value.
def prepare(field, operator, value)
unless operator =~ /exists|type|size/
value = value.__expand_complex__
field = field.to_s
name = aliases[field] || field
serializer = serializers[name]
value = serializer ? serializer.evolve(value) : value
end
selection = { operator => value }
negating? ? { "$not" => selection } : selection
end
end
end
end
end