parse-stack-next 5.7.6 → 5.8.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/CHANGELOG.md +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
data/lib/parse/query.rb
CHANGED
|
@@ -209,8 +209,8 @@ module Parse
|
|
|
209
209
|
# # Valid values: :primary, :primary_preferred, :secondary, :secondary_preferred, :nearest
|
|
210
210
|
# @return [Symbol, String] the read preference for this query.
|
|
211
211
|
attr_reader :table, :session_token
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
attr_accessor :key, :cache, :verbose_aggregate, :read_preference
|
|
213
|
+
attr_reader :use_master_key
|
|
214
214
|
|
|
215
215
|
# We have a special class method to handle field formatting. This turns
|
|
216
216
|
# the symbol keys in an operand from one key to another. For example, we can
|
|
@@ -225,6 +225,32 @@ module Parse
|
|
|
225
225
|
@field_formatter = :columnize
|
|
226
226
|
@allow_scope_introspection = false
|
|
227
227
|
|
|
228
|
+
# Fiber-local scope used by {Parse::Query.format_field} to honor a
|
|
229
|
+
# model's explicit `field:` names while a query compiles.
|
|
230
|
+
# Stored in inheritable fiber storage (`Fiber[]`), so a fiber or thread
|
|
231
|
+
# started while a query compiles sees the same names, and an assignment in
|
|
232
|
+
# the child never leaks back to the parent. The value is a
|
|
233
|
+
# {FieldAliasFrame} (or nil when no query is compiling). The frame is
|
|
234
|
+
# closed when the block that opened it returns, so a long-lived thread
|
|
235
|
+
# started during a compile stops using that query's names afterwards.
|
|
236
|
+
FIELD_ALIAS_SCOPE_KEY = :parse_query_field_alias_scope
|
|
237
|
+
EMPTY_FIELD_ALIASES = {}.freeze
|
|
238
|
+
FIELD_ALIAS_CACHE_MAX = 1_000
|
|
239
|
+
FIELD_ALIAS_CACHE_MUTEX = Mutex.new
|
|
240
|
+
# @!visibility private
|
|
241
|
+
# One cached alias map for a table, stamped with the model registry
|
|
242
|
+
# generation and the model's field_map size it was built from.
|
|
243
|
+
FieldAliasScope = Struct.new(:table, :aliases, :generation, :klass, :field_count)
|
|
244
|
+
# @!visibility private
|
|
245
|
+
# One {with_field_aliases} activation: the scope plus whether the block
|
|
246
|
+
# that set it is still running.
|
|
247
|
+
FieldAliasFrame = Struct.new(:scope, :aliases, :open)
|
|
248
|
+
# Per-table {FieldAliasScope} cache. Replaced (never mutated) under the
|
|
249
|
+
# mutex, so readers need no lock.
|
|
250
|
+
@field_alias_cache = {}.freeze
|
|
251
|
+
# System fields with their own handling, never treated as aliases.
|
|
252
|
+
FIELD_ALIAS_BUILTINS = %w[id created_at updated_at acl].freeze
|
|
253
|
+
|
|
228
254
|
# The set of symbol keys that {#conditions} treats as query-shape
|
|
229
255
|
# options (cache TTL, ordering, limits, ACL convenience helpers,
|
|
230
256
|
# session/master-key overrides) rather than as field-name
|
|
@@ -303,14 +329,159 @@ module Parse
|
|
|
303
329
|
|
|
304
330
|
# @param str [String] the string to format
|
|
305
331
|
# @return [String] formatted string using {Parse::Query.field_formatter}.
|
|
332
|
+
# While a query is compiling, a name the query's model declares with
|
|
333
|
+
# an explicit `field:` (for example `property :account_id, field:
|
|
334
|
+
# :account_id` or `field: :authId_sub`) is returned exactly as
|
|
335
|
+
# declared, whether the caller used the Ruby name or the remote name.
|
|
306
336
|
def format_field(str)
|
|
307
337
|
res = str.to_s.strip
|
|
338
|
+
frame = Fiber[FIELD_ALIAS_SCOPE_KEY]
|
|
339
|
+
if frame && frame.open && (mapped = frame.aliases[res])
|
|
340
|
+
return mapped
|
|
341
|
+
end
|
|
308
342
|
if field_formatter.present? && res.respond_to?(field_formatter)
|
|
309
343
|
res = res.send(field_formatter)
|
|
310
344
|
end
|
|
311
345
|
res
|
|
312
346
|
end
|
|
313
347
|
|
|
348
|
+
# Run the block with `table`'s explicit field aliases in effect for
|
|
349
|
+
# {format_field}. Always sets the scope (possibly to an empty map), so
|
|
350
|
+
# a subquery on another class, compiled inside an outer query, uses its
|
|
351
|
+
# own model's names. Re-entering for the table already in scope just
|
|
352
|
+
# yields. The scope lives in inheritable fiber storage, so concurrent
|
|
353
|
+
# queries on other threads or fibers are isolated.
|
|
354
|
+
#
|
|
355
|
+
# @param table [String] the Parse class name.
|
|
356
|
+
# @return the block's value
|
|
357
|
+
def with_field_aliases(table)
|
|
358
|
+
table = table.to_s unless table.nil? || table.is_a?(String)
|
|
359
|
+
previous = Fiber[FIELD_ALIAS_SCOPE_KEY]
|
|
360
|
+
return yield if table == (previous && previous.open ? previous.scope.table : nil)
|
|
361
|
+
scope = field_alias_scope_for(table)
|
|
362
|
+
frame = FieldAliasFrame.new(scope, scope.aliases, true)
|
|
363
|
+
begin
|
|
364
|
+
Fiber[FIELD_ALIAS_SCOPE_KEY] = frame
|
|
365
|
+
yield
|
|
366
|
+
ensure
|
|
367
|
+
frame.open = false
|
|
368
|
+
Fiber[FIELD_ALIAS_SCOPE_KEY] = previous
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# @!visibility private
|
|
373
|
+
# The {FieldAliasScope} in effect, or nil. A frame inherited from a
|
|
374
|
+
# block that has since returned is ignored.
|
|
375
|
+
# @return [FieldAliasScope, nil]
|
|
376
|
+
def current_field_alias_scope
|
|
377
|
+
frame = Fiber[FIELD_ALIAS_SCOPE_KEY]
|
|
378
|
+
frame.scope if frame && frame.open
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# @!visibility private
|
|
382
|
+
# The Parse class whose aliases are in scope, or nil.
|
|
383
|
+
# @return [String, nil]
|
|
384
|
+
def field_alias_table
|
|
385
|
+
current_field_alias_scope&.table
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# @!visibility private
|
|
389
|
+
# Wrap a caller's block so it runs with the alias scope that was active
|
|
390
|
+
# before the query method opened its own. Without this, a block passed
|
|
391
|
+
# to `results`, `first`, `each`, and similar would format another
|
|
392
|
+
# class's keys (a pointer `fetch(keys:)`, a cursor, a nested query
|
|
393
|
+
# helper) with the outer model's names.
|
|
394
|
+
#
|
|
395
|
+
# @param blk [Proc, nil] the caller's block.
|
|
396
|
+
# @param table [String, nil] the table the scope is about to be set to.
|
|
397
|
+
# @return [Proc, nil]
|
|
398
|
+
def block_outside_field_aliases(blk, table)
|
|
399
|
+
return blk if blk.nil?
|
|
400
|
+
outer = Fiber[FIELD_ALIAS_SCOPE_KEY]
|
|
401
|
+
table = table.to_s unless table.nil? || table.is_a?(String)
|
|
402
|
+
# Re-entrant call for the table already in scope: the block either
|
|
403
|
+
# belongs to the caller that opened it or is internal.
|
|
404
|
+
return blk if table == (outer && outer.open ? outer.scope.table : nil)
|
|
405
|
+
wrapped = proc do |*args, &inner|
|
|
406
|
+
scoped = Fiber[FIELD_ALIAS_SCOPE_KEY]
|
|
407
|
+
Fiber[FIELD_ALIAS_SCOPE_KEY] = outer
|
|
408
|
+
begin
|
|
409
|
+
blk.call(*args, &inner)
|
|
410
|
+
ensure
|
|
411
|
+
Fiber[FIELD_ALIAS_SCOPE_KEY] = scoped
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
wrapped.ruby2_keywords
|
|
415
|
+
wrapped
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# The explicit remote names a model declares: every `field_map` entry
|
|
419
|
+
# whose remote name differs from what {format_field} would produce
|
|
420
|
+
# for the Ruby name. Maps both the Ruby name and the remote name to the
|
|
421
|
+
# remote name, so `where(account_id:)` and `where("account_id" =>)`
|
|
422
|
+
# both compile to the declared column. Names the model does not alias
|
|
423
|
+
# are absent, so they keep the default formatting.
|
|
424
|
+
#
|
|
425
|
+
# Cached per table. An entry is reused until a model is defined, a
|
|
426
|
+
# `parse_class` is set, or a field is declared (see
|
|
427
|
+
# {Parse::Model.model_generation}), or until the model's `field_map`
|
|
428
|
+
# grows (associations add entries there directly).
|
|
429
|
+
#
|
|
430
|
+
# @param table [String]
|
|
431
|
+
# @return [Hash{String => String}]
|
|
432
|
+
def field_aliases_for(table)
|
|
433
|
+
field_alias_scope_for(table).aliases
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# @!visibility private
|
|
437
|
+
# @param table [String, nil]
|
|
438
|
+
# @return [FieldAliasScope] the cached scope for `table`.
|
|
439
|
+
def field_alias_scope_for(table)
|
|
440
|
+
key = table.to_s
|
|
441
|
+
generation = Parse::Model.model_generation
|
|
442
|
+
entry = @field_alias_cache[key]
|
|
443
|
+
if entry && entry.generation == generation &&
|
|
444
|
+
(entry.klass.nil? || entry.klass.field_map.size == entry.field_count)
|
|
445
|
+
return entry
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
klass = (Parse::Model.find_class(key) rescue nil)
|
|
449
|
+
klass = nil unless klass.respond_to?(:field_map)
|
|
450
|
+
aliases = klass ? build_field_aliases(klass.field_map) : EMPTY_FIELD_ALIASES
|
|
451
|
+
entry = FieldAliasScope.new(table, aliases, generation, klass,
|
|
452
|
+
klass ? klass.field_map.size : 0).freeze
|
|
453
|
+
FIELD_ALIAS_CACHE_MUTEX.synchronize do
|
|
454
|
+
cache = @field_alias_cache
|
|
455
|
+
cache = {} if cache.size >= FIELD_ALIAS_CACHE_MAX
|
|
456
|
+
@field_alias_cache = cache.merge(key => entry).freeze
|
|
457
|
+
end
|
|
458
|
+
entry
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# @!visibility private
|
|
462
|
+
# The query-side view of {Parse::Model.field_resolution_map}: the same
|
|
463
|
+
# resolution rule (Ruby property name first, then an exact declared
|
|
464
|
+
# column), keeping only the names whose column differs from the default
|
|
465
|
+
# lowerCamelCase form. Every other name keeps {field_formatter}
|
|
466
|
+
# (including nil) exactly as before.
|
|
467
|
+
#
|
|
468
|
+
# @param fmap [Hash{Symbol => Symbol}] a model's field_map.
|
|
469
|
+
# @return [Hash{String => String}] frozen alias map.
|
|
470
|
+
def build_field_aliases(fmap)
|
|
471
|
+
# Built-in system fields keep their existing handling, under both
|
|
472
|
+
# their Ruby and their column names.
|
|
473
|
+
builtin_wires = FIELD_ALIAS_BUILTINS.filter_map { |b| fmap[b.to_sym]&.to_s }
|
|
474
|
+
aliases = {}
|
|
475
|
+
Parse::Model.field_resolution_map(fmap).each do |name, wire|
|
|
476
|
+
next if wire == name.columnize
|
|
477
|
+
next if FIELD_ALIAS_BUILTINS.include?(name) || builtin_wires.include?(name)
|
|
478
|
+
# Never let an alias address an internal Parse Server column.
|
|
479
|
+
next if wire.start_with?("_")
|
|
480
|
+
aliases[name] = wire
|
|
481
|
+
end
|
|
482
|
+
aliases.empty? ? EMPTY_FIELD_ALIASES : aliases.freeze
|
|
483
|
+
end
|
|
484
|
+
|
|
314
485
|
# Convert camelCase string to snake_case
|
|
315
486
|
# @param str [String] the camelCase string
|
|
316
487
|
# @return [String] the snake_case string
|
|
@@ -399,29 +570,157 @@ module Parse
|
|
|
399
570
|
constraint_reduce(where)
|
|
400
571
|
end
|
|
401
572
|
|
|
573
|
+
# SDK routing markers that a REST `$or` branch cannot carry.
|
|
574
|
+
# @!visibility private
|
|
575
|
+
OR_BRANCH_MARKERS = %w[__aggregation_pipeline __mongo_direct_only].freeze
|
|
576
|
+
|
|
402
577
|
# @!visibility private
|
|
578
|
+
# Compile one branch of an OR group. A constraint that only works
|
|
579
|
+
# through the aggregation pipeline or mongo-direct (`readable_by`,
|
|
580
|
+
# `:ACL.readable_by`, `:tags.array_size`, the mongo-direct geo
|
|
581
|
+
# operators) compiles to a routing marker that {compile_where}
|
|
582
|
+
# strips. Inside an OR that would turn the branch into `{}`, which
|
|
583
|
+
# matches every row, or silently drop part of it. Such a branch is
|
|
584
|
+
# refused instead.
|
|
585
|
+
# @param where [Array<Parse::Constraint>]
|
|
586
|
+
# @return [Hash] the compiled branch.
|
|
587
|
+
# @raise [ArgumentError] when the branch carries a routing marker.
|
|
588
|
+
def compile_or_branch(where)
|
|
589
|
+
markers = compile_markers(where)
|
|
590
|
+
if or_branch_marker?(markers)
|
|
591
|
+
names = pipeline_only_constraint_names(where)
|
|
592
|
+
raise ArgumentError,
|
|
593
|
+
"This constraint is not supported inside an OR " \
|
|
594
|
+
"(#{names.empty? ? "pipeline-only constraint" : names.join(", ")}): it needs the " \
|
|
595
|
+
"aggregation pipeline or mongo-direct, and a REST `$or` branch cannot carry it. " \
|
|
596
|
+
"Apply it outside the OR, or run the OR branches as separate queries."
|
|
597
|
+
end
|
|
598
|
+
compile_where(where)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# @!visibility private
|
|
602
|
+
# Names (`field.operator`) of the constraints in a where list that
|
|
603
|
+
# compile to a pipeline or mongo-direct routing marker.
|
|
604
|
+
# @return [Array<String>]
|
|
605
|
+
def pipeline_only_constraint_names(where)
|
|
606
|
+
Array(where).select { |c| c.is_a?(Parse::Constraint) && or_branch_marker?(c.as_json) }
|
|
607
|
+
.map do |c|
|
|
608
|
+
field = c.operand.to_s.casecmp?("acl") ? "ACL" : c.operand.to_s
|
|
609
|
+
c.operation.respond_to?(:operator) ? "#{field}.#{c.operation.operator}" : field
|
|
610
|
+
end.uniq
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# @!visibility private
|
|
614
|
+
def or_branch_marker?(value)
|
|
615
|
+
case value
|
|
616
|
+
when Hash
|
|
617
|
+
value.any? { |k, v| OR_BRANCH_MARKERS.include?(k.to_s) || or_branch_marker?(v) }
|
|
618
|
+
when Array
|
|
619
|
+
value.any? { |v| or_branch_marker?(v) }
|
|
620
|
+
else
|
|
621
|
+
false
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# @!visibility private
|
|
626
|
+
# Reduce a list of constraints into one where hash. Constraints on the
|
|
627
|
+
# same field are combined so every one of them still applies:
|
|
628
|
+
#
|
|
629
|
+
# * Operator hashes with distinct operators merge
|
|
630
|
+
# (`:plays.gt => 1` and `:plays.lt => 9` give `{"$gt" => 1, "$lt" => 9}`).
|
|
631
|
+
# * An equality next to operators becomes `$eq`
|
|
632
|
+
# (`:plays => 5` and `:plays.gt => 1` give `{"$eq" => 5, "$gt" => 1}`).
|
|
633
|
+
# * Anything that cannot merge without changing its meaning (the same
|
|
634
|
+
# operator with a different value, two different equalities, two
|
|
635
|
+
# regular expressions, two `$or` groups) is kept in a top-level `$and`,
|
|
636
|
+
# so both conditions must hold. The first constraint keeps the field
|
|
637
|
+
# key and each later conflicting one is appended to `$and`.
|
|
638
|
+
#
|
|
639
|
+
# Identical constraints collapse to one.
|
|
403
640
|
def constraint_reduce(clauses)
|
|
404
|
-
# @todo Need to add proper constraint merging
|
|
405
641
|
clauses.reduce({}) do |clause, subclause|
|
|
406
|
-
#puts "Merging Subclause: #{subclause.as_json}"
|
|
407
|
-
|
|
408
642
|
subclause_json = subclause.as_json || {}
|
|
643
|
+
subclause_json.each { |key, value| merge_constraint_entry!(clause, key, value) }
|
|
644
|
+
clause
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Operators whose meaning depends on a partner operator in the same
|
|
649
|
+
# hash. Two hashes that both use one of a group are never merged.
|
|
650
|
+
COUPLED_OPERATOR_GROUPS = [
|
|
651
|
+
%w[$regex $options],
|
|
652
|
+
%w[$near $nearSphere $maxDistance $maxDistanceInRadians $maxDistanceInMiles
|
|
653
|
+
$maxDistanceInKilometers $geoWithin $geoIntersects $within],
|
|
654
|
+
%w[$text $search],
|
|
655
|
+
].freeze
|
|
409
656
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
657
|
+
# @!visibility private
|
|
658
|
+
def merge_constraint_entry!(clause, key, value)
|
|
659
|
+
unless clause.key?(key)
|
|
660
|
+
clause[key] = value.is_a?(Array) && key == "__aggregation_pipeline" ? value.dup : value
|
|
661
|
+
return
|
|
662
|
+
end
|
|
663
|
+
existing = clause[key]
|
|
664
|
+
return if existing == value
|
|
665
|
+
|
|
666
|
+
if key == "__aggregation_pipeline" || (key == "$and" && existing.is_a?(Array) && value.is_a?(Array))
|
|
667
|
+
clause[key] = existing + value
|
|
668
|
+
elsif key.is_a?(String) && key.start_with?("__")
|
|
669
|
+
# SDK-internal routing markers keep their previous merge behavior.
|
|
670
|
+
clause[key] = existing.is_a?(Hash) && value.is_a?(Hash) ? existing.deep_merge(value) : value
|
|
671
|
+
elsif operator_hash?(existing) && operator_hash?(value)
|
|
672
|
+
if mergeable_operator_hashes?(existing, value)
|
|
673
|
+
clause[key] = existing.merge(value)
|
|
417
674
|
else
|
|
418
|
-
|
|
675
|
+
append_and_constraint!(clause, key, value)
|
|
419
676
|
end
|
|
420
|
-
|
|
421
|
-
|
|
677
|
+
elsif operator_hash?(existing) && !operator_value_key?(key)
|
|
678
|
+
eq = { "$eq" => value }
|
|
679
|
+
if mergeable_operator_hashes?(existing, eq)
|
|
680
|
+
clause[key] = existing.merge(eq)
|
|
681
|
+
else
|
|
682
|
+
append_and_constraint!(clause, key, value)
|
|
683
|
+
end
|
|
684
|
+
elsif operator_hash?(value) && !operator_value_key?(key)
|
|
685
|
+
eq = { "$eq" => existing }
|
|
686
|
+
if mergeable_operator_hashes?(eq, value)
|
|
687
|
+
clause[key] = eq.merge(value)
|
|
688
|
+
else
|
|
689
|
+
append_and_constraint!(clause, key, value)
|
|
690
|
+
end
|
|
691
|
+
else
|
|
692
|
+
append_and_constraint!(clause, key, value)
|
|
422
693
|
end
|
|
423
694
|
end
|
|
424
695
|
|
|
696
|
+
# @!visibility private
|
|
697
|
+
def append_and_constraint!(clause, key, value)
|
|
698
|
+
list = clause["$and"]
|
|
699
|
+
clause["$and"] = (list.is_a?(Array) ? list : []) + [{ key => value }]
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# @!visibility private
|
|
703
|
+
# A top-level operator key (`$or`, `$relatedTo`) holds a value, not a
|
|
704
|
+
# field constraint.
|
|
705
|
+
def operator_value_key?(key)
|
|
706
|
+
key.to_s.start_with?("$")
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# @!visibility private
|
|
710
|
+
def operator_hash?(value)
|
|
711
|
+
value.is_a?(Hash) && !value.empty? && value.each_key.all? { |k| k.to_s.start_with?("$") }
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# @!visibility private
|
|
715
|
+
def mergeable_operator_hashes?(a, b)
|
|
716
|
+
a_ops = a.transform_keys(&:to_s)
|
|
717
|
+
b_ops = b.transform_keys(&:to_s)
|
|
718
|
+
a_keys = a_ops.keys
|
|
719
|
+
b_keys = b_ops.keys
|
|
720
|
+
return false if (a_keys & b_keys).any? { |op| a_ops[op] != b_ops[op] }
|
|
721
|
+
COUPLED_OPERATOR_GROUPS.none? { |group| (a_keys & group).any? && (b_keys & group).any? }
|
|
722
|
+
end
|
|
723
|
+
|
|
425
724
|
# Applies special singleton methods to a query instance in order to
|
|
426
725
|
# automatically fetch results when using any ruby console.
|
|
427
726
|
# @!visibility private
|
|
@@ -441,6 +740,14 @@ module Parse
|
|
|
441
740
|
@client ||= self.class.client
|
|
442
741
|
end
|
|
443
742
|
|
|
743
|
+
# Set the client this query runs through. A client from
|
|
744
|
+
# {Parse::Client#become} carries a session, so on a query combined
|
|
745
|
+
# with OR the change is checked like any other change of authority.
|
|
746
|
+
# @param value [Parse::Client]
|
|
747
|
+
def client=(value)
|
|
748
|
+
guard_or_scope_change { @client = value }
|
|
749
|
+
end
|
|
750
|
+
|
|
444
751
|
# Clear a specific clause of this query. This can be one of: :where, :order,
|
|
445
752
|
# :includes, :skip, :limit, :count, :keys or :results.
|
|
446
753
|
# @param item [:Symbol] the clause to clear.
|
|
@@ -521,6 +828,11 @@ module Parse
|
|
|
521
828
|
# and their value.
|
|
522
829
|
# @return [self]
|
|
523
830
|
def conditions(expressions = {})
|
|
831
|
+
# Apply `:or` last, so a `:session` or `:use_master_key` in the same
|
|
832
|
+
# Hash is in place before the branches' authority is compared.
|
|
833
|
+
if expressions.is_a?(Hash) && expressions.key?(:or) && expressions.keys.last != :or
|
|
834
|
+
expressions = expressions.reject { |k, _| k == :or }.merge(or: expressions[:or])
|
|
835
|
+
end
|
|
524
836
|
expressions.each do |expression, value|
|
|
525
837
|
# Normalize to symbol for comparison (handles both string and symbol keys)
|
|
526
838
|
expr_sym = expression.respond_to?(:to_sym) ? expression.to_sym : expression
|
|
@@ -588,6 +900,9 @@ module Parse
|
|
|
588
900
|
not_publicly_readable if value
|
|
589
901
|
elsif expr_sym == :not_publicly_writable
|
|
590
902
|
not_publicly_writable if value
|
|
903
|
+
elsif expression == :or
|
|
904
|
+
# Symbol key only: a string "or" stays a literal field name.
|
|
905
|
+
add_or_branches(value)
|
|
591
906
|
else
|
|
592
907
|
add_constraint(expression, value)
|
|
593
908
|
end
|
|
@@ -595,6 +910,114 @@ module Parse
|
|
|
595
910
|
self #chaining
|
|
596
911
|
end
|
|
597
912
|
|
|
913
|
+
# @!visibility private
|
|
914
|
+
# Handle the `:or => [branch, ...]` condition. Each branch is a
|
|
915
|
+
# constraint Hash, a {Parse::Query}, or an Array of {Parse::Constraint}.
|
|
916
|
+
# The branches are ORed together and the group is ANDed with the
|
|
917
|
+
# query's other constraints. A branch with no constraints matches every
|
|
918
|
+
# row, so the group adds no constraint. An empty list matches no rows.
|
|
919
|
+
# @param branches [Array]
|
|
920
|
+
def add_or_branches(branches)
|
|
921
|
+
unless branches.is_a?(Array)
|
|
922
|
+
raise ArgumentError, ":or expects an Array of constraint hashes or queries, got #{branches.class}."
|
|
923
|
+
end
|
|
924
|
+
with_or_rollback { add_or_branches_unguarded(branches) }
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# @!visibility private
|
|
928
|
+
def add_or_branches_unguarded(branches)
|
|
929
|
+
wheres = branches.map do |branch|
|
|
930
|
+
case branch
|
|
931
|
+
when Parse::Query
|
|
932
|
+
unless branch.table == @table
|
|
933
|
+
raise ArgumentError, ":or branch queries must be of the same class #{@table} (got #{branch.table})."
|
|
934
|
+
end
|
|
935
|
+
# The branch's authority travels with its conditions, or raises
|
|
936
|
+
# when it differs from this query's.
|
|
937
|
+
merge_or_scope!(branch)
|
|
938
|
+
branch.where
|
|
939
|
+
when Hash then or_branch_hash_where(branch)
|
|
940
|
+
when Array
|
|
941
|
+
# A branch given as a list: constraints, or constraint hashes that
|
|
942
|
+
# are expanded the same way a Hash branch is. Anything else is
|
|
943
|
+
# refused, so a typo cannot empty the branch and turn it into
|
|
944
|
+
# match-all.
|
|
945
|
+
branch.flat_map do |item|
|
|
946
|
+
case item
|
|
947
|
+
when Parse::Constraint then [item]
|
|
948
|
+
when Hash then or_branch_hash_where(item)
|
|
949
|
+
else
|
|
950
|
+
raise ArgumentError, ":or Array branches may only hold Parse::Constraint objects or constraint hashes, got #{item.class}."
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
else
|
|
954
|
+
raise ArgumentError, ":or branches must be constraint hashes or Parse::Query objects, got #{branch.class}."
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
if wheres.empty?
|
|
958
|
+
# `$or: []` is rejected by MongoDB. Keep the "no rows" meaning.
|
|
959
|
+
add_constraint(:objectId.in, [])
|
|
960
|
+
return
|
|
961
|
+
end
|
|
962
|
+
# Refuse pipeline-only constraints in any branch before deciding
|
|
963
|
+
# whether the group is match-all.
|
|
964
|
+
compiled = wheres.map { |w| Parse::Query.compile_or_branch(w) }
|
|
965
|
+
return if wheres.any? { |w| Parse::Query.match_all?(w) }
|
|
966
|
+
add_constraint(Parse::Constraint::CompoundQueryConstraint.new(:or, compiled), nil, filter: false)
|
|
967
|
+
end
|
|
968
|
+
private :add_or_branches_unguarded
|
|
969
|
+
|
|
970
|
+
# @!visibility private
|
|
971
|
+
# Query options that shape a whole query and mean nothing inside one OR
|
|
972
|
+
# branch.
|
|
973
|
+
OR_BRANCH_OPTION_KEYS = %i[order keys key skip limit include includes cache read_preference].freeze
|
|
974
|
+
|
|
975
|
+
# @!visibility private
|
|
976
|
+
# The constraints of a Hash OR branch. The branch is built as its own
|
|
977
|
+
# query so a `session:` / `use_master_key:` key or a nested `:or` with a
|
|
978
|
+
# scoped query is applied there, and its authority then has to agree
|
|
979
|
+
# with this query's (adopted when this query has none, refused when it
|
|
980
|
+
# differs). Query-shaping options inside a branch are refused.
|
|
981
|
+
# @param hash [Hash]
|
|
982
|
+
# @return [Array<Parse::Constraint>]
|
|
983
|
+
def or_branch_hash_where(hash)
|
|
984
|
+
options = hash.keys.select { |k| k.is_a?(Symbol) && OR_BRANCH_OPTION_KEYS.include?(k) }
|
|
985
|
+
if options.any?
|
|
986
|
+
raise ArgumentError,
|
|
987
|
+
"#{options.map(&:inspect).join(", ")} cannot be set inside an OR branch; " \
|
|
988
|
+
"set it on the query itself."
|
|
989
|
+
end
|
|
990
|
+
branch = Parse::Query.new(@table, hash)
|
|
991
|
+
merge_or_scope!(branch)
|
|
992
|
+
branch.where
|
|
993
|
+
end
|
|
994
|
+
private :or_branch_hash_where
|
|
995
|
+
|
|
996
|
+
# @!visibility private
|
|
997
|
+
# Run an OR or subquery merge so that a raise leaves this query exactly
|
|
998
|
+
# as it was: constraints, authority, client, and the pinned authority.
|
|
999
|
+
def with_or_rollback
|
|
1000
|
+
# Captured before the guarded block: a failure here changes nothing,
|
|
1001
|
+
# so there is nothing to restore.
|
|
1002
|
+
ivars = OR_SCOPE_STATE_IVARS + %i[@_or_branch_scope @_or_branch_app @read_preference]
|
|
1003
|
+
saved = ivars.to_h { |iv| [iv, [instance_variable_defined?(iv), instance_variable_get(iv)]] }
|
|
1004
|
+
saved_where = @where.dup
|
|
1005
|
+
begin
|
|
1006
|
+
yield
|
|
1007
|
+
rescue StandardError
|
|
1008
|
+
saved.each do |iv, (defined, value)|
|
|
1009
|
+
if defined
|
|
1010
|
+
instance_variable_set(iv, value)
|
|
1011
|
+
elsif instance_variable_defined?(iv)
|
|
1012
|
+
remove_instance_variable(iv)
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
@where = saved_where
|
|
1016
|
+
raise
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
private :with_or_rollback
|
|
1020
|
+
|
|
598
1021
|
alias_method :query, :conditions
|
|
599
1022
|
alias_method :append, :conditions
|
|
600
1023
|
|
|
@@ -607,11 +1030,16 @@ module Parse
|
|
|
607
1030
|
value = value.session_token
|
|
608
1031
|
end
|
|
609
1032
|
|
|
610
|
-
|
|
611
|
-
@session_token = value
|
|
612
|
-
else
|
|
1033
|
+
unless value.nil? || (value.is_a?(String) && value.present?)
|
|
613
1034
|
raise ArgumentError, "Invalid session token passed to query."
|
|
614
1035
|
end
|
|
1036
|
+
guard_or_scope_change { @session_token = value }
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# @param value [Boolean, nil] whether to send the master key (nil
|
|
1040
|
+
# leaves the decision to the request layer).
|
|
1041
|
+
def use_master_key=(value)
|
|
1042
|
+
guard_or_scope_change { @use_master_key = value }
|
|
615
1043
|
end
|
|
616
1044
|
|
|
617
1045
|
# returns the query clause for the particular clause
|
|
@@ -761,11 +1189,16 @@ module Parse
|
|
|
761
1189
|
case entry
|
|
762
1190
|
when Order
|
|
763
1191
|
entry.field = Query.format_field(entry.field)
|
|
764
|
-
|
|
765
|
-
when
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1192
|
+
push_order(entry)
|
|
1193
|
+
when String
|
|
1194
|
+
if entry.include?(",")
|
|
1195
|
+
# The REST sort string lists several fields: "title,-plays".
|
|
1196
|
+
order(*entry.split(",").map(&:strip).reject(&:empty?))
|
|
1197
|
+
next
|
|
1198
|
+
end
|
|
1199
|
+
push_order(parse_order_string(entry))
|
|
1200
|
+
when Symbol
|
|
1201
|
+
push_order(parse_order_string(entry))
|
|
769
1202
|
when Hash
|
|
770
1203
|
entry.each do |field, direction|
|
|
771
1204
|
dir_sym = direction.is_a?(String) ? direction.downcase.to_sym : direction
|
|
@@ -776,7 +1209,7 @@ module Parse
|
|
|
776
1209
|
end
|
|
777
1210
|
o = Order.new(field, dir_sym)
|
|
778
1211
|
o.field = Query.format_field(o.field)
|
|
779
|
-
|
|
1212
|
+
push_order(o)
|
|
780
1213
|
end
|
|
781
1214
|
else
|
|
782
1215
|
raise ArgumentError,
|
|
@@ -789,6 +1222,38 @@ module Parse
|
|
|
789
1222
|
self #chaining
|
|
790
1223
|
end #order
|
|
791
1224
|
|
|
1225
|
+
# @!visibility private
|
|
1226
|
+
# One field of the REST sort-string form: "-title" is descending and
|
|
1227
|
+
# "+title" ascending. Parsing the sign here keeps a repeated field
|
|
1228
|
+
# deduplicated and gives the direct `$sort` stage a real field name.
|
|
1229
|
+
# @return [Parse::Order]
|
|
1230
|
+
def parse_order_string(entry)
|
|
1231
|
+
name = entry.to_s
|
|
1232
|
+
o = if name.start_with?("-") && name.length > 1
|
|
1233
|
+
Order.new(name[1..], :desc)
|
|
1234
|
+
elsif name.start_with?("+") && name.length > 1
|
|
1235
|
+
Order.new(name[1..], :asc)
|
|
1236
|
+
else
|
|
1237
|
+
Order.new(entry)
|
|
1238
|
+
end
|
|
1239
|
+
o.field = Query.format_field(o.field)
|
|
1240
|
+
o
|
|
1241
|
+
end
|
|
1242
|
+
private :parse_order_string
|
|
1243
|
+
|
|
1244
|
+
# @!visibility private
|
|
1245
|
+
# Add one sort key. A field that is already in the order keeps its
|
|
1246
|
+
# position and takes the new direction, so it is sent once. Parse Server
|
|
1247
|
+
# and MongoDB both resolve a repeated sort key the same way.
|
|
1248
|
+
def push_order(order)
|
|
1249
|
+
index = @order.index { |existing| existing.field.to_s == order.field.to_s }
|
|
1250
|
+
if index
|
|
1251
|
+
@order[index] = order
|
|
1252
|
+
else
|
|
1253
|
+
@order.push order
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
|
|
792
1257
|
# Use with limit to paginate through results. Default is 0.
|
|
793
1258
|
# @example
|
|
794
1259
|
# # get the next 3 songs after the first 10
|
|
@@ -937,6 +1402,7 @@ module Parse
|
|
|
937
1402
|
# @return [self]
|
|
938
1403
|
def add_constraints(list)
|
|
939
1404
|
list = Array.wrap(list).select { |m| m.is_a?(Parse::Constraint) }
|
|
1405
|
+
list.each { |c| merge_subquery_scopes!(c.value) }
|
|
940
1406
|
@where = @where + list
|
|
941
1407
|
self
|
|
942
1408
|
end
|
|
@@ -975,11 +1441,31 @@ module Parse
|
|
|
975
1441
|
constraint.operand = Query.format_field(constraint.operand)
|
|
976
1442
|
end
|
|
977
1443
|
reject_vector_constraint!(constraint)
|
|
1444
|
+
merge_subquery_scopes!(constraint.value)
|
|
978
1445
|
@where.push constraint
|
|
979
1446
|
@results = nil
|
|
980
1447
|
self #chaining
|
|
981
1448
|
end
|
|
982
1449
|
|
|
1450
|
+
# @!visibility private
|
|
1451
|
+
# Parse Server runs a subquery (`$inQuery`, `$notInQuery`, `$select`,
|
|
1452
|
+
# `$dontSelect`) under the outer request's authority, so a subquery's
|
|
1453
|
+
# own session or scope has to agree with this query's: it is adopted
|
|
1454
|
+
# when this query has none and refused when it differs, the same rule
|
|
1455
|
+
# as an OR branch.
|
|
1456
|
+
def merge_subquery_scopes!(value)
|
|
1457
|
+
subqueries = case value
|
|
1458
|
+
when Parse::Query then [value]
|
|
1459
|
+
when Hash
|
|
1460
|
+
q = value[:query] || value["query"]
|
|
1461
|
+
q.is_a?(Parse::Query) ? [q] : []
|
|
1462
|
+
else []
|
|
1463
|
+
end
|
|
1464
|
+
return if subqueries.empty?
|
|
1465
|
+
with_or_rollback { subqueries.each { |q| merge_or_scope!(q) } }
|
|
1466
|
+
end
|
|
1467
|
+
private :merge_subquery_scopes!
|
|
1468
|
+
|
|
983
1469
|
# @!visibility private
|
|
984
1470
|
# Raise {Parse::VectorSearch::ConstraintNotSupported} when a
|
|
985
1471
|
# constraint targets a declared `:vector` property with an operator
|
|
@@ -1078,24 +1564,41 @@ module Parse
|
|
|
1078
1564
|
# @param where_clauses [Array<Parse::Constraint>] a list of Parse::Constraint objects to combine.
|
|
1079
1565
|
# @return [Query] the combined query with an OR clause.
|
|
1080
1566
|
def or_where(where_clauses = [])
|
|
1081
|
-
|
|
1082
|
-
|
|
1567
|
+
with_or_rollback { or_where_unguarded(where_clauses) }
|
|
1568
|
+
end
|
|
1569
|
+
|
|
1570
|
+
# @!visibility private
|
|
1571
|
+
def or_where_unguarded(where_clauses)
|
|
1572
|
+
if where_clauses.is_a?(Parse::Query)
|
|
1573
|
+
unless where_clauses.table == @table
|
|
1574
|
+
raise ArgumentError, "Parse queries must be of the same class #{@table}."
|
|
1575
|
+
end
|
|
1576
|
+
merge_or_scope!(where_clauses)
|
|
1577
|
+
# A query with no constraints is a branch that matches every row, so
|
|
1578
|
+
# the whole OR does too.
|
|
1579
|
+
return match_all! if Parse::Query.match_all?(where_clauses.where)
|
|
1580
|
+
where_clauses = where_clauses.where
|
|
1581
|
+
end
|
|
1582
|
+
where_clauses = or_branch_hash_where(where_clauses) if where_clauses.is_a?(Hash)
|
|
1083
1583
|
return self if where_clauses.blank?
|
|
1084
|
-
#
|
|
1085
|
-
#
|
|
1086
|
-
|
|
1087
|
-
#
|
|
1088
|
-
|
|
1584
|
+
# Reuse the existing OR only when it is the query's sole constraint.
|
|
1585
|
+
# When other constraints sit beside it, the current where is
|
|
1586
|
+
# `(A or B) and C`, so that whole expression becomes one branch of the
|
|
1587
|
+
# new OR. Appending to the existing OR would silently drop `C`.
|
|
1588
|
+
compound = nil
|
|
1589
|
+
if @where.size == 1 && @where.first.is_a?(Parse::Constraint::CompoundQueryConstraint)
|
|
1590
|
+
compound = @where.first
|
|
1591
|
+
end
|
|
1089
1592
|
# if we don't have a OR clause to reuse, then create a new one with then
|
|
1090
1593
|
# current set of constraints
|
|
1091
1594
|
if compound.blank?
|
|
1092
|
-
initial_constraints = Parse::Query.
|
|
1595
|
+
initial_constraints = Parse::Query.compile_or_branch(@where)
|
|
1093
1596
|
# Only include initial constraints if they're not empty
|
|
1094
1597
|
initial_values = initial_constraints.empty? ? [] : [initial_constraints]
|
|
1095
1598
|
compound = Parse::Constraint::CompoundQueryConstraint.new :or, initial_values
|
|
1096
1599
|
end
|
|
1097
1600
|
# then take the where clauses from the second query and append them.
|
|
1098
|
-
new_constraints = Parse::Query.
|
|
1601
|
+
new_constraints = Parse::Query.compile_or_branch(where_clauses)
|
|
1099
1602
|
# Only add new constraints if they're not empty
|
|
1100
1603
|
unless new_constraints.empty?
|
|
1101
1604
|
compound.value.push new_constraints
|
|
@@ -1104,16 +1607,330 @@ module Parse
|
|
|
1104
1607
|
@where = [compound]
|
|
1105
1608
|
self #chaining
|
|
1106
1609
|
end
|
|
1610
|
+
private :or_where_unguarded
|
|
1107
1611
|
|
|
1612
|
+
# Combine two queries with OR. The result is a copy of the receiver
|
|
1613
|
+
# (its limit, order, keys and includes) whose where is the OR of both.
|
|
1614
|
+
#
|
|
1615
|
+
# * A receiver with no constraints is a starting point, the same as
|
|
1616
|
+
# {#or_where}: `Model.query | q` gives an OR whose only branch is `q`,
|
|
1617
|
+
# so `[q1, q2].reduce(Model.query, :|)` gives `q1 OR q2`.
|
|
1618
|
+
# * A right-hand side with no constraints is a branch that matches
|
|
1619
|
+
# every row, so `q | Model.query` has no constraints.
|
|
1620
|
+
#
|
|
1621
|
+
# The auth scope (session token, master-key flag, `scope_to_user`,
|
|
1622
|
+
# `scope_to_role`) carries over from whichever side set it. Two
|
|
1623
|
+
# different scopes raise `ArgumentError`, since the OR could only run
|
|
1624
|
+
# under one of them.
|
|
1108
1625
|
# @see #or_where
|
|
1109
1626
|
# @return [Query] the combined query with an OR clause.
|
|
1627
|
+
# @raise [ArgumentError] when the classes or auth scopes differ, or a
|
|
1628
|
+
# branch holds a pipeline-only constraint.
|
|
1110
1629
|
def |(other_query)
|
|
1111
1630
|
raise ArgumentError, "Parse queries must be of the same class #{@table}." unless @table == other_query.table
|
|
1112
1631
|
copy_query = self.clone
|
|
1113
|
-
copy_query.
|
|
1632
|
+
copy_query.send(:merge_or_scope!, other_query)
|
|
1633
|
+
if Parse::Query.match_all?(other_query.where)
|
|
1634
|
+
# Still refuse a pipeline-only receiver rather than silently
|
|
1635
|
+
# widening it.
|
|
1636
|
+
Parse::Query.compile_or_branch(@where)
|
|
1637
|
+
copy_query.match_all!
|
|
1638
|
+
else
|
|
1639
|
+
copy_query.or_where other_query.where
|
|
1640
|
+
end
|
|
1114
1641
|
copy_query
|
|
1115
1642
|
end
|
|
1116
1643
|
|
|
1644
|
+
# @!visibility private
|
|
1645
|
+
# Auth scope ivars an OR must agree on.
|
|
1646
|
+
OR_SCOPE_IVARS = %i[session_token use_master_key acl_user acl_role].freeze
|
|
1647
|
+
|
|
1648
|
+
# @!visibility private
|
|
1649
|
+
# The authority this query runs under, as one comparable Hash: the
|
|
1650
|
+
# session token, the scoped user id, the scoped role name, and an
|
|
1651
|
+
# explicit master-key flag. An explicit `use_master_key = false` beside
|
|
1652
|
+
# a session, user, or role adds nothing, since those already run without
|
|
1653
|
+
# the master key. An empty Hash is the default authority.
|
|
1654
|
+
# @return [Hash]
|
|
1655
|
+
def or_effective_scope
|
|
1656
|
+
scope = {}
|
|
1657
|
+
scope[:session] = @session_token unless @session_token.nil?
|
|
1658
|
+
# A client from Parse::Client#become sends its bound session on every
|
|
1659
|
+
# request that names no token and does not ask for the master key,
|
|
1660
|
+
# so it counts as that session. A non-default client with neither a
|
|
1661
|
+
# session nor a master key runs without the master key.
|
|
1662
|
+
own_client = or_explicit_client
|
|
1663
|
+
if own_client && @session_token.nil? && @use_master_key != true
|
|
1664
|
+
bound = own_client.respond_to?(:session_token) ? own_client.session_token : nil
|
|
1665
|
+
if bound.is_a?(String) && !bound.strip.empty?
|
|
1666
|
+
scope[:session] = bound
|
|
1667
|
+
elsif !(own_client.respond_to?(:master_key) && own_client.master_key.present?)
|
|
1668
|
+
scope[:master] = false
|
|
1669
|
+
end
|
|
1670
|
+
end
|
|
1671
|
+
scope[:user] = Parse::Query.or_scope_value(:acl_user, @acl_user) unless @acl_user.nil?
|
|
1672
|
+
scope[:role] = Parse::Query.or_scope_value(:acl_role, @acl_role) unless @acl_role.nil?
|
|
1673
|
+
unless @use_master_key.nil? || (@use_master_key == false && !scope.empty?)
|
|
1674
|
+
scope[:master] = @use_master_key
|
|
1675
|
+
end
|
|
1676
|
+
scope.delete(:master) if scope[:master] == false && scope.size > 1
|
|
1677
|
+
scope
|
|
1678
|
+
end
|
|
1679
|
+
protected :or_effective_scope
|
|
1680
|
+
|
|
1681
|
+
# @!visibility private
|
|
1682
|
+
# The client set on this query when it is not the default client, or
|
|
1683
|
+
# nil. Read from the instance variable so it never memoizes the default.
|
|
1684
|
+
# @return [Parse::Client, nil]
|
|
1685
|
+
def or_explicit_client
|
|
1686
|
+
own = @client
|
|
1687
|
+
return nil if own.nil?
|
|
1688
|
+
default = begin
|
|
1689
|
+
self.class.client
|
|
1690
|
+
rescue StandardError
|
|
1691
|
+
nil
|
|
1692
|
+
end
|
|
1693
|
+
return nil if default && own.equal?(default)
|
|
1694
|
+
own
|
|
1695
|
+
end
|
|
1696
|
+
protected :or_explicit_client
|
|
1697
|
+
|
|
1698
|
+
# @!visibility private
|
|
1699
|
+
# Instance variables that make up a query's authority.
|
|
1700
|
+
OR_SCOPE_STATE_IVARS = %i[@session_token @use_master_key @acl_user @acl_role @client].freeze
|
|
1701
|
+
|
|
1702
|
+
# @!visibility private
|
|
1703
|
+
# Run an assignment that may change this query's authority. On a query
|
|
1704
|
+
# combined with OR, a change that leaves the pinned authority raises
|
|
1705
|
+
# and the previous values are restored, so a rescued error never
|
|
1706
|
+
# leaves the query running under a different principal.
|
|
1707
|
+
def guard_or_scope_change
|
|
1708
|
+
return yield if @_or_branch_scope.nil?
|
|
1709
|
+
saved = OR_SCOPE_STATE_IVARS.to_h { |iv| [iv, instance_variable_get(iv)] }
|
|
1710
|
+
begin
|
|
1711
|
+
yield
|
|
1712
|
+
enforce_or_branch_scope!
|
|
1713
|
+
rescue StandardError
|
|
1714
|
+
saved.each { |iv, v| instance_variable_set(iv, v) }
|
|
1715
|
+
raise
|
|
1716
|
+
end
|
|
1717
|
+
end
|
|
1718
|
+
private :guard_or_scope_change
|
|
1719
|
+
|
|
1720
|
+
# @!visibility private
|
|
1721
|
+
# Combine `other`'s authority with this query's for an OR. Both must
|
|
1722
|
+
# run under the same authority: a query with the default authority
|
|
1723
|
+
# adopts the other's, and two different authorities (a session and a
|
|
1724
|
+
# scoped user, a session and the master key, two users) raise. The
|
|
1725
|
+
# resulting authority is then pinned whichever side carried it, so a
|
|
1726
|
+
# later change to this query's
|
|
1727
|
+
# session, master-key flag, or scope raises instead of running the
|
|
1728
|
+
# other query's rows under a different principal. Read preference is
|
|
1729
|
+
# carried over but a mismatch keeps this query's value.
|
|
1730
|
+
# @param other [Parse::Query]
|
|
1731
|
+
def merge_or_scope!(other)
|
|
1732
|
+
merge_or_application!(other)
|
|
1733
|
+
theirs = other.or_effective_scope
|
|
1734
|
+
if theirs.empty?
|
|
1735
|
+
# The other query has the default authority: pin this query's own
|
|
1736
|
+
# authority, so the result does not depend on operand order.
|
|
1737
|
+
mine = or_effective_scope
|
|
1738
|
+
unless mine.empty?
|
|
1739
|
+
@_or_branch_scope = mine
|
|
1740
|
+
@_or_branch_app = or_application_identity
|
|
1741
|
+
end
|
|
1742
|
+
else
|
|
1743
|
+
mine = or_effective_scope
|
|
1744
|
+
if mine.empty?
|
|
1745
|
+
OR_SCOPE_IVARS.each do |ivar|
|
|
1746
|
+
instance_variable_set(:"@#{ivar}", other.instance_variable_get(:"@#{ivar}"))
|
|
1747
|
+
end
|
|
1748
|
+
adopt_or_client!(other, theirs)
|
|
1749
|
+
# Fail closed if the adopted state still does not reproduce the
|
|
1750
|
+
# other query's authority.
|
|
1751
|
+
unless or_effective_scope == theirs
|
|
1752
|
+
raise ArgumentError,
|
|
1753
|
+
"Cannot combine with OR a query running under #{Parse::Query.describe_or_scope(theirs)} " \
|
|
1754
|
+
"through its own client. Run the queries separately."
|
|
1755
|
+
end
|
|
1756
|
+
elsif mine != theirs
|
|
1757
|
+
raise ArgumentError,
|
|
1758
|
+
"Queries combined with OR must run under the same authority " \
|
|
1759
|
+
"(#{Parse::Query.describe_or_scope(mine)} vs #{Parse::Query.describe_or_scope(theirs)}). " \
|
|
1760
|
+
"Run them separately, or set the same scope on each."
|
|
1761
|
+
end
|
|
1762
|
+
@_or_branch_scope = theirs
|
|
1763
|
+
@_or_branch_app = or_application_identity
|
|
1764
|
+
end
|
|
1765
|
+
@read_preference ||= other.read_preference
|
|
1766
|
+
self
|
|
1767
|
+
end
|
|
1768
|
+
protected :merge_or_scope!
|
|
1769
|
+
|
|
1770
|
+
# @!visibility private
|
|
1771
|
+
# The client set on this query when it is a real Parse client other
|
|
1772
|
+
# than the default one, or nil.
|
|
1773
|
+
# @return [Parse::Client, nil]
|
|
1774
|
+
def or_application_client
|
|
1775
|
+
own = or_explicit_client
|
|
1776
|
+
return nil unless own.respond_to?(:application_id) && own.respond_to?(:server_url)
|
|
1777
|
+
own
|
|
1778
|
+
end
|
|
1779
|
+
protected :or_application_client
|
|
1780
|
+
|
|
1781
|
+
# @!visibility private
|
|
1782
|
+
# The application this query runs against, as [application id, server
|
|
1783
|
+
# URL], from its own client or the default client. nil when no client
|
|
1784
|
+
# can be resolved.
|
|
1785
|
+
# @return [Array<String>, nil]
|
|
1786
|
+
def or_application_identity
|
|
1787
|
+
target = or_application_client || begin
|
|
1788
|
+
self.class.client
|
|
1789
|
+
rescue StandardError
|
|
1790
|
+
nil
|
|
1791
|
+
end
|
|
1792
|
+
return nil unless target.respond_to?(:application_id) && target.respond_to?(:server_url)
|
|
1793
|
+
[target.application_id.to_s, target.server_url.to_s.chomp("/")]
|
|
1794
|
+
end
|
|
1795
|
+
protected :or_application_identity
|
|
1796
|
+
|
|
1797
|
+
# @!visibility private
|
|
1798
|
+
# Queries combined with OR must target the same application, so a
|
|
1799
|
+
# session token or master-key authority from one app is never sent to
|
|
1800
|
+
# another. An empty receiver on the default client (no constraints, no
|
|
1801
|
+
# authority) takes the other query's client, as it takes its authority;
|
|
1802
|
+
# any other mismatch raises.
|
|
1803
|
+
def merge_or_application!(other)
|
|
1804
|
+
mine = or_application_identity
|
|
1805
|
+
theirs = other.or_application_identity
|
|
1806
|
+
return if mine.nil? || theirs.nil? || mine == theirs
|
|
1807
|
+
their_client = other.or_application_client
|
|
1808
|
+
if their_client && or_application_client.nil? && @_or_branch_scope.nil? &&
|
|
1809
|
+
or_effective_scope.empty? && Parse::Query.match_all?(@where)
|
|
1810
|
+
@client = their_client
|
|
1811
|
+
return
|
|
1812
|
+
end
|
|
1813
|
+
raise ArgumentError,
|
|
1814
|
+
"Queries combined with OR must run against the same Parse application " \
|
|
1815
|
+
"(#{mine.first} at #{mine.last} vs #{theirs.first} at #{theirs.last}). " \
|
|
1816
|
+
"Run them separately on their own clients."
|
|
1817
|
+
end
|
|
1818
|
+
private :merge_or_application!
|
|
1819
|
+
|
|
1820
|
+
# @!visibility private
|
|
1821
|
+
# Carry `other`'s client-bound authority onto this query. A query with
|
|
1822
|
+
# no client of its own takes `other`'s client, which keeps its server
|
|
1823
|
+
# and session. A query with its own client takes the derived session
|
|
1824
|
+
# token (or the no-master-key flag) explicitly instead.
|
|
1825
|
+
def adopt_or_client!(other, theirs)
|
|
1826
|
+
their_client = other.or_explicit_client
|
|
1827
|
+
return if their_client.nil?
|
|
1828
|
+
if or_explicit_client.nil?
|
|
1829
|
+
@client = their_client
|
|
1830
|
+
elsif other.session_token.nil? && theirs.key?(:session)
|
|
1831
|
+
@session_token = theirs[:session]
|
|
1832
|
+
elsif theirs[:master] == false
|
|
1833
|
+
@use_master_key = false
|
|
1834
|
+
end
|
|
1835
|
+
end
|
|
1836
|
+
private :adopt_or_client!
|
|
1837
|
+
|
|
1838
|
+
# @!visibility private
|
|
1839
|
+
# Raise when this query's authority no longer matches the one an OR
|
|
1840
|
+
# branch was combined under.
|
|
1841
|
+
def enforce_or_branch_scope!
|
|
1842
|
+
return if @_or_branch_scope.nil?
|
|
1843
|
+
if @_or_branch_app && (app = or_application_identity) && app != @_or_branch_app
|
|
1844
|
+
raise ArgumentError,
|
|
1845
|
+
"This query was combined with OR against Parse application #{@_or_branch_app.first}; " \
|
|
1846
|
+
"running it against #{app.first} would send its authority to another application. " \
|
|
1847
|
+
"Build a new query instead."
|
|
1848
|
+
end
|
|
1849
|
+
current = or_effective_scope
|
|
1850
|
+
return if current == @_or_branch_scope
|
|
1851
|
+
raise ArgumentError,
|
|
1852
|
+
"This query was combined with OR under #{Parse::Query.describe_or_scope(@_or_branch_scope)}; " \
|
|
1853
|
+
"changing it to #{Parse::Query.describe_or_scope(current)} would run the combined " \
|
|
1854
|
+
"branches under a different authority. Build a new query instead."
|
|
1855
|
+
end
|
|
1856
|
+
private :enforce_or_branch_scope!
|
|
1857
|
+
|
|
1858
|
+
# @!visibility private
|
|
1859
|
+
# On a query combined with OR, auth kwargs passed straight to a direct
|
|
1860
|
+
# or Atlas terminal (`results_direct(master: true)`) must name the same
|
|
1861
|
+
# authority the query was pinned to, and an explicit client the same
|
|
1862
|
+
# application. A narrower `master: false` is allowed.
|
|
1863
|
+
def enforce_explicit_auth_against_or_scope!(session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil)
|
|
1864
|
+
return if @_or_branch_scope.nil?
|
|
1865
|
+
if client && @_or_branch_app && client.respond_to?(:application_id) && client.respond_to?(:server_url)
|
|
1866
|
+
app = [client.application_id.to_s, client.server_url.to_s.chomp("/")]
|
|
1867
|
+
unless app == @_or_branch_app
|
|
1868
|
+
raise ArgumentError,
|
|
1869
|
+
"This query was combined with OR against Parse application #{@_or_branch_app.first}; " \
|
|
1870
|
+
"the client: passed here targets #{app.first}."
|
|
1871
|
+
end
|
|
1872
|
+
end
|
|
1873
|
+
given = {}
|
|
1874
|
+
token = session_token.respond_to?(:session_token) ? session_token.session_token : session_token
|
|
1875
|
+
given[:session] = token unless token.nil?
|
|
1876
|
+
given[:user] = Parse::Query.or_scope_value(:acl_user, acl_user) unless acl_user.nil?
|
|
1877
|
+
given[:role] = Parse::Query.or_scope_value(:acl_role, acl_role) unless acl_role.nil?
|
|
1878
|
+
given[:master] = true if master && master != false
|
|
1879
|
+
return if given.empty? || given == @_or_branch_scope
|
|
1880
|
+
raise ArgumentError,
|
|
1881
|
+
"This query was combined with OR under #{Parse::Query.describe_or_scope(@_or_branch_scope)}; " \
|
|
1882
|
+
"the auth passed here (#{Parse::Query.describe_or_scope(given)}) would run it under a " \
|
|
1883
|
+
"different authority."
|
|
1884
|
+
end
|
|
1885
|
+
private :enforce_explicit_auth_against_or_scope!
|
|
1886
|
+
|
|
1887
|
+
# @!visibility private
|
|
1888
|
+
# A redacted, human-readable form of an authority Hash for errors.
|
|
1889
|
+
def self.describe_or_scope(scope)
|
|
1890
|
+
return "the default authority" if scope.empty?
|
|
1891
|
+
scope.map do |kind, value|
|
|
1892
|
+
case kind
|
|
1893
|
+
when :session then "a session token"
|
|
1894
|
+
when :user then "scoped user #{value}"
|
|
1895
|
+
when :role then "scoped role #{value}"
|
|
1896
|
+
when :master then value ? "the master key" : "no master key"
|
|
1897
|
+
end
|
|
1898
|
+
end.join(" + ")
|
|
1899
|
+
end
|
|
1900
|
+
|
|
1901
|
+
# @!visibility private
|
|
1902
|
+
# A comparable form of one auth scope value.
|
|
1903
|
+
def self.or_scope_value(ivar, value)
|
|
1904
|
+
case ivar
|
|
1905
|
+
when :acl_user
|
|
1906
|
+
value.respond_to?(:id) ? value.id : value.to_s
|
|
1907
|
+
when :acl_role
|
|
1908
|
+
name = value.respond_to?(:name) ? value.name : value.to_s
|
|
1909
|
+
name.to_s.delete_prefix("role:")
|
|
1910
|
+
else
|
|
1911
|
+
value
|
|
1912
|
+
end
|
|
1913
|
+
end
|
|
1914
|
+
|
|
1915
|
+
# @!visibility private
|
|
1916
|
+
# Drop every where constraint, so the query matches all rows.
|
|
1917
|
+
# @return [self]
|
|
1918
|
+
def match_all!
|
|
1919
|
+
@where = []
|
|
1920
|
+
@results = nil
|
|
1921
|
+
self
|
|
1922
|
+
end
|
|
1923
|
+
protected :match_all!
|
|
1924
|
+
|
|
1925
|
+
# @!visibility private
|
|
1926
|
+
# Whether a list of constraints places no restriction on the rows (an
|
|
1927
|
+
# empty list, or constraints that compile to nothing).
|
|
1928
|
+
# @param where [Array<Parse::Constraint>]
|
|
1929
|
+
# @return [Boolean]
|
|
1930
|
+
def self.match_all?(where)
|
|
1931
|
+
where.blank? || compile_markers(where).empty?
|
|
1932
|
+
end
|
|
1933
|
+
|
|
1117
1934
|
# Add a "field is NOT between" condition — the logical negation of
|
|
1118
1935
|
# `.where(field.between => value)`: `field < min OR field > max` for a
|
|
1119
1936
|
# fully-bounded Range/Array, or a single one-sided comparison when the
|
|
@@ -1260,7 +2077,7 @@ module Parse
|
|
|
1260
2077
|
values = raw_results.map { |item| item["value"] }.compact
|
|
1261
2078
|
|
|
1262
2079
|
# Use schema-based approach to handle pointer field results
|
|
1263
|
-
parse_class =
|
|
2080
|
+
parse_class = table_model_class
|
|
1264
2081
|
is_pointer = parse_class && is_pointer_field?(parse_class, field, formatted_field)
|
|
1265
2082
|
|
|
1266
2083
|
if is_pointer && values.any?
|
|
@@ -1423,9 +2240,13 @@ module Parse
|
|
|
1423
2240
|
{ "$count" => "distinctCount" },
|
|
1424
2241
|
]
|
|
1425
2242
|
|
|
2243
|
+
# `limit(0)` selects no rows.
|
|
2244
|
+
return 0 if @limit == 0
|
|
2245
|
+
|
|
1426
2246
|
# Use the Aggregation class to execute
|
|
1427
|
-
# The aggregate method will automatically handle where conditions
|
|
1428
|
-
|
|
2247
|
+
# The aggregate method will automatically handle where conditions,
|
|
2248
|
+
# and the query's order / skip / limit pick the rows before `$group`.
|
|
2249
|
+
aggregation = aggregate(pipeline, verbose: @verbose_aggregate, window_before_pipeline: true)
|
|
1429
2250
|
raw_results = aggregation.raw
|
|
1430
2251
|
|
|
1431
2252
|
# Extract the count from the response
|
|
@@ -1481,35 +2302,36 @@ module Parse
|
|
|
1481
2302
|
return first_direct(limit_or_constraints)
|
|
1482
2303
|
end
|
|
1483
2304
|
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
2305
|
+
# Fetch on a temporary state: the limit, constraints, and cached
|
|
2306
|
+
# results set for this call never stay on the query.
|
|
2307
|
+
with_temporary_query_state do
|
|
2308
|
+
if limit_or_constraints.is_a?(Hash)
|
|
2309
|
+
conditions(limit_or_constraints)
|
|
2310
|
+
# Check if limit was set in constraints, otherwise use 1
|
|
2311
|
+
# Handle :max case - if @limit is :max, default to 1 for first()
|
|
2312
|
+
fetch_count = (@limit.is_a?(Numeric) ? @limit : nil) || 1
|
|
2313
|
+
else
|
|
2314
|
+
fetch_count = case limit_or_constraints
|
|
2315
|
+
when Numeric then limit_or_constraints.to_i
|
|
2316
|
+
when String
|
|
2317
|
+
unless limit_or_constraints =~ /\A-?\d+\z/
|
|
2318
|
+
raise ArgumentError,
|
|
2319
|
+
"Invalid first() argument #{limit_or_constraints.inspect}. " \
|
|
2320
|
+
"Expected an Integer, a numeric String, or a Hash of constraints."
|
|
2321
|
+
end
|
|
2322
|
+
limit_or_constraints.to_i
|
|
2323
|
+
else
|
|
1497
2324
|
raise ArgumentError,
|
|
1498
2325
|
"Invalid first() argument #{limit_or_constraints.inspect}. " \
|
|
1499
2326
|
"Expected an Integer, a numeric String, or a Hash of constraints."
|
|
1500
2327
|
end
|
|
1501
|
-
|
|
1502
|
-
else
|
|
1503
|
-
raise ArgumentError,
|
|
1504
|
-
"Invalid first() argument #{limit_or_constraints.inspect}. " \
|
|
1505
|
-
"Expected an Integer, a numeric String, or a Hash of constraints."
|
|
1506
|
-
end
|
|
2328
|
+
end
|
|
1507
2329
|
@results = nil if @limit != fetch_count
|
|
1508
2330
|
@limit = fetch_count
|
|
2331
|
+
# Apply any additional keyword options as conditions (e.g., keys:, includes:)
|
|
2332
|
+
conditions(options) unless options.empty?
|
|
2333
|
+
fetch_count == 1 ? results.first : results.first(fetch_count)
|
|
1509
2334
|
end
|
|
1510
|
-
# Apply any additional keyword options as conditions (e.g., keys:, includes:)
|
|
1511
|
-
conditions(options) unless options.empty?
|
|
1512
|
-
fetch_count == 1 ? results.first : results.first(fetch_count)
|
|
1513
2335
|
end
|
|
1514
2336
|
|
|
1515
2337
|
# Returns the most recently created object(s) (ordered by created_at descending).
|
|
@@ -1517,21 +2339,16 @@ module Parse
|
|
|
1517
2339
|
# @return [Parse::Object] if limit == 1
|
|
1518
2340
|
# @return [Array<Parse::Object>] if limit > 1
|
|
1519
2341
|
# @note Supports all constraint options like :keys, :includes, :limit, etc.
|
|
2342
|
+
# The query itself is not changed: the order, limit, and constraints
|
|
2343
|
+
# apply to this call only. `createdAt` descending is the primary sort;
|
|
2344
|
+
# an existing order on other fields breaks ties.
|
|
1520
2345
|
# @example
|
|
1521
2346
|
# query.latest # single most recent
|
|
1522
2347
|
# query.latest(5) # 5 most recent
|
|
1523
2348
|
# query.latest(:user.eq => x) # most recent for user
|
|
1524
2349
|
# query.latest(:user.eq => x, limit: 5) # 5 most recent for user
|
|
1525
2350
|
def latest(limit = 1, **options)
|
|
1526
|
-
|
|
1527
|
-
limit = options.delete(:limit) if options.key?(:limit)
|
|
1528
|
-
@results = nil if @limit != limit
|
|
1529
|
-
@limit = limit
|
|
1530
|
-
# Add created_at descending order if not already present
|
|
1531
|
-
order(:created_at.desc) unless @order.any? { |o| o.operand == :created_at }
|
|
1532
|
-
# Apply any additional keyword options as conditions (e.g., keys:, includes:)
|
|
1533
|
-
conditions(options) unless options.empty?
|
|
1534
|
-
limit == 1 ? results.first : results.first(limit)
|
|
2351
|
+
newest_by(:created_at, limit, **options)
|
|
1535
2352
|
end
|
|
1536
2353
|
|
|
1537
2354
|
# Returns the most recently updated object(s) (ordered by updated_at descending).
|
|
@@ -1539,21 +2356,53 @@ module Parse
|
|
|
1539
2356
|
# @return [Parse::Object] if limit == 1
|
|
1540
2357
|
# @return [Array<Parse::Object>] if limit > 1
|
|
1541
2358
|
# @note Supports all constraint options like :keys, :includes, :limit, etc.
|
|
2359
|
+
# The query itself is not changed, as with {#latest}.
|
|
1542
2360
|
# @example
|
|
1543
2361
|
# query.last_updated # single most recently updated
|
|
1544
2362
|
# query.last_updated(5) # 5 most recently updated
|
|
1545
2363
|
# query.last_updated(:user.eq => x) # most recently updated for user
|
|
1546
2364
|
# query.last_updated(:user.eq => x, limit: 5) # 5 most recently updated for user
|
|
1547
2365
|
def last_updated(limit = 1, **options)
|
|
2366
|
+
newest_by(:updated_at, limit, **options)
|
|
2367
|
+
end
|
|
2368
|
+
|
|
2369
|
+
# @!visibility private
|
|
2370
|
+
# Shared body of {#latest} and {#last_updated}.
|
|
2371
|
+
def newest_by(field, limit, **options)
|
|
1548
2372
|
# Allow limit to be overridden via options
|
|
1549
2373
|
limit = options.delete(:limit) if options.key?(:limit)
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2374
|
+
with_temporary_query_state do
|
|
2375
|
+
@results = nil
|
|
2376
|
+
@limit = limit
|
|
2377
|
+
column = Query.format_field(field)
|
|
2378
|
+
# Put the timestamp first; keep any other existing order as a
|
|
2379
|
+
# tiebreaker. An existing order on the same column is replaced.
|
|
2380
|
+
existing = @order.reject { |o| Query.format_field(o.field) == column }
|
|
2381
|
+
@order = []
|
|
2382
|
+
order(field.to_sym.desc)
|
|
2383
|
+
@order.concat(existing)
|
|
2384
|
+
# Apply any additional keyword options as conditions (e.g., keys:, includes:)
|
|
2385
|
+
conditions(options) unless options.empty?
|
|
2386
|
+
limit == 1 ? results.first : results.first(limit)
|
|
2387
|
+
end
|
|
2388
|
+
end
|
|
2389
|
+
|
|
2390
|
+
# @!visibility private
|
|
2391
|
+
# Run the block, then restore every instance variable to its value from
|
|
2392
|
+
# before the block (Arrays and Hashes are copied, so in-place appends
|
|
2393
|
+
# are undone too). Used by the fetch helpers that adjust the limit,
|
|
2394
|
+
# order, or constraints for one call.
|
|
2395
|
+
def with_temporary_query_state
|
|
2396
|
+
saved = instance_variables.each_with_object({}) do |ivar, memo|
|
|
2397
|
+
value = instance_variable_get(ivar)
|
|
2398
|
+
memo[ivar] = value.is_a?(Array) || value.is_a?(Hash) ? value.dup : value
|
|
2399
|
+
end
|
|
2400
|
+
begin
|
|
2401
|
+
yield
|
|
2402
|
+
ensure
|
|
2403
|
+
(instance_variables - saved.keys).each { |ivar| remove_instance_variable(ivar) }
|
|
2404
|
+
saved.each { |ivar, value| instance_variable_set(ivar, value) }
|
|
2405
|
+
end
|
|
1557
2406
|
end
|
|
1558
2407
|
|
|
1559
2408
|
# Retrieve a single object by its objectId.
|
|
@@ -1584,8 +2433,10 @@ module Parse
|
|
|
1584
2433
|
batch_size = 100
|
|
1585
2434
|
results = []
|
|
1586
2435
|
# determine if there is a user provided hard limit
|
|
2436
|
+
return [] if @limit == 0
|
|
1587
2437
|
_limit = (@limit.is_a?(Numeric) && @limit > 0) ? @limit : nil
|
|
1588
2438
|
compiled_query[:skip] ||= 0
|
|
2439
|
+
add_paging_tiebreaker!(compiled_query)
|
|
1589
2440
|
|
|
1590
2441
|
loop do
|
|
1591
2442
|
# always reset the batch size
|
|
@@ -1634,8 +2485,26 @@ module Parse
|
|
|
1634
2485
|
results
|
|
1635
2486
|
end
|
|
1636
2487
|
|
|
2488
|
+
# @!visibility private
|
|
2489
|
+
# Skip-based paging is only stable under a total order. When several rows
|
|
2490
|
+
# share the sort value, the server may return them in a different order
|
|
2491
|
+
# on each page request, so rows repeat on one page and are skipped on
|
|
2492
|
+
# another. Ending the order with `objectId` makes every page boundary
|
|
2493
|
+
# deterministic. Left alone when the query already orders by objectId, or
|
|
2494
|
+
# when `$near` / `$text` supply their own relevance order.
|
|
2495
|
+
# @param compiled_query [Hash] a compiled query (mutated).
|
|
2496
|
+
def add_paging_tiebreaker!(compiled_query)
|
|
2497
|
+
where = compiled_query[:where].to_s
|
|
2498
|
+
return if where.include?("$near") || where.include?("$text")
|
|
2499
|
+
order = compiled_query[:order].to_s
|
|
2500
|
+
fields = order.split(",").map { |f| f.strip.delete_prefix("-") }
|
|
2501
|
+
return if fields.include?("objectId") || fields.any? { |f| f.start_with?("$") }
|
|
2502
|
+
compiled_query[:order] = order.empty? ? "objectId" : "#{order},objectId"
|
|
2503
|
+
end
|
|
2504
|
+
|
|
1637
2505
|
# @!visibility private
|
|
1638
2506
|
def _opts
|
|
2507
|
+
enforce_or_branch_scope!
|
|
1639
2508
|
opts = {}
|
|
1640
2509
|
opts[:cache] = self.cache || false
|
|
1641
2510
|
# Only forward `use_master_key` when the caller actually set it.
|
|
@@ -1645,6 +2514,14 @@ module Parse
|
|
|
1645
2514
|
# init-block comment on `@use_master_key`.
|
|
1646
2515
|
opts[:use_master_key] = self.use_master_key unless self.use_master_key.nil?
|
|
1647
2516
|
opts[:session_token] = self.session_token unless self.session_token.nil?
|
|
2517
|
+
# SDK-internal metadata read (the role-graph walk used for scope
|
|
2518
|
+
# enforcement): keeps the master key inside `Parse.without_master_key`.
|
|
2519
|
+
# Set only by the SDK through an instance variable, never a public option.
|
|
2520
|
+
if @_metadata_master == true
|
|
2521
|
+
opts[:use_master_key] = true
|
|
2522
|
+
opts.delete(:session_token)
|
|
2523
|
+
opts[:metadata_master] = Parse::Client::METADATA_MASTER_REQUEST
|
|
2524
|
+
end
|
|
1648
2525
|
# for now, don't cache requests where we disable master_key or provide session token
|
|
1649
2526
|
# if opts[:use_master_key] == false || opts[:session_token].present?
|
|
1650
2527
|
# opts[:cache] = false
|
|
@@ -1814,6 +2691,9 @@ module Parse
|
|
|
1814
2691
|
# @param mongo_direct [Boolean] if true, queries MongoDB directly bypassing Parse Server.
|
|
1815
2692
|
# Requires Parse::MongoDB to be configured. Default: false.
|
|
1816
2693
|
def results(raw: false, return_pointers: false, mongo_direct: false, &block)
|
|
2694
|
+
# `limit(0)` asks for no rows: answer without a request. Sending no
|
|
2695
|
+
# limit would return the server default page of 100.
|
|
2696
|
+
return [] if @limit == 0
|
|
1817
2697
|
# Use direct MongoDB query if requested
|
|
1818
2698
|
if mongo_direct
|
|
1819
2699
|
return results_direct(raw: raw, **mongo_direct_auth_kwargs, &block)
|
|
@@ -1921,6 +2801,9 @@ module Parse
|
|
|
1921
2801
|
unless use_master_key == true
|
|
1922
2802
|
ambient = ambient_session_token
|
|
1923
2803
|
return true if ambient.is_a?(String) && !ambient.empty?
|
|
2804
|
+
# A client bound to a user's session (`become`, `session_client`,
|
|
2805
|
+
# a webhook `user_client`) is scoped the same way.
|
|
2806
|
+
return true if client_bound_session_token
|
|
1924
2807
|
end
|
|
1925
2808
|
false
|
|
1926
2809
|
end
|
|
@@ -1984,7 +2867,7 @@ module Parse
|
|
|
1984
2867
|
# @return [self]
|
|
1985
2868
|
def scope_to_user(user)
|
|
1986
2869
|
raise ArgumentError, "[Parse::Query] scope_to_user requires a Parse::User or User Pointer." unless user.respond_to?(:id) && user.id.is_a?(String)
|
|
1987
|
-
@acl_user = user
|
|
2870
|
+
guard_or_scope_change { @acl_user = user }
|
|
1988
2871
|
self
|
|
1989
2872
|
end
|
|
1990
2873
|
|
|
@@ -2024,7 +2907,7 @@ module Parse
|
|
|
2024
2907
|
# Parse::ACLScope#resolve_for_role only ever sees Parse::Role or
|
|
2025
2908
|
# String. Without normalization, any String-only operation
|
|
2026
2909
|
# (e.g. #start_with?, #sub) silently NoMethodErrors on Symbol.
|
|
2027
|
-
@acl_role = role.is_a?(Symbol) ? role.to_s : role
|
|
2910
|
+
guard_or_scope_change { @acl_role = role.is_a?(Symbol) ? role.to_s : role }
|
|
2028
2911
|
self
|
|
2029
2912
|
end
|
|
2030
2913
|
|
|
@@ -2083,7 +2966,7 @@ module Parse
|
|
|
2083
2966
|
# query would raise instead of running scoped — and on a master
|
|
2084
2967
|
# client the ambient is what `mongo_direct_auth_kwargs` forwards so
|
|
2085
2968
|
# the read is scoped rather than silently master.
|
|
2086
|
-
has_ambient_session = !ambient_session_token.nil?
|
|
2969
|
+
has_ambient_session = !ambient_session_token.nil? || !client_bound_session_token.nil?
|
|
2087
2970
|
# Mirror the request-layer auth resolution in Parse::Client#request:
|
|
2088
2971
|
# when the process is in "server mode" — Parse.client_mode == false
|
|
2089
2972
|
# AND the resolved Parse::Client has a master_key — and the caller
|
|
@@ -2099,7 +2982,11 @@ module Parse
|
|
|
2099
2982
|
false
|
|
2100
2983
|
end
|
|
2101
2984
|
server_mode_master = (use_master_key != false) && !Parse.client_mode && client_has_master_key
|
|
2102
|
-
|
|
2985
|
+
# Inside `Parse.without_master_key` REST sends no master key, so
|
|
2986
|
+
# neither the explicit opt-in nor the server-mode default authorizes
|
|
2987
|
+
# a direct read there.
|
|
2988
|
+
master_authorized = (use_master_key || server_mode_master) && !master_key_suppressed?
|
|
2989
|
+
unless master_authorized || @acl_user || @acl_role || has_session || has_ambient_session
|
|
2103
2990
|
raise MongoDirectRequired,
|
|
2104
2991
|
"[Parse::Query] This query uses a constraint that can only run " \
|
|
2105
2992
|
"via mongo-direct. Mongo-direct bypasses Parse Server's enforcement, " \
|
|
@@ -2166,6 +3053,10 @@ module Parse
|
|
|
2166
3053
|
|
|
2167
3054
|
# @!visibility private
|
|
2168
3055
|
def mongo_direct_scope_kwargs
|
|
3056
|
+
enforce_or_branch_scope!
|
|
3057
|
+
# SDK-internal metadata read (see `_opts`): master mode on the direct
|
|
3058
|
+
# path too, whatever `Parse.without_master_key` says.
|
|
3059
|
+
return { master: Parse::ACLScope::METADATA_MASTER } if @_metadata_master == true
|
|
2169
3060
|
if @acl_user
|
|
2170
3061
|
# Pre-resolved User pointer. Hand it to Parse::ACLScope as
|
|
2171
3062
|
# acl_user: so the same three-layer simulation runs (top-level
|
|
@@ -2189,11 +3080,73 @@ module Parse
|
|
|
2189
3080
|
# deliberate admin call and skips the ambient, exactly as the REST
|
|
2190
3081
|
# path does.
|
|
2191
3082
|
{ session_token: ambient }
|
|
2192
|
-
|
|
3083
|
+
elsif use_master_key != true && anonymous_session_block?
|
|
3084
|
+
# Inside `Parse.with_session(nil)`: REST sends neither a token nor
|
|
3085
|
+
# the master key, so the direct read runs in the public scope too.
|
|
3086
|
+
{}
|
|
3087
|
+
elsif use_master_key != true && (bound = client_bound_session_token)
|
|
3088
|
+
# The query's client carries its own session token (a client from
|
|
3089
|
+
# `Parse::Client#become`, `Parse::User#session_client`, or a webhook
|
|
3090
|
+
# payload's `user_client`). REST sends that token on every request
|
|
3091
|
+
# from the client, so the direct read is scoped to the same user.
|
|
3092
|
+
{ session_token: bound }
|
|
3093
|
+
elsif mongo_direct_master_posture?
|
|
2193
3094
|
{ master: true }
|
|
3095
|
+
else
|
|
3096
|
+
# A client with no master key, `Parse.client_mode`, or an explicit
|
|
3097
|
+
# `use_master_key = false`, and no session anywhere: REST would run
|
|
3098
|
+
# this read anonymously. Return no auth so Parse::ACLScope resolves
|
|
3099
|
+
# the public scope (or raises ACLRequired when
|
|
3100
|
+
# `require_session_token` is on) instead of reading as master.
|
|
3101
|
+
{}
|
|
2194
3102
|
end
|
|
2195
3103
|
end
|
|
2196
3104
|
|
|
3105
|
+
# The session token bound to this query's client, or nil.
|
|
3106
|
+
# @return [String, nil]
|
|
3107
|
+
# @!visibility private
|
|
3108
|
+
def client_bound_session_token
|
|
3109
|
+
c = begin
|
|
3110
|
+
client
|
|
3111
|
+
rescue StandardError
|
|
3112
|
+
nil
|
|
3113
|
+
end
|
|
3114
|
+
return nil unless c.respond_to?(:session_token)
|
|
3115
|
+
token = c.session_token
|
|
3116
|
+
token = token.session_token if token.respond_to?(:session_token)
|
|
3117
|
+
token.is_a?(String) && !token.strip.empty? ? token : nil
|
|
3118
|
+
end
|
|
3119
|
+
|
|
3120
|
+
# Whether REST would send the master key for this query: the client
|
|
3121
|
+
# holds one and the caller either asked for it explicitly or left the
|
|
3122
|
+
# choice to the server-mode default (`Parse.client_mode` off and no
|
|
3123
|
+
# `use_master_key = false`). Mirrors Parse::Client#request.
|
|
3124
|
+
# @return [Boolean]
|
|
3125
|
+
# @!visibility private
|
|
3126
|
+
def mongo_direct_master_posture?
|
|
3127
|
+
# Inside `Parse.without_master_key` this still answers true: the
|
|
3128
|
+
# resulting `master: true` is dropped by Parse::ACLScope.resolve!,
|
|
3129
|
+
# which runs the read in the public scope (or raises under
|
|
3130
|
+
# `require_session_token`) with the block-specific message.
|
|
3131
|
+
c = begin
|
|
3132
|
+
client
|
|
3133
|
+
rescue StandardError
|
|
3134
|
+
nil
|
|
3135
|
+
end
|
|
3136
|
+
has_key = c.respond_to?(:master_key) && !c.master_key.to_s.empty?
|
|
3137
|
+
return false unless has_key
|
|
3138
|
+
return true if use_master_key == true
|
|
3139
|
+
use_master_key != false && !Parse.client_mode
|
|
3140
|
+
end
|
|
3141
|
+
|
|
3142
|
+
# @return [Boolean] true inside a `Parse.without_master_key` block (and
|
|
3143
|
+
# not re-enabled by a nested `Parse.with_master_key`), where REST sends
|
|
3144
|
+
# no master key on any request.
|
|
3145
|
+
# @!visibility private
|
|
3146
|
+
def master_key_suppressed?
|
|
3147
|
+
Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
3148
|
+
end
|
|
3149
|
+
|
|
2197
3150
|
# Auth kwargs for the Atlas Search bridge (`#atlas_search` builder
|
|
2198
3151
|
# block). Explicit `atlas_search(...)` auth kwargs win; otherwise
|
|
2199
3152
|
# derive from the query's own scope (`#scope_to_user`, an explicit
|
|
@@ -2225,13 +3178,20 @@ module Parse
|
|
|
2225
3178
|
end
|
|
2226
3179
|
|
|
2227
3180
|
explicit = %i[session_token master acl_user acl_role].select { |k| options.key?(k) }
|
|
2228
|
-
|
|
3181
|
+
if explicit.any? || options.key?(:client)
|
|
3182
|
+
given = explicit.to_h { |k| [k, options[k]] }
|
|
3183
|
+
enforce_explicit_auth_against_or_scope!(client: options[:client], **given)
|
|
3184
|
+
end
|
|
3185
|
+
if explicit.any?
|
|
3186
|
+
return client_kwarg.merge(given)
|
|
3187
|
+
end
|
|
2229
3188
|
|
|
2230
3189
|
client_kwarg.merge(atlas_search_scope_kwargs)
|
|
2231
3190
|
end
|
|
2232
3191
|
|
|
2233
3192
|
# @!visibility private
|
|
2234
3193
|
def atlas_search_scope_kwargs
|
|
3194
|
+
enforce_or_branch_scope!
|
|
2235
3195
|
if @acl_user
|
|
2236
3196
|
{ acl_user: @acl_user }
|
|
2237
3197
|
elsif @acl_role
|
|
@@ -2239,14 +3199,28 @@ module Parse
|
|
|
2239
3199
|
elsif @session_token.is_a?(String) && !@session_token.empty?
|
|
2240
3200
|
{ session_token: @session_token }
|
|
2241
3201
|
elsif use_master_key == true
|
|
3202
|
+
# An explicit master request skips the ambient session, as on REST.
|
|
3203
|
+
# Inside `Parse.without_master_key` Parse::AtlasSearch drops it and
|
|
3204
|
+
# the search runs in the public scope.
|
|
2242
3205
|
{ master: true }
|
|
2243
3206
|
elsif (ambient = ambient_session_token)
|
|
2244
3207
|
{ session_token: ambient }
|
|
3208
|
+
elsif anonymous_session_block?
|
|
3209
|
+
{}
|
|
3210
|
+
elsif (bound = client_bound_session_token)
|
|
3211
|
+
{ session_token: bound }
|
|
2245
3212
|
else
|
|
2246
3213
|
{}
|
|
2247
3214
|
end
|
|
2248
3215
|
end
|
|
2249
3216
|
|
|
3217
|
+
# @return [Boolean] true inside an anonymous `Parse.with_session(nil)`
|
|
3218
|
+
# block, where requests carry neither a session token nor the master key.
|
|
3219
|
+
# @!visibility private
|
|
3220
|
+
def anonymous_session_block?
|
|
3221
|
+
Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
|
|
3222
|
+
end
|
|
3223
|
+
|
|
2250
3224
|
# The fiber-local ambient session token set by `Parse.with_session`,
|
|
2251
3225
|
# or nil. A whitespace-only ambient is treated as absent so it cannot
|
|
2252
3226
|
# block the master fallback and then fail a later presence check —
|
|
@@ -2259,6 +3233,18 @@ module Parse
|
|
|
2259
3233
|
ambient if ambient.is_a?(String) && !ambient.strip.empty?
|
|
2260
3234
|
end
|
|
2261
3235
|
|
|
3236
|
+
# Like the default `inspect`, but never prints the session token, which
|
|
3237
|
+
# would otherwise reach logs and error reports.
|
|
3238
|
+
# @return [String]
|
|
3239
|
+
def inspect
|
|
3240
|
+
ivars = instance_variables.map do |ivar|
|
|
3241
|
+
value = instance_variable_get(ivar)
|
|
3242
|
+
shown = ivar == :@session_token && value ? "[FILTERED]" : value.inspect
|
|
3243
|
+
"#{ivar}=#{shown}"
|
|
3244
|
+
end
|
|
3245
|
+
"#<#{self.class.name} #{ivars.join(", ")}>"
|
|
3246
|
+
end
|
|
3247
|
+
|
|
2262
3248
|
# Check if this query contains constraints that require aggregation pipeline processing
|
|
2263
3249
|
# @return [Boolean] true if aggregation pipeline is required
|
|
2264
3250
|
def requires_aggregation_pipeline?
|
|
@@ -2318,6 +3304,11 @@ module Parse
|
|
|
2318
3304
|
# @note This is a read-only operation. Direct MongoDB queries cannot modify data.
|
|
2319
3305
|
# @see Parse::MongoDB.configure
|
|
2320
3306
|
def results_direct(raw: false, max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil, &block)
|
|
3307
|
+
enforce_explicit_auth_against_or_scope!(session_token: session_token, master: master,
|
|
3308
|
+
acl_user: acl_user, acl_role: acl_role, client: client)
|
|
3309
|
+
# `limit(0)` asks for no rows. MongoDB rejects `$limit: 0`, and
|
|
3310
|
+
# omitting the stage would return every row.
|
|
3311
|
+
return [] if @limit == 0
|
|
2321
3312
|
require_relative "mongodb"
|
|
2322
3313
|
Parse::MongoDB.require_gem!
|
|
2323
3314
|
|
|
@@ -2463,6 +3454,8 @@ module Parse
|
|
|
2463
3454
|
# @note This is a read-only operation. Direct MongoDB queries cannot modify data.
|
|
2464
3455
|
# @see Parse::MongoDB.configure
|
|
2465
3456
|
def count_direct(session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil)
|
|
3457
|
+
enforce_explicit_auth_against_or_scope!(session_token: session_token, master: master,
|
|
3458
|
+
acl_user: acl_user, acl_role: acl_role, client: client)
|
|
2466
3459
|
require_relative "mongodb"
|
|
2467
3460
|
Parse::MongoDB.require_gem!
|
|
2468
3461
|
|
|
@@ -2541,6 +3534,8 @@ module Parse
|
|
|
2541
3534
|
def distinct_direct(field, return_pointers: false, order: nil,
|
|
2542
3535
|
session_token: nil, master: nil, acl_user: nil, acl_role: nil,
|
|
2543
3536
|
client: nil)
|
|
3537
|
+
enforce_explicit_auth_against_or_scope!(session_token: session_token, master: master,
|
|
3538
|
+
acl_user: acl_user, acl_role: acl_role, client: client)
|
|
2544
3539
|
require_relative "mongodb"
|
|
2545
3540
|
Parse::MongoDB.require_gem!
|
|
2546
3541
|
|
|
@@ -2944,9 +3939,13 @@ module Parse
|
|
|
2944
3939
|
if compiled_where.present?
|
|
2945
3940
|
# Convert field names and values for direct MongoDB access.
|
|
2946
3941
|
# `compiled_where` is already marker-free, so no further
|
|
2947
|
-
# reject pass is required.
|
|
2948
|
-
|
|
3942
|
+
# reject pass is required. Subquery constraints (`$inQuery`,
|
|
3943
|
+
# `$notInQuery`, `$select`, `$dontSelect`) have no MongoDB
|
|
3944
|
+
# equivalent and are compiled into `$lookup` joins plus a
|
|
3945
|
+
# post-join `$match`; see #direct_subquery_stages.
|
|
3946
|
+
mongo_constraints, subquery_stages = direct_subquery_stages(compiled_where)
|
|
2949
3947
|
pipeline << { "$match" => mongo_constraints } if mongo_constraints.any?
|
|
3948
|
+
pipeline.concat(subquery_stages)
|
|
2950
3949
|
end
|
|
2951
3950
|
|
|
2952
3951
|
# Handle aggregation pipeline stages (from empty_or_nil, set_equals, etc.)
|
|
@@ -3001,9 +4000,23 @@ module Parse
|
|
|
3001
4000
|
"_acl" => 1,
|
|
3002
4001
|
}
|
|
3003
4002
|
@keys.each do |key|
|
|
3004
|
-
|
|
4003
|
+
# A dotted key (`meta.k`, `owner.name`) selects its top-level
|
|
4004
|
+
# column on REST: Parse Server keeps the whole `meta` object, or
|
|
4005
|
+
# the whole `owner` pointer. Projecting the dotted path would
|
|
4006
|
+
# return a partial sub-document REST never produces.
|
|
4007
|
+
top = key.to_s.split(".", 2).first
|
|
4008
|
+
next if top.nil? || top.empty?
|
|
4009
|
+
mongo_field = convert_field_for_direct_mongodb(top)
|
|
3005
4010
|
project_stage[mongo_field] = 1
|
|
3006
4011
|
end
|
|
4012
|
+
# Keep each include's `$lookup` output. Without it the joined
|
|
4013
|
+
# document was projected away and the field decoded as a bare
|
|
4014
|
+
# pointer even though the caller asked for it to be included.
|
|
4015
|
+
@includes.each do |inc|
|
|
4016
|
+
base = inc.to_s.split(".", 2).first
|
|
4017
|
+
next if base.nil? || base.empty?
|
|
4018
|
+
project_stage["_included_#{base}"] = 1 if get_pointer_target_class(base.to_sym)
|
|
4019
|
+
end
|
|
3007
4020
|
pipeline << { "$project" => project_stage }
|
|
3008
4021
|
end
|
|
3009
4022
|
|
|
@@ -3058,11 +4071,16 @@ module Parse
|
|
|
3058
4071
|
},
|
|
3059
4072
|
}
|
|
3060
4073
|
|
|
3061
|
-
# Stage 3:
|
|
4074
|
+
# Stage 3: Collapse the `$lookup` array to its single document.
|
|
4075
|
+
# An include that resolves to nothing (dangling pointer, or a row the
|
|
4076
|
+
# scope cannot read, which the ACL rewriter filters out of the
|
|
4077
|
+
# join) becomes an explicit null, so the row converter can drop the
|
|
4078
|
+
# field the way REST does instead of leaving a bare pointer.
|
|
3062
4079
|
stages << {
|
|
3063
|
-
"$
|
|
3064
|
-
|
|
3065
|
-
|
|
4080
|
+
"$addFields" => {
|
|
4081
|
+
lookup_result_field => {
|
|
4082
|
+
"$ifNull" => [{ "$arrayElemAt" => ["$#{lookup_result_field}", 0] }, nil],
|
|
4083
|
+
},
|
|
3066
4084
|
},
|
|
3067
4085
|
}
|
|
3068
4086
|
|
|
@@ -3107,6 +4125,244 @@ module Parse
|
|
|
3107
4125
|
end
|
|
3108
4126
|
end
|
|
3109
4127
|
|
|
4128
|
+
# Subquery operators the mongo-direct path compiles into `$lookup`
|
|
4129
|
+
# joins. MongoDB has no equivalent; passed through verbatim they fail
|
|
4130
|
+
# with "unknown operator".
|
|
4131
|
+
DIRECT_SUBQUERY_OPERATORS = %w[$inQuery $notInQuery $select $dontSelect].freeze
|
|
4132
|
+
|
|
4133
|
+
# Logical operators whose clauses may hold subquery constraints.
|
|
4134
|
+
DIRECT_LOGICAL_OPERATORS = %w[$and $or $nor].freeze
|
|
4135
|
+
|
|
4136
|
+
# Split compiled constraints into a MongoDB `$match` and the extra
|
|
4137
|
+
# stages that implement subquery operators.
|
|
4138
|
+
#
|
|
4139
|
+
# * `$inQuery` / `$notInQuery`: `$lookup` the pointed-to row in the
|
|
4140
|
+
# subquery's class, filtered by the subquery's `where`, then keep rows
|
|
4141
|
+
# whose join is non-empty (or empty).
|
|
4142
|
+
# * `$select` / `$dontSelect`: `$lookup` rows of the subquery's class
|
|
4143
|
+
# matching its `where` whose `key` equals this row's field, then keep
|
|
4144
|
+
# rows whose join is non-empty (or empty).
|
|
4145
|
+
#
|
|
4146
|
+
# A subquery inside `$and` / `$or` / `$nor` (at any depth, as
|
|
4147
|
+
# `or_where` produces) gets its own `$lookup` too. The logical clause
|
|
4148
|
+
# holding it moves to the post-join `$match`, with the subquery replaced
|
|
4149
|
+
# by a test on its join result, so `$or` keeps its meaning. Constraints
|
|
4150
|
+
# with no subquery stay in the first `$match`, ahead of the joins. A
|
|
4151
|
+
# subquery in any other position (under `$not`, `$elemMatch`, `$expr`)
|
|
4152
|
+
# cannot be translated and raises ArgumentError rather than reaching
|
|
4153
|
+
# MongoDB as an unknown operator.
|
|
4154
|
+
#
|
|
4155
|
+
# The joins run through Parse::MongoDB.aggregate, so the ACL rewriter
|
|
4156
|
+
# filters the joined rows by `_rperm`, the joined class's CLP is
|
|
4157
|
+
# checked, and a `where` on the joined class's protectedFields is
|
|
4158
|
+
# refused, as Parse Server does for its subqueries. Temporary join
|
|
4159
|
+
# columns are removed with `$unset`.
|
|
4160
|
+
#
|
|
4161
|
+
# @param constraints [Hash] compiled where constraints.
|
|
4162
|
+
# @return [Array(Hash, Array<Hash>)] the `$match` body and the stages.
|
|
4163
|
+
# @raise [ArgumentError] when a subquery sits where it cannot be translated.
|
|
4164
|
+
# @api private
|
|
4165
|
+
def direct_subquery_stages(constraints)
|
|
4166
|
+
unless direct_subquery_present?(constraints)
|
|
4167
|
+
return [convert_constraints_for_direct_mongodb(constraints), []]
|
|
4168
|
+
end
|
|
4169
|
+
|
|
4170
|
+
plain = {}
|
|
4171
|
+
post = {}
|
|
4172
|
+
lookups = []
|
|
4173
|
+
temps = []
|
|
4174
|
+
constraints.each do |field, value|
|
|
4175
|
+
direct_translate_subquery_pair(field, value, plain, post, lookups, temps)
|
|
4176
|
+
end
|
|
4177
|
+
match = convert_constraints_for_direct_mongodb(plain)
|
|
4178
|
+
refuse_untranslated_subquery!(match)
|
|
4179
|
+
refuse_untranslated_subquery!(post)
|
|
4180
|
+
|
|
4181
|
+
stages = lookups
|
|
4182
|
+
stages << { "$match" => post } if post.any?
|
|
4183
|
+
stages << { "$unset" => temps } if temps.any?
|
|
4184
|
+
[match, stages]
|
|
4185
|
+
end
|
|
4186
|
+
|
|
4187
|
+
# Translate one `field => value` constraint. A constraint with no
|
|
4188
|
+
# subquery is collected into `plain` unchanged (converted later by the
|
|
4189
|
+
# caller). A subquery operator adds a `$lookup` to `lookups` and a test
|
|
4190
|
+
# on its join result to `out`. A logical operator holding a subquery is
|
|
4191
|
+
# rebuilt clause by clause into `out`.
|
|
4192
|
+
# @api private
|
|
4193
|
+
def direct_translate_subquery_pair(field, value, plain, out, lookups, temps)
|
|
4194
|
+
key = field.to_s
|
|
4195
|
+
if DIRECT_LOGICAL_OPERATORS.include?(key) && value.is_a?(Array)
|
|
4196
|
+
if direct_subquery_present?(value)
|
|
4197
|
+
direct_merge_clause!(out, key, value.map { |clause| direct_translate_subquery_clause(clause, lookups, temps) })
|
|
4198
|
+
else
|
|
4199
|
+
plain[field] = value
|
|
4200
|
+
end
|
|
4201
|
+
return
|
|
4202
|
+
end
|
|
4203
|
+
|
|
4204
|
+
ops = value.is_a?(Hash) ? value.keys.map(&:to_s) & DIRECT_SUBQUERY_OPERATORS : []
|
|
4205
|
+
if ops.empty?
|
|
4206
|
+
plain[field] = value
|
|
4207
|
+
return
|
|
4208
|
+
end
|
|
4209
|
+
others = value.reject { |k, _| DIRECT_SUBQUERY_OPERATORS.include?(k.to_s) }
|
|
4210
|
+
plain[field] = others if others.any?
|
|
4211
|
+
ops.each do |op|
|
|
4212
|
+
spec = value.key?(op) ? value[op] : value[op.to_sym]
|
|
4213
|
+
temp = "_subquery_#{temps.size}_#{key.gsub(/[^A-Za-z0-9_]/, "_")}"
|
|
4214
|
+
temps << temp
|
|
4215
|
+
lookups << direct_subquery_lookup(key, op, spec, temp)
|
|
4216
|
+
out[temp] = %w[$inQuery $select].include?(op) ? { "$ne" => [] } : { "$eq" => [] }
|
|
4217
|
+
end
|
|
4218
|
+
end
|
|
4219
|
+
|
|
4220
|
+
# Translate one clause of a logical operator into a MongoDB filter
|
|
4221
|
+
# that reads the join results.
|
|
4222
|
+
# @api private
|
|
4223
|
+
def direct_translate_subquery_clause(clause, lookups, temps)
|
|
4224
|
+
unless clause.is_a?(Hash)
|
|
4225
|
+
raise ArgumentError,
|
|
4226
|
+
"[Parse::Query] a logical operator clause holding a subquery must be a Hash, got #{clause.class}."
|
|
4227
|
+
end
|
|
4228
|
+
plain = {}
|
|
4229
|
+
out = {}
|
|
4230
|
+
clause.each do |field, value|
|
|
4231
|
+
direct_translate_subquery_pair(field, value, plain, out, lookups, temps)
|
|
4232
|
+
end
|
|
4233
|
+
converted = convert_constraints_for_direct_mongodb(plain)
|
|
4234
|
+
return converted.merge(out) if (converted.keys & out.keys).empty?
|
|
4235
|
+
# The same operator appeared twice (a String and a Symbol key).
|
|
4236
|
+
# Keep both by matching them together rather than letting one
|
|
4237
|
+
# overwrite the other.
|
|
4238
|
+
{ "$and" => [converted, out] }
|
|
4239
|
+
end
|
|
4240
|
+
|
|
4241
|
+
# Add a translated logical clause to `out` without overwriting one that
|
|
4242
|
+
# is already there under the same operator.
|
|
4243
|
+
# @api private
|
|
4244
|
+
def direct_merge_clause!(out, key, clauses)
|
|
4245
|
+
if out.key?(key)
|
|
4246
|
+
existing = out.delete(key)
|
|
4247
|
+
out["$and"] = Array(out.delete("$and")) + [{ key => existing }, { key => clauses }]
|
|
4248
|
+
else
|
|
4249
|
+
out[key] = clauses
|
|
4250
|
+
end
|
|
4251
|
+
end
|
|
4252
|
+
|
|
4253
|
+
# Fail closed when a subquery operator is still present after
|
|
4254
|
+
# translation. MongoDB has no such operator, and a subquery in a
|
|
4255
|
+
# position the SDK cannot join (under `$not`, `$elemMatch`, `$expr`)
|
|
4256
|
+
# must not run as some other filter.
|
|
4257
|
+
# @raise [ArgumentError]
|
|
4258
|
+
# @api private
|
|
4259
|
+
def refuse_untranslated_subquery!(node, context: :direct)
|
|
4260
|
+
case node
|
|
4261
|
+
when Hash
|
|
4262
|
+
node.each do |key, value|
|
|
4263
|
+
if DIRECT_SUBQUERY_OPERATORS.include?(key.to_s)
|
|
4264
|
+
if context == :aggregate
|
|
4265
|
+
raise ArgumentError,
|
|
4266
|
+
"[Parse::Query] #{key} cannot be translated into an aggregation pipeline in " \
|
|
4267
|
+
"this position. Aggregations translate only top-level $inQuery / $notInQuery " \
|
|
4268
|
+
"field constraints into joins. Use results_direct / count_direct, which also " \
|
|
4269
|
+
"translate subqueries inside $and / $or / $nor, or run the query via REST."
|
|
4270
|
+
end
|
|
4271
|
+
raise ArgumentError,
|
|
4272
|
+
"[Parse::Query] #{key} cannot run on the mongo-direct path in this position. " \
|
|
4273
|
+
"A subquery is translated into a join only as a field constraint, at the top " \
|
|
4274
|
+
"level or inside $and / $or / $nor. Run this query via REST, or move the " \
|
|
4275
|
+
"subquery out of the enclosing operator."
|
|
4276
|
+
end
|
|
4277
|
+
refuse_untranslated_subquery!(value, context: context)
|
|
4278
|
+
end
|
|
4279
|
+
when Array
|
|
4280
|
+
node.each { |child| refuse_untranslated_subquery!(child, context: context) }
|
|
4281
|
+
end
|
|
4282
|
+
nil
|
|
4283
|
+
end
|
|
4284
|
+
|
|
4285
|
+
# @return [Boolean] true when a subquery operator appears anywhere in
|
|
4286
|
+
# the constraints, at any depth.
|
|
4287
|
+
# @api private
|
|
4288
|
+
def direct_subquery_present?(constraints)
|
|
4289
|
+
case constraints
|
|
4290
|
+
when Hash
|
|
4291
|
+
constraints.any? do |key, value|
|
|
4292
|
+
DIRECT_SUBQUERY_OPERATORS.include?(key.to_s) || direct_subquery_present?(value)
|
|
4293
|
+
end
|
|
4294
|
+
when Array
|
|
4295
|
+
constraints.any? { |child| direct_subquery_present?(child) }
|
|
4296
|
+
else
|
|
4297
|
+
false
|
|
4298
|
+
end
|
|
4299
|
+
end
|
|
4300
|
+
|
|
4301
|
+
# Build the `$lookup` stage for one subquery operator.
|
|
4302
|
+
# @api private
|
|
4303
|
+
def direct_subquery_lookup(field, op, spec, temp)
|
|
4304
|
+
spec = (spec || {}).transform_keys(&:to_s)
|
|
4305
|
+
if op == "$select" || op == "$dontSelect"
|
|
4306
|
+
query = (spec["query"] || {})
|
|
4307
|
+
query = query.transform_keys(&:to_s) if query.is_a?(Hash)
|
|
4308
|
+
class_name = query["className"].to_s
|
|
4309
|
+
where = query["where"] || {}
|
|
4310
|
+
key = (spec["key"] || field).to_s
|
|
4311
|
+
else
|
|
4312
|
+
class_name = spec["className"].to_s
|
|
4313
|
+
where = spec["where"] || {}
|
|
4314
|
+
key = nil
|
|
4315
|
+
end
|
|
4316
|
+
if class_name.empty?
|
|
4317
|
+
raise ArgumentError, "[Parse::Query] #{op} on '#{field}' is missing the subquery className."
|
|
4318
|
+
end
|
|
4319
|
+
|
|
4320
|
+
sub = Parse::Query.new(class_name)
|
|
4321
|
+
sub_match, sub_stages = Parse::Query.with_field_aliases(class_name) do
|
|
4322
|
+
sub.send(:direct_subquery_stages, where.is_a?(Hash) ? where : {})
|
|
4323
|
+
end
|
|
4324
|
+
local = convert_field_for_direct_mongodb(field)
|
|
4325
|
+
|
|
4326
|
+
if key.nil?
|
|
4327
|
+
# `$inQuery` / `$notInQuery` compare a pointer column, stored as
|
|
4328
|
+
# `Class$objectId`, with the subquery row's `_id`.
|
|
4329
|
+
local = "_p_#{Query.format_field(field)}" unless local.start_with?("_p_")
|
|
4330
|
+
pipeline = [
|
|
4331
|
+
{ "$match" => { "$expr" => { "$eq" => ["$_id", "$$subquery_id"] } } },
|
|
4332
|
+
]
|
|
4333
|
+
pipeline << { "$match" => sub_match } if sub_match.any?
|
|
4334
|
+
pipeline.concat(sub_stages)
|
|
4335
|
+
pipeline << { "$limit" => 1 }
|
|
4336
|
+
{
|
|
4337
|
+
"$lookup" => {
|
|
4338
|
+
"from" => class_name,
|
|
4339
|
+
"let" => {
|
|
4340
|
+
"subquery_id" => {
|
|
4341
|
+
"$arrayElemAt" => [{ "$split" => ["$#{local}", { "$literal" => "$" }] }, 1],
|
|
4342
|
+
},
|
|
4343
|
+
},
|
|
4344
|
+
"pipeline" => pipeline,
|
|
4345
|
+
"as" => temp,
|
|
4346
|
+
},
|
|
4347
|
+
}
|
|
4348
|
+
else
|
|
4349
|
+
foreign = Parse::Query.with_field_aliases(class_name) { sub.send(:convert_field_for_direct_mongodb, key) }
|
|
4350
|
+
pipeline = []
|
|
4351
|
+
pipeline << { "$match" => sub_match } if sub_match.any?
|
|
4352
|
+
pipeline.concat(sub_stages)
|
|
4353
|
+
pipeline << { "$match" => { "$expr" => { "$eq" => ["$#{foreign}", "$$subquery_value"] } } }
|
|
4354
|
+
pipeline << { "$limit" => 1 }
|
|
4355
|
+
{
|
|
4356
|
+
"$lookup" => {
|
|
4357
|
+
"from" => class_name,
|
|
4358
|
+
"let" => { "subquery_value" => "$#{local}" },
|
|
4359
|
+
"pipeline" => pipeline,
|
|
4360
|
+
"as" => temp,
|
|
4361
|
+
},
|
|
4362
|
+
}
|
|
4363
|
+
end
|
|
4364
|
+
end
|
|
4365
|
+
|
|
3110
4366
|
# Convert constraints for direct MongoDB execution.
|
|
3111
4367
|
# @param constraints [Hash] the compiled where constraints
|
|
3112
4368
|
# @return [Hash] constraints with MongoDB-native field names
|
|
@@ -3151,6 +4407,12 @@ module Parse
|
|
|
3151
4407
|
|
|
3152
4408
|
# Convert field name for MongoDB
|
|
3153
4409
|
mongo_field = convert_field_for_direct_mongodb(field_str)
|
|
4410
|
+
# Bare objectIds against a pointer column use the storage form.
|
|
4411
|
+
value = coerce_bare_pointer_ids(field_str, value, arrays: true)
|
|
4412
|
+
# `$containedBy` (every array element is in the list) has no MongoDB
|
|
4413
|
+
# operator. Parse Server's equivalent is "no element outside the
|
|
4414
|
+
# list".
|
|
4415
|
+
value = rewrite_contained_by_for_direct(value)
|
|
3154
4416
|
|
|
3155
4417
|
# Convert value
|
|
3156
4418
|
result[mongo_field] = convert_value_for_direct_mongodb(field_str, value)
|
|
@@ -3159,6 +4421,16 @@ module Parse
|
|
|
3159
4421
|
result
|
|
3160
4422
|
end
|
|
3161
4423
|
|
|
4424
|
+
# @api private
|
|
4425
|
+
def rewrite_contained_by_for_direct(value)
|
|
4426
|
+
return value unless value.is_a?(Hash)
|
|
4427
|
+
key = value.key?("$containedBy") ? "$containedBy" : (value.key?(:$containedBy) ? :$containedBy : nil)
|
|
4428
|
+
return value if key.nil?
|
|
4429
|
+
list = value[key]
|
|
4430
|
+
rest = value.reject { |k, _| k == key }
|
|
4431
|
+
rest.merge("$not" => { "$elemMatch" => { "$nin" => Array(list) } })
|
|
4432
|
+
end
|
|
4433
|
+
|
|
3162
4434
|
# Convert a field name for direct MongoDB access.
|
|
3163
4435
|
# @param field [String] the Parse field name
|
|
3164
4436
|
# @return [String] the MongoDB field name
|
|
@@ -3561,11 +4833,11 @@ module Parse
|
|
|
3561
4833
|
lq_client = client || Parse::LiveQuery.client
|
|
3562
4834
|
lq_client.subscribe(
|
|
3563
4835
|
@table,
|
|
3564
|
-
where:
|
|
4836
|
+
where: compile_rest_where,
|
|
3565
4837
|
fields: fields,
|
|
3566
4838
|
keys: keys,
|
|
3567
4839
|
watch: watch,
|
|
3568
|
-
session_token: session_token
|
|
4840
|
+
session_token: live_query_session_token(session_token, live_query_client: lq_client),
|
|
3569
4841
|
use_master_key: use_master_key,
|
|
3570
4842
|
&block
|
|
3571
4843
|
)
|
|
@@ -3667,7 +4939,8 @@ module Parse
|
|
|
3667
4939
|
# at the top-level stage.
|
|
3668
4940
|
BLOCKED_PIPELINE_STAGES = Parse::PipelineSecurity::DENIED_OPERATORS
|
|
3669
4941
|
|
|
3670
|
-
def aggregate(pipeline, verbose: nil, mongo_direct: nil, rewrite_lookups: nil, raw_values: false, raw_field_names: false
|
|
4942
|
+
def aggregate(pipeline, verbose: nil, mongo_direct: nil, rewrite_lookups: nil, raw_values: false, raw_field_names: false,
|
|
4943
|
+
window_before_pipeline: false)
|
|
3671
4944
|
validate_pipeline!(pipeline)
|
|
3672
4945
|
|
|
3673
4946
|
# Auto-rewrite LLM-style $lookup stages against logical Parse class
|
|
@@ -3767,29 +5040,31 @@ module Parse
|
|
|
3767
5040
|
end
|
|
3768
5041
|
end
|
|
3769
5042
|
|
|
3770
|
-
|
|
3771
|
-
|
|
5043
|
+
if window_before_pipeline
|
|
5044
|
+
# The query's order / skip / limit select the rows the pipeline
|
|
5045
|
+
# (a `$group` from sum, average, group_by, ...) runs over. Leading
|
|
5046
|
+
# `$match` stages of the pipeline filter rows, so they run first.
|
|
5047
|
+
leading = pipeline.take_while { |stage| stage.is_a?(Hash) && stage.keys == ["$match"] }
|
|
5048
|
+
complete_pipeline.concat(leading)
|
|
5049
|
+
complete_pipeline.concat(query_window_stages)
|
|
5050
|
+
complete_pipeline.concat(pipeline.drop(leading.size))
|
|
5051
|
+
else
|
|
5052
|
+
# Append the provided pipeline stages
|
|
5053
|
+
complete_pipeline.concat(pipeline)
|
|
3772
5054
|
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
@order.each do |order_obj|
|
|
3777
|
-
# order_obj is a Parse::Order object with field and direction
|
|
3778
|
-
field_name = order_obj.field.to_s
|
|
3779
|
-
direction = order_obj.direction == :desc ? -1 : 1
|
|
3780
|
-
sort_stage[field_name] = direction
|
|
3781
|
-
end
|
|
3782
|
-
complete_pipeline << { "$sort" => sort_stage } if sort_stage.any?
|
|
3783
|
-
end
|
|
5055
|
+
# Add $sort stage from order constraints if any exist
|
|
5056
|
+
sort = query_sort_stage
|
|
5057
|
+
complete_pipeline << sort if sort
|
|
3784
5058
|
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
5059
|
+
# Add $skip stage if specified
|
|
5060
|
+
if @skip > 0
|
|
5061
|
+
complete_pipeline << { "$skip" => @skip }
|
|
5062
|
+
end
|
|
3789
5063
|
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
5064
|
+
# Add $limit stage if specified
|
|
5065
|
+
if @limit.is_a?(Numeric) && @limit > 0
|
|
5066
|
+
complete_pipeline << { "$limit" => @limit }
|
|
5067
|
+
end
|
|
3793
5068
|
end
|
|
3794
5069
|
|
|
3795
5070
|
# Optimize pipeline by merging consecutive $match stages
|
|
@@ -3861,6 +5136,35 @@ module Parse
|
|
|
3861
5136
|
raw_values: raw_values, raw_field_names: raw_field_names)
|
|
3862
5137
|
end
|
|
3863
5138
|
|
|
5139
|
+
# @!visibility private
|
|
5140
|
+
# The `$sort` stage for the query's order, or nil.
|
|
5141
|
+
# @return [Hash, nil]
|
|
5142
|
+
def query_sort_stage
|
|
5143
|
+
return nil if @order.empty?
|
|
5144
|
+
sort_stage = {}
|
|
5145
|
+
@order.each do |order_obj|
|
|
5146
|
+
sort_stage[order_obj.field.to_s] = order_obj.direction == :desc ? -1 : 1
|
|
5147
|
+
end
|
|
5148
|
+
sort_stage.any? ? { "$sort" => sort_stage } : nil
|
|
5149
|
+
end
|
|
5150
|
+
|
|
5151
|
+
# @!visibility private
|
|
5152
|
+
# The stages that select which rows an aggregate helper groups: the
|
|
5153
|
+
# query's order, skip, and limit, in that order. Empty unless a skip or
|
|
5154
|
+
# a limit is set (an order alone does not change which rows are
|
|
5155
|
+
# grouped).
|
|
5156
|
+
# @return [Array<Hash>]
|
|
5157
|
+
def query_window_stages
|
|
5158
|
+
limited = @limit.is_a?(Numeric) && @limit > 0
|
|
5159
|
+
return [] unless @skip > 0 || limited
|
|
5160
|
+
stages = []
|
|
5161
|
+
sort = query_sort_stage
|
|
5162
|
+
stages << sort if sort
|
|
5163
|
+
stages << { "$skip" => @skip } if @skip > 0
|
|
5164
|
+
stages << { "$limit" => @limit } if limited
|
|
5165
|
+
stages
|
|
5166
|
+
end
|
|
5167
|
+
|
|
3864
5168
|
# Apply the direct-MongoDB stage converter to every stage in a pipeline.
|
|
3865
5169
|
# Idempotent on already-translated input (the per-stage converter
|
|
3866
5170
|
# passes `_p_*` references through unchanged).
|
|
@@ -4344,21 +5648,17 @@ module Parse
|
|
|
4344
5648
|
# $notInQuery: keep documents where lookup found no matches
|
|
4345
5649
|
post_lookup_match[lookup_result_field] = { "$eq" => [] }
|
|
4346
5650
|
end
|
|
4347
|
-
elsif value.is_a?(Hash)
|
|
4348
|
-
# Recursively handle nested constraints
|
|
4349
|
-
nested = extract_subquery_to_lookup_stages(value)
|
|
4350
|
-
if nested[:lookup_stages].any?
|
|
4351
|
-
lookup_stages.concat(nested[:lookup_stages])
|
|
4352
|
-
post_lookup_match.merge!(nested[:post_lookup_match])
|
|
4353
|
-
remaining_constraints[field] = nested[:constraints]
|
|
4354
|
-
else
|
|
4355
|
-
remaining_constraints[field] = value
|
|
4356
|
-
end
|
|
4357
5651
|
else
|
|
5652
|
+
# A subquery nested anywhere else (under `$or` / `$and` / `$nor`,
|
|
5653
|
+
# `$not`, `$elemMatch`) is not translated on this path. Lifting it
|
|
5654
|
+
# into the post-join `$match` would drop the enclosing operator and
|
|
5655
|
+
# change the query's meaning, and leaving it in place hands MongoDB
|
|
5656
|
+
# an operator it does not have. The scan below refuses it.
|
|
4358
5657
|
remaining_constraints[field] = value
|
|
4359
5658
|
end
|
|
4360
5659
|
end
|
|
4361
5660
|
|
|
5661
|
+
refuse_untranslated_subquery!(remaining_constraints, context: :aggregate)
|
|
4362
5662
|
{ constraints: remaining_constraints, lookup_stages: lookup_stages, post_lookup_match: post_lookup_match }
|
|
4363
5663
|
end
|
|
4364
5664
|
|
|
@@ -4390,18 +5690,11 @@ module Parse
|
|
|
4390
5690
|
# @return [Boolean] true if subquery constraints are present
|
|
4391
5691
|
def has_subquery_constraints?(constraints)
|
|
4392
5692
|
return false unless constraints.is_a?(Hash)
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
value.key?("$inQuery") || value.key?(:"$inQuery") ||
|
|
4399
|
-
value.key?("$notInQuery") || value.key?(:"$notInQuery") ||
|
|
4400
|
-
has_subquery_constraints?(value)
|
|
4401
|
-
else
|
|
4402
|
-
false
|
|
4403
|
-
end
|
|
4404
|
-
end
|
|
5693
|
+
# Any subquery operator at any depth (string or symbol keys, inside
|
|
5694
|
+
# logical-operator arrays too), so a nested one reaches
|
|
5695
|
+
# {#extract_subquery_to_lookup_stages} and is refused there rather
|
|
5696
|
+
# than passed to MongoDB verbatim.
|
|
5697
|
+
direct_subquery_present?(constraints)
|
|
4405
5698
|
end
|
|
4406
5699
|
|
|
4407
5700
|
alias_method :result, :results
|
|
@@ -4430,7 +5723,12 @@ module Parse
|
|
|
4430
5723
|
# Keys like ["project.name", "project.status"] define which subfields to fetch on nested objects
|
|
4431
5724
|
nested_keys = Parse::Query.parse_keys_to_nested_keys(@keys) if @keys.present?
|
|
4432
5725
|
|
|
4433
|
-
list.map { |m| Parse::Object.build(m, @table, fetched_keys: fetch_keys, nested_fetched_keys: nested_keys) }.compact
|
|
5726
|
+
built = list.map { |m| Parse::Object.build(m, @table, fetched_keys: fetch_keys, nested_fetched_keys: nested_keys) }.compact
|
|
5727
|
+
# Record session owners against the client that fetched them, so a
|
|
5728
|
+
# later delete of an already-gone session can still drop its owner's
|
|
5729
|
+
# cached identities.
|
|
5730
|
+
Parse::Session._remember_owners!(built, client) if @table == Parse::Model::CLASS_SESSION && defined?(Parse::Session)
|
|
5731
|
+
built
|
|
4434
5732
|
end
|
|
4435
5733
|
|
|
4436
5734
|
# Validates includes against keys and field types, printing debug warnings for:
|
|
@@ -4570,7 +5868,9 @@ module Parse
|
|
|
4570
5868
|
|
|
4571
5869
|
run_callbacks :prepare do
|
|
4572
5870
|
q = {} #query
|
|
4573
|
-
|
|
5871
|
+
# An explicit `limit(0)` is sent as 0 (no rows). Omitting it would let
|
|
5872
|
+
# the server apply its default page size.
|
|
5873
|
+
q[:limit] = @limit if @limit.is_a?(Numeric) && @limit >= 0
|
|
4574
5874
|
q[:skip] = @skip if @skip > 0
|
|
4575
5875
|
|
|
4576
5876
|
q[:include] = @includes.join(",") unless @includes.empty?
|
|
@@ -4578,7 +5878,7 @@ module Parse
|
|
|
4578
5878
|
q[:excludeKeys] = @exclude_keys.join(",") if encode && @exclude_keys&.any?
|
|
4579
5879
|
q[:order] = @order.join(",") unless @order.empty?
|
|
4580
5880
|
unless @where.empty?
|
|
4581
|
-
q[:where] = Parse::Query.compile_where(@where)
|
|
5881
|
+
q[:where] = coerce_rest_pointer_ids(Parse::Query.compile_where(@where))
|
|
4582
5882
|
q[:where] = q[:where].to_json if encode
|
|
4583
5883
|
end
|
|
4584
5884
|
|
|
@@ -4611,6 +5911,94 @@ module Parse
|
|
|
4611
5911
|
self.class.compile_where(@where || [])
|
|
4612
5912
|
end
|
|
4613
5913
|
|
|
5914
|
+
# @return [Hash] the where clause as sent to Parse Server over REST or
|
|
5915
|
+
# LiveQuery: {#compile_where} with bare objectIds on declared pointer
|
|
5916
|
+
# fields rewritten into Pointer hashes, so they match the stored
|
|
5917
|
+
# pointer.
|
|
5918
|
+
def compile_rest_where
|
|
5919
|
+
refuse_pipeline_only!("a LiveQuery subscription or push target")
|
|
5920
|
+
coerce_rest_pointer_ids(compile_where)
|
|
5921
|
+
end
|
|
5922
|
+
|
|
5923
|
+
# @!visibility private
|
|
5924
|
+
# Compile this query as a subquery of another (`$inQuery`,
|
|
5925
|
+
# `$notInQuery`, `$select`, `$dontSelect`). Parse Server evaluates it
|
|
5926
|
+
# from the REST form, so a pipeline-only constraint, which that form
|
|
5927
|
+
# drops, is refused rather than turning the subquery into match-all.
|
|
5928
|
+
# @return [Hash]
|
|
5929
|
+
def compile_subquery
|
|
5930
|
+
refuse_pipeline_only!("a subquery")
|
|
5931
|
+
enforce_or_branch_scope!
|
|
5932
|
+
compile(encode: false, includeClassName: true)
|
|
5933
|
+
end
|
|
5934
|
+
|
|
5935
|
+
# @!visibility private
|
|
5936
|
+
# Raise when this query holds a constraint that only the aggregation
|
|
5937
|
+
# pipeline or mongo-direct can run, for a target that receives only
|
|
5938
|
+
# the REST where form.
|
|
5939
|
+
def refuse_pipeline_only!(target)
|
|
5940
|
+
markers = compile_markers
|
|
5941
|
+
return unless Parse::Query.or_branch_marker?(markers)
|
|
5942
|
+
names = Parse::Query.pipeline_only_constraint_names(@where)
|
|
5943
|
+
raise ArgumentError,
|
|
5944
|
+
"This query cannot be used as #{target}: " \
|
|
5945
|
+
"#{names.empty? ? "a pipeline-only constraint" : names.join(", ")} needs the aggregation " \
|
|
5946
|
+
"pipeline or mongo-direct, and Parse Server receives only the REST where form, " \
|
|
5947
|
+
"which would drop it."
|
|
5948
|
+
end
|
|
5949
|
+
private :refuse_pipeline_only!
|
|
5950
|
+
|
|
5951
|
+
# @!visibility private
|
|
5952
|
+
# The session a LiveQuery subscription built from this query must use:
|
|
5953
|
+
# the query's own session token or the session of its `become` client.
|
|
5954
|
+
# LiveQuery has no equivalent of `scope_to_user` / `scope_to_role`, so
|
|
5955
|
+
# those raise, and an explicit token that differs from the query's
|
|
5956
|
+
# authority raises too.
|
|
5957
|
+
#
|
|
5958
|
+
# A query that carries any authority must target the same Parse
|
|
5959
|
+
# application as the LiveQuery client, compared by application id, so
|
|
5960
|
+
# one app's session token is never sent to another app's LiveQuery
|
|
5961
|
+
# server. A query with the default authority sends no query-derived
|
|
5962
|
+
# credential, so it is not checked.
|
|
5963
|
+
# @param explicit [String, nil] a `session_token:` passed by the caller.
|
|
5964
|
+
# @param live_query_client [Parse::LiveQuery::Client, nil] the client
|
|
5965
|
+
# the subscription will be sent on.
|
|
5966
|
+
# @return [String, nil]
|
|
5967
|
+
def live_query_session_token(explicit = nil, live_query_client: nil)
|
|
5968
|
+
enforce_or_branch_scope!
|
|
5969
|
+
scope = or_effective_scope
|
|
5970
|
+
if scope.key?(:user) || scope.key?(:role)
|
|
5971
|
+
raise ArgumentError,
|
|
5972
|
+
"A query scoped with scope_to_user / scope_to_role cannot be subscribed to: " \
|
|
5973
|
+
"LiveQuery authorizes by session token only. Subscribe with that user's session instead."
|
|
5974
|
+
end
|
|
5975
|
+
explicit = explicit.session_token if explicit.respond_to?(:session_token)
|
|
5976
|
+
own = scope[:session]
|
|
5977
|
+
if explicit.is_a?(String) && !explicit.empty? && own && explicit != own
|
|
5978
|
+
raise ArgumentError,
|
|
5979
|
+
"The session_token: passed to subscribe differs from the session this query runs under."
|
|
5980
|
+
end
|
|
5981
|
+
ensure_live_query_application!(live_query_client) unless scope.empty?
|
|
5982
|
+
(explicit.is_a?(String) && !explicit.empty?) ? explicit : own
|
|
5983
|
+
end
|
|
5984
|
+
|
|
5985
|
+
# @!visibility private
|
|
5986
|
+
# Raise when a LiveQuery client targets a different Parse application
|
|
5987
|
+
# than this query, so a credential bound to one app never reaches
|
|
5988
|
+
# another. Compared by application id: the LiveQuery URL is a WebSocket
|
|
5989
|
+
# endpoint and never matches the REST server URL.
|
|
5990
|
+
def ensure_live_query_application!(live_query_client)
|
|
5991
|
+
return if live_query_client.nil? || !live_query_client.respond_to?(:application_id)
|
|
5992
|
+
query_app = or_application_identity&.first
|
|
5993
|
+
lq_app = live_query_client.application_id.to_s
|
|
5994
|
+
return if query_app.nil? || query_app.empty? || lq_app.empty? || query_app == lq_app
|
|
5995
|
+
raise ArgumentError,
|
|
5996
|
+
"This query runs under an authority bound to application #{query_app.inspect}, but the " \
|
|
5997
|
+
"LiveQuery client targets #{lq_app.inspect}. Its credentials are not sent to another " \
|
|
5998
|
+
"application; subscribe on a LiveQuery client for the same application."
|
|
5999
|
+
end
|
|
6000
|
+
private :ensure_live_query_application!
|
|
6001
|
+
|
|
4614
6002
|
# @return [Hash] the un-stripped reduced where hash, including any
|
|
4615
6003
|
# SDK-internal markers like `"__mongo_direct_only"` and
|
|
4616
6004
|
# `"__aggregation_pipeline"`. Used by the routing layer to decide
|
|
@@ -5322,6 +6710,8 @@ module Parse
|
|
|
5322
6710
|
# @param result_key [String] the key to extract from the result
|
|
5323
6711
|
# @return [Object] the aggregation result
|
|
5324
6712
|
def execute_basic_aggregation(pipeline, operation, field, result_key)
|
|
6713
|
+
# `limit(0)` selects no rows.
|
|
6714
|
+
return nil if @limit == 0
|
|
5325
6715
|
# Add match stage if there are where conditions
|
|
5326
6716
|
compiled_where = compile_where
|
|
5327
6717
|
if compiled_where.present?
|
|
@@ -5331,8 +6721,9 @@ module Parse
|
|
|
5331
6721
|
pipeline.unshift({ "$match" => stringified_where })
|
|
5332
6722
|
end
|
|
5333
6723
|
|
|
5334
|
-
# Use the Aggregation class to execute
|
|
5335
|
-
|
|
6724
|
+
# Use the Aggregation class to execute. The query's order / skip /
|
|
6725
|
+
# limit pick the rows before `$group`.
|
|
6726
|
+
aggregation = aggregate(pipeline, verbose: @verbose_aggregate, window_before_pipeline: true)
|
|
5336
6727
|
raw_results = aggregation.raw
|
|
5337
6728
|
|
|
5338
6729
|
# Extract the result from the response
|
|
@@ -5360,7 +6751,7 @@ module Parse
|
|
|
5360
6751
|
formatted = Query.format_field(field)
|
|
5361
6752
|
# For pointer fields, MongoDB stores them with _p_ prefix
|
|
5362
6753
|
# Check if this field is defined as a pointer in the Parse class
|
|
5363
|
-
parse_class =
|
|
6754
|
+
parse_class = table_model_class
|
|
5364
6755
|
if parse_class && is_pointer_field?(parse_class, field, formatted)
|
|
5365
6756
|
"_p_#{formatted}"
|
|
5366
6757
|
else
|
|
@@ -5410,6 +6801,94 @@ module Parse
|
|
|
5410
6801
|
nil
|
|
5411
6802
|
end
|
|
5412
6803
|
|
|
6804
|
+
# @!visibility private
|
|
6805
|
+
# Rewrite bare objectId strings compared to a pointer column into the
|
|
6806
|
+
# "Class$objectId" storage form, for the aggregate and mongo-direct
|
|
6807
|
+
# paths. Only applies when the column is a declared pointer with a known
|
|
6808
|
+
# target class; values already in storage form, pointer hashes, and
|
|
6809
|
+
# non-string values are left alone.
|
|
6810
|
+
#
|
|
6811
|
+
# @param field [String, Symbol] the constraint key.
|
|
6812
|
+
# @param value [Object] the compiled constraint value.
|
|
6813
|
+
# @param arrays [Boolean] also rewrite `$in` / `$nin` / `$all` arrays.
|
|
6814
|
+
# @return [Object] the value, rewritten when it applies.
|
|
6815
|
+
def coerce_bare_pointer_ids(field, value, arrays:)
|
|
6816
|
+
return value unless value.is_a?(String) || value.is_a?(Hash)
|
|
6817
|
+
name = field.to_s
|
|
6818
|
+
name = name.delete_prefix("_p_")
|
|
6819
|
+
return value if name.include?(".")
|
|
6820
|
+
klass = table_model_class
|
|
6821
|
+
return value unless klass
|
|
6822
|
+
target = get_pointer_target_class_for(klass, name)
|
|
6823
|
+
return value unless target
|
|
6824
|
+
bare = ->(v) { v.is_a?(String) && !v.empty? && !v.include?("$") }
|
|
6825
|
+
to_storage = ->(v) { bare.(v) ? "#{target}$#{v}" : v }
|
|
6826
|
+
case value
|
|
6827
|
+
when String
|
|
6828
|
+
to_storage.(value)
|
|
6829
|
+
when Hash
|
|
6830
|
+
return value if value.key?("__type") || value.key?(:__type)
|
|
6831
|
+
value.each_with_object({}) do |(op, op_value), out|
|
|
6832
|
+
out[op] = case op.to_s
|
|
6833
|
+
when "$eq", "$ne" then to_storage.(op_value)
|
|
6834
|
+
when "$in", "$nin", "$all"
|
|
6835
|
+
arrays && op_value.is_a?(Array) ? op_value.map(&to_storage) : op_value
|
|
6836
|
+
else op_value
|
|
6837
|
+
end
|
|
6838
|
+
end
|
|
6839
|
+
end
|
|
6840
|
+
end
|
|
6841
|
+
|
|
6842
|
+
# @!visibility private
|
|
6843
|
+
# Rewrite bare objectId strings compared to a declared pointer column
|
|
6844
|
+
# into Pointer hashes, for the REST where clause. Parse Server compares
|
|
6845
|
+
# a pointer column against its "Class$objectId" storage form, so a bare
|
|
6846
|
+
# objectId never matched (and `$ne` matched every row). Same rules as
|
|
6847
|
+
# {#coerce_bare_pointer_ids}: only declared pointers with a known target
|
|
6848
|
+
# class, only `$eq` / `$ne` / `$in` / `$nin` / `$all` and plain
|
|
6849
|
+
# equality, recursing into `$or` / `$and` / `$nor`.
|
|
6850
|
+
# @param where [Hash] a compiled where clause.
|
|
6851
|
+
# @return [Hash]
|
|
6852
|
+
def coerce_rest_pointer_ids(where)
|
|
6853
|
+
return where unless where.is_a?(Hash) && !where.empty?
|
|
6854
|
+
klass = table_model_class
|
|
6855
|
+
return where unless klass
|
|
6856
|
+
where.each_with_object({}) do |(field, value), out|
|
|
6857
|
+
name = field.to_s
|
|
6858
|
+
out[field] = if %w[$or $and $nor].include?(name) && value.is_a?(Array)
|
|
6859
|
+
value.map { |branch| coerce_rest_pointer_ids(branch) }
|
|
6860
|
+
elsif name.start_with?("$") || name.include?(".")
|
|
6861
|
+
value
|
|
6862
|
+
elsif (target = get_pointer_target_class_for(klass, name))
|
|
6863
|
+
rest_pointer_value(target, value)
|
|
6864
|
+
else
|
|
6865
|
+
value
|
|
6866
|
+
end
|
|
6867
|
+
end
|
|
6868
|
+
end
|
|
6869
|
+
|
|
6870
|
+
# @!visibility private
|
|
6871
|
+
def rest_pointer_value(target, value)
|
|
6872
|
+
bare = ->(v) { v.is_a?(String) && !v.empty? && !v.include?("$") }
|
|
6873
|
+
to_pointer = ->(v) { bare.(v) ? { "__type" => "Pointer", "className" => target, "objectId" => v } : v }
|
|
6874
|
+
case value
|
|
6875
|
+
when String
|
|
6876
|
+
to_pointer.(value)
|
|
6877
|
+
when Hash
|
|
6878
|
+
return value if value.key?("__type") || value.key?(:__type)
|
|
6879
|
+
value.each_with_object({}) do |(op, op_value), out|
|
|
6880
|
+
out[op] = case op.to_s
|
|
6881
|
+
when "$eq", "$ne" then to_pointer.(op_value)
|
|
6882
|
+
when "$in", "$nin", "$all"
|
|
6883
|
+
op_value.is_a?(Array) ? op_value.map(&to_pointer) : op_value
|
|
6884
|
+
else op_value
|
|
6885
|
+
end
|
|
6886
|
+
end
|
|
6887
|
+
else
|
|
6888
|
+
value
|
|
6889
|
+
end
|
|
6890
|
+
end
|
|
6891
|
+
|
|
5413
6892
|
# Handle a constraint value that is a bare String inside `$in`/`$nin`
|
|
5414
6893
|
# against a column positively identified as a pointer, when the
|
|
5415
6894
|
# target class cannot be resolved (no local belongs_to AND no peer
|
|
@@ -5444,10 +6923,27 @@ module Parse
|
|
|
5444
6923
|
|
|
5445
6924
|
# Check if a field is a pointer field using schema information
|
|
5446
6925
|
# @param field [Symbol, String] the field name to check
|
|
6926
|
+
# @api private
|
|
6927
|
+
# The model class registered for this query's table. Resolves through
|
|
6928
|
+
# each model's `parse_class` (Parse::Model.find_class), so it works for
|
|
6929
|
+
# Parse class names that are not valid Ruby constants (`"contacts"`,
|
|
6930
|
+
# `"_User"`) and for models whose `parse_class` differs from the Ruby
|
|
6931
|
+
# class name. Falls back to a constant lookup for a table named after a
|
|
6932
|
+
# Ruby class that has not been registered under that name. Before 5.8
|
|
6933
|
+
# the query layer used `Parse::Model.const_get(@table)` alone, which
|
|
6934
|
+
# raised (and was rescued to nil) for such names, so pointer fields were
|
|
6935
|
+
# silently treated as plain fields: mongo-direct pipelines addressed
|
|
6936
|
+
# `owner` instead of `_p_owner` and matched nothing.
|
|
6937
|
+
#
|
|
6938
|
+
# @return [Class, nil]
|
|
6939
|
+
def table_model_class
|
|
6940
|
+
Parse::Model.find_class(@table) || (Parse::Model.const_get(@table) rescue nil)
|
|
6941
|
+
end
|
|
6942
|
+
|
|
5447
6943
|
# @return [Boolean] true if the field is a pointer field
|
|
5448
6944
|
def field_is_pointer?(field)
|
|
5449
6945
|
begin
|
|
5450
|
-
parse_class =
|
|
6946
|
+
parse_class = table_model_class
|
|
5451
6947
|
return false unless parse_class.respond_to?(:fields)
|
|
5452
6948
|
|
|
5453
6949
|
# If the field already has _p_ prefix, strip it to get the original field name
|
|
@@ -5500,7 +6996,7 @@ module Parse
|
|
|
5500
6996
|
# @api private
|
|
5501
6997
|
def field_is_known_to_schema?(field)
|
|
5502
6998
|
begin
|
|
5503
|
-
parse_class =
|
|
6999
|
+
parse_class = table_model_class
|
|
5504
7000
|
return false unless parse_class.respond_to?(:fields)
|
|
5505
7001
|
|
|
5506
7002
|
fields_to_check = [field.to_s, field.to_sym]
|
|
@@ -5555,7 +7051,7 @@ module Parse
|
|
|
5555
7051
|
def convert_pointer_value_with_schema(value, field_name, **options)
|
|
5556
7052
|
return value unless value # nil/empty values pass through
|
|
5557
7053
|
|
|
5558
|
-
parse_class =
|
|
7054
|
+
parse_class = table_model_class
|
|
5559
7055
|
is_pointer = parse_class && is_pointer_field?(parse_class, field_name, Query.format_field(field_name))
|
|
5560
7056
|
target_class = parse_class ? get_pointer_target_class_for(parse_class, field_name) : nil
|
|
5561
7057
|
|
|
@@ -5649,6 +7145,12 @@ module Parse
|
|
|
5649
7145
|
next
|
|
5650
7146
|
end
|
|
5651
7147
|
|
|
7148
|
+
# A bare objectId compared to a pointer column becomes the
|
|
7149
|
+
# "Class$objectId" storage form. Compared as-is it never equals the
|
|
7150
|
+
# stored value, so equality matched nothing and `$ne` matched every
|
|
7151
|
+
# row. (`$in` / `$nin` arrays are converted below.)
|
|
7152
|
+
value = coerce_bare_pointer_ids(field, value, arrays: false)
|
|
7153
|
+
|
|
5652
7154
|
# Convert field name to aggregation format
|
|
5653
7155
|
# If field already has _p_ prefix, don't reformat it
|
|
5654
7156
|
if field.to_s.start_with?("_p_")
|
|
@@ -5696,7 +7198,7 @@ module Parse
|
|
|
5696
7198
|
class_name = nil
|
|
5697
7199
|
|
|
5698
7200
|
# First try to get it from the schema
|
|
5699
|
-
parse_class =
|
|
7201
|
+
parse_class = table_model_class
|
|
5700
7202
|
if parse_class
|
|
5701
7203
|
class_name = get_pointer_target_class_for(parse_class, field)
|
|
5702
7204
|
end
|
|
@@ -5790,20 +7292,25 @@ module Parse
|
|
|
5790
7292
|
raise ArgumentError, "All queries passed to Parse::Query.or must be for the same Parse class."
|
|
5791
7293
|
end
|
|
5792
7294
|
|
|
5793
|
-
# Start with an empty query for this table
|
|
7295
|
+
# Start with an empty query for this table, carrying the members'
|
|
7296
|
+
# common auth scope (raises when two members set different scopes).
|
|
5794
7297
|
result = self.new(table)
|
|
7298
|
+
# Run against the members' application: seed the client from the
|
|
7299
|
+
# first member that sets one (the members are then checked to agree).
|
|
7300
|
+
seed = queries.lazy.map { |q| q.send(:or_application_client) }.find(&:itself)
|
|
7301
|
+
result.instance_variable_set(:@client, seed) if seed
|
|
7302
|
+
queries.each { |query| result.send(:merge_or_scope!, query) }
|
|
5795
7303
|
|
|
5796
|
-
#
|
|
5797
|
-
|
|
7304
|
+
# Refuse pipeline-only constraints in any member first, so one is
|
|
7305
|
+
# never dropped or widened.
|
|
7306
|
+
queries.each { |query| compile_or_branch(query.where) }
|
|
7307
|
+
|
|
7308
|
+
# A query with no constraints matches every row, so an OR that
|
|
7309
|
+
# includes one does too.
|
|
7310
|
+
return result if queries.any? { |q| match_all?(q.where) }
|
|
5798
7311
|
|
|
5799
7312
|
# Add each query's complete constraint set as an OR branch
|
|
5800
|
-
queries.each
|
|
5801
|
-
# Compile the where constraints to check if they result in empty conditions
|
|
5802
|
-
compiled_where = Parse::Query.compile_where(query.where)
|
|
5803
|
-
unless compiled_where.empty?
|
|
5804
|
-
result.or_where(query.where)
|
|
5805
|
-
end
|
|
5806
|
-
end
|
|
7313
|
+
queries.each { |query| result.or_where(query.where) }
|
|
5807
7314
|
|
|
5808
7315
|
result
|
|
5809
7316
|
end
|
|
@@ -5850,13 +7357,19 @@ module Parse
|
|
|
5850
7357
|
|
|
5851
7358
|
# Creates a deep copy of this query object, allowing independent modifications
|
|
5852
7359
|
# @return [Parse::Query] a new query object with the same constraints
|
|
5853
|
-
# @note The @
|
|
5854
|
-
#
|
|
7360
|
+
# @note The @results instance variable is not cloned. A client that sets
|
|
7361
|
+
# the query's authority (session-bound or without a master key) is kept
|
|
7362
|
+
# by reference; otherwise the copy uses the default client.
|
|
5855
7363
|
def clone
|
|
5856
7364
|
cloned_query = Parse::Query.new(self.instance_variable_get(:@table))
|
|
5857
7365
|
# Note: :client is intentionally excluded - it contains non-serializable objects
|
|
5858
7366
|
# (Redis connections, Faraday connections) and should be obtained lazily
|
|
5859
|
-
|
|
7367
|
+
# The auth scope and read routing travel with the copy, so a cloned or
|
|
7368
|
+
# combined query never runs under a broader authority than the
|
|
7369
|
+
# original.
|
|
7370
|
+
[:count, :where, :order, :keys, :exclude_keys, :includes, :limit, :skip, :cache, :use_master_key, :hint,
|
|
7371
|
+
:session_token, :acl_user, :acl_role, :read_preference, :key, :acl_query_mongo_direct,
|
|
7372
|
+
:_or_branch_scope, :_or_branch_app].each do |param|
|
|
5860
7373
|
if instance_variable_defined?(:"@#{param}")
|
|
5861
7374
|
value = instance_variable_get(:"@#{param}")
|
|
5862
7375
|
if value.is_a?(Array) || value.is_a?(Hash)
|
|
@@ -5874,6 +7387,12 @@ module Parse
|
|
|
5874
7387
|
cloned_query.instance_variable_set(:"@#{param}", cloned_value)
|
|
5875
7388
|
end
|
|
5876
7389
|
end
|
|
7390
|
+
# A client set on the query (one from Parse::Client#become or
|
|
7391
|
+
# #anonymous, or a client for another application) decides where the
|
|
7392
|
+
# query's session token and authority are sent, so the copy keeps it.
|
|
7393
|
+
# Only the memoized default client is resolved lazily again.
|
|
7394
|
+
own_client = or_application_client
|
|
7395
|
+
cloned_query.instance_variable_set(:@client, own_client) if own_client
|
|
5877
7396
|
cloned_query.instance_variable_set(:@results, nil)
|
|
5878
7397
|
cloned_query
|
|
5879
7398
|
end
|
|
@@ -6111,20 +7630,62 @@ module Parse
|
|
|
6111
7630
|
|
|
6112
7631
|
# Wrapper class for custom aggregation results (from $group, $project, etc.)
|
|
6113
7632
|
# Provides both hash-style access and method-style access to fields.
|
|
6114
|
-
#
|
|
7633
|
+
#
|
|
7634
|
+
# Two naming modes:
|
|
7635
|
+
#
|
|
7636
|
+
# * `:default` (the default): field names are converted from camelCase to
|
|
7637
|
+
# snake_case symbols. `#to_h` returns those symbol keys, and the original
|
|
7638
|
+
# string keys stay readable through `#[]` and `#raw`.
|
|
7639
|
+
# * `:server`: field names are kept exactly as the aggregation returned
|
|
7640
|
+
# them. `#to_h` and `#keys` use the original String keys (`"totalPlays"`,
|
|
7641
|
+
# `"ExternalID"`), nested values are untouched, and distinct keys whose
|
|
7642
|
+
# snake_case forms collide (`"totalPlays"` and `"total_plays"`) both
|
|
7643
|
+
# survive. Method-style access still accepts a snake_case name when it
|
|
7644
|
+
# identifies exactly one key, and raises when it is ambiguous.
|
|
6115
7645
|
#
|
|
6116
7646
|
# @example
|
|
6117
7647
|
# result = AggregationResult.new({ "_id" => "Rock", "totalPlays" => 500 })
|
|
6118
7648
|
# result["_id"] # => "Rock"
|
|
6119
7649
|
# result[:total_plays] # => 500
|
|
6120
7650
|
# result.total_plays # => 500
|
|
7651
|
+
# result.to_h # => { _id: "Rock", total_plays: 500 }
|
|
7652
|
+
#
|
|
7653
|
+
# server = AggregationResult.new({ "_id" => "Rock", "totalPlays" => 500 }, field_names: :server)
|
|
7654
|
+
# server.to_h # => { "_id" => "Rock", "totalPlays" => 500 }
|
|
6121
7655
|
#
|
|
6122
7656
|
class AggregationResult
|
|
7657
|
+
# Naming modes accepted by `field_names:` across the SDK's result APIs.
|
|
7658
|
+
FIELD_NAME_MODES = %i[default server].freeze
|
|
7659
|
+
|
|
7660
|
+
# Normalize a `field_names:` option. nil means `:default`.
|
|
7661
|
+
#
|
|
7662
|
+
# @param value [Symbol, String, nil]
|
|
7663
|
+
# @return [Symbol] `:default` or `:server`
|
|
7664
|
+
# @raise [ArgumentError] for any other value.
|
|
7665
|
+
def self.normalize_field_names!(value)
|
|
7666
|
+
return :default if value.nil?
|
|
7667
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
7668
|
+
return mode if FIELD_NAME_MODES.include?(mode)
|
|
7669
|
+
raise ArgumentError,
|
|
7670
|
+
"field_names: must be one of #{FIELD_NAME_MODES.inspect} (got #{value.inspect})."
|
|
7671
|
+
end
|
|
7672
|
+
|
|
7673
|
+
# @return [Symbol] `:default` or `:server`.
|
|
7674
|
+
attr_reader :field_names
|
|
7675
|
+
|
|
6123
7676
|
# @param data [Hash] the raw aggregation result hash
|
|
6124
|
-
|
|
7677
|
+
# @param field_names [Symbol, nil] `:default` (snake_case symbol keys) or
|
|
7678
|
+
# `:server` (keys exactly as returned).
|
|
7679
|
+
def initialize(data, field_names: nil)
|
|
7680
|
+
@field_names = self.class.normalize_field_names!(field_names)
|
|
6125
7681
|
@data = {}
|
|
6126
7682
|
@raw_data = data
|
|
6127
7683
|
|
|
7684
|
+
if @field_names == :server
|
|
7685
|
+
data.each { |key, value| @data[key.to_s] = value }
|
|
7686
|
+
return
|
|
7687
|
+
end
|
|
7688
|
+
|
|
6128
7689
|
# Convert keys to snake_case and store
|
|
6129
7690
|
data.each do |key, value|
|
|
6130
7691
|
snake_key = Parse::Query.to_snake_case(key.to_s)
|
|
@@ -6137,6 +7698,7 @@ module Parse
|
|
|
6137
7698
|
# @param key [String, Symbol] the field name
|
|
6138
7699
|
# @return [Object] the field value
|
|
6139
7700
|
def [](key)
|
|
7701
|
+
return @data[server_key_for(key)] if server?
|
|
6140
7702
|
@data[key.to_s] || @data[key.to_sym]
|
|
6141
7703
|
end
|
|
6142
7704
|
|
|
@@ -6144,18 +7706,23 @@ module Parse
|
|
|
6144
7706
|
# @param key [String, Symbol] the field name
|
|
6145
7707
|
# @return [Boolean]
|
|
6146
7708
|
def key?(key)
|
|
7709
|
+
return @data.key?(server_key_for(key)) if server?
|
|
6147
7710
|
@data.key?(key.to_s) || @data.key?(key.to_sym)
|
|
6148
7711
|
end
|
|
6149
7712
|
|
|
6150
|
-
# Get all keys
|
|
6151
|
-
#
|
|
7713
|
+
# Get all keys: snake_case symbols by default, or the original String
|
|
7714
|
+
# keys in `:server` mode.
|
|
7715
|
+
# @return [Array<Symbol>, Array<String>]
|
|
6152
7716
|
def keys
|
|
7717
|
+
return @data.keys if server?
|
|
6153
7718
|
@data.keys.select { |k| k.is_a?(Symbol) }
|
|
6154
7719
|
end
|
|
6155
7720
|
|
|
6156
|
-
# Convert to hash
|
|
7721
|
+
# Convert to hash: snake_case symbol keys by default, or the original
|
|
7722
|
+
# String keys (a copy, nested values untouched) in `:server` mode.
|
|
6157
7723
|
# @return [Hash]
|
|
6158
7724
|
def to_h
|
|
7725
|
+
return @data.dup if server?
|
|
6159
7726
|
@data.select { |k, _| k.is_a?(Symbol) }
|
|
6160
7727
|
end
|
|
6161
7728
|
|
|
@@ -6168,8 +7735,18 @@ module Parse
|
|
|
6168
7735
|
@raw_data
|
|
6169
7736
|
end
|
|
6170
7737
|
|
|
7738
|
+
# @return [Boolean] true in `:server` naming mode.
|
|
7739
|
+
def server?
|
|
7740
|
+
@field_names == :server
|
|
7741
|
+
end
|
|
7742
|
+
|
|
6171
7743
|
# Method-style access to fields
|
|
6172
7744
|
def method_missing(method_name, *args, &block)
|
|
7745
|
+
if server?
|
|
7746
|
+
key = server_key_for(method_name, strict: true)
|
|
7747
|
+
return @data[key] if key
|
|
7748
|
+
return super
|
|
7749
|
+
end
|
|
6173
7750
|
key = method_name.to_sym
|
|
6174
7751
|
if @data.key?(key)
|
|
6175
7752
|
@data[key]
|
|
@@ -6179,12 +7756,32 @@ module Parse
|
|
|
6179
7756
|
end
|
|
6180
7757
|
|
|
6181
7758
|
def respond_to_missing?(method_name, include_private = false)
|
|
7759
|
+
return !server_key_for(method_name).nil? || super if server?
|
|
6182
7760
|
@data.key?(method_name.to_sym) || super
|
|
6183
7761
|
end
|
|
6184
7762
|
|
|
6185
7763
|
def inspect
|
|
6186
7764
|
"#<Parse::AggregationResult #{to_h.inspect}>"
|
|
6187
7765
|
end
|
|
7766
|
+
|
|
7767
|
+
private
|
|
7768
|
+
|
|
7769
|
+
# Resolve a requested name to a stored key in `:server` mode: an exact
|
|
7770
|
+
# match first, else the single key whose snake_case form equals it. Two
|
|
7771
|
+
# or more snake_case matches are ambiguous (e.g. `totalPlays` and
|
|
7772
|
+
# `total_plays` both stored): `strict:` raises naming them, otherwise nil.
|
|
7773
|
+
def server_key_for(name, strict: false)
|
|
7774
|
+
str = name.to_s
|
|
7775
|
+
return str if @data.key?(str)
|
|
7776
|
+
matches = @data.keys.select { |k| Parse::Query.to_snake_case(k) == str }
|
|
7777
|
+
return matches.first if matches.length == 1
|
|
7778
|
+
if matches.length > 1 && strict
|
|
7779
|
+
raise ArgumentError,
|
|
7780
|
+
"Parse::AggregationResult: #{str.inspect} matches several fields " \
|
|
7781
|
+
"(#{matches.inspect}); read one with result[#{matches.first.inspect}]."
|
|
7782
|
+
end
|
|
7783
|
+
nil
|
|
7784
|
+
end
|
|
6188
7785
|
end
|
|
6189
7786
|
|
|
6190
7787
|
# Helper class for executing arbitrary MongoDB aggregation pipelines.
|
|
@@ -6303,7 +7900,13 @@ module Parse
|
|
|
6303
7900
|
#
|
|
6304
7901
|
# @yield a block to iterate for each object in the result
|
|
6305
7902
|
# @return [Array<Parse::Object, AggregationResult>] array of results
|
|
6306
|
-
|
|
7903
|
+
#
|
|
7904
|
+
# @param field_names [Symbol, nil] naming mode for AggregationResult rows:
|
|
7905
|
+
# `:default` (snake_case symbol keys from `#to_h`) or `:server` (keys
|
|
7906
|
+
# exactly as the aggregation returned them). Parse::Object rows are
|
|
7907
|
+
# unaffected; use `#as_json` for their server-named form.
|
|
7908
|
+
def results(field_names: nil, &block)
|
|
7909
|
+
@result_field_names = AggregationResult.normalize_field_names!(field_names)
|
|
6307
7910
|
response = execute!
|
|
6308
7911
|
|
|
6309
7912
|
if @mongo_direct && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
@@ -6333,7 +7936,7 @@ module Parse
|
|
|
6333
7936
|
if looks_like_parse_document?(item)
|
|
6334
7937
|
@query.send(:decode, [item]).first
|
|
6335
7938
|
else
|
|
6336
|
-
AggregationResult.new(item)
|
|
7939
|
+
AggregationResult.new(item, field_names: @result_field_names)
|
|
6337
7940
|
end
|
|
6338
7941
|
end
|
|
6339
7942
|
|
|
@@ -6351,7 +7954,7 @@ module Parse
|
|
|
6351
7954
|
@query.send(:redact_excluded_keys!, [parse_doc])
|
|
6352
7955
|
@query.send(:decode, [parse_doc]).first
|
|
6353
7956
|
else
|
|
6354
|
-
AggregationResult.new(Parse::MongoDB.convert_aggregation_document(raw))
|
|
7957
|
+
AggregationResult.new(Parse::MongoDB.convert_aggregation_document(raw), field_names: @result_field_names)
|
|
6355
7958
|
end
|
|
6356
7959
|
end
|
|
6357
7960
|
|
|
@@ -6667,7 +8270,9 @@ module Parse
|
|
|
6667
8270
|
pipeline << sort if sort
|
|
6668
8271
|
pipeline << { "$project" => { "_id" => 0, "objectId" => "$_id", "count" => 1 } }
|
|
6669
8272
|
|
|
6670
|
-
|
|
8273
|
+
return [] if @query.instance_variable_get(:@limit) == 0
|
|
8274
|
+
@query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate),
|
|
8275
|
+
window_before_pipeline: true).raw || []
|
|
6671
8276
|
end
|
|
6672
8277
|
|
|
6673
8278
|
# Count the number of items in each group.
|
|
@@ -6799,6 +8404,9 @@ module Parse
|
|
|
6799
8404
|
# what the caller meant.
|
|
6800
8405
|
validate_sort_target_for_operation!(operation)
|
|
6801
8406
|
|
|
8407
|
+
# `limit(0)` selects no rows, so there are no groups.
|
|
8408
|
+
return {} if @query.instance_variable_get(:@limit) == 0
|
|
8409
|
+
|
|
6802
8410
|
# Format the group field name
|
|
6803
8411
|
formatted_group_field = @query.send(:format_aggregation_field, @group_field)
|
|
6804
8412
|
|
|
@@ -6853,8 +8461,10 @@ module Parse
|
|
|
6853
8461
|
},
|
|
6854
8462
|
}
|
|
6855
8463
|
|
|
6856
|
-
# Use the Aggregation class to execute
|
|
6857
|
-
|
|
8464
|
+
# Use the Aggregation class to execute. The query's order / skip /
|
|
8465
|
+
# limit pick the rows before `$group`.
|
|
8466
|
+
aggregation = @query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate),
|
|
8467
|
+
window_before_pipeline: true)
|
|
6858
8468
|
raw_results = aggregation.raw
|
|
6859
8469
|
|
|
6860
8470
|
# Convert array of results to hash
|
|
@@ -6922,6 +8532,10 @@ module Parse
|
|
|
6922
8532
|
pipeline << { "$match" => mongo_constraints } if mongo_constraints.any?
|
|
6923
8533
|
end
|
|
6924
8534
|
|
|
8535
|
+
# The query's order / skip / limit pick the rows before `$group`.
|
|
8536
|
+
window = @query.send(:query_window_stages)
|
|
8537
|
+
pipeline.concat(@query.send(:translate_pipeline_for_direct_mongodb, window)) if window.any?
|
|
8538
|
+
|
|
6925
8539
|
# Add unwind stage if flatten_arrays is enabled
|
|
6926
8540
|
if @flatten_arrays
|
|
6927
8541
|
pipeline << { "$unwind" => "$#{mongo_group_field}" }
|
|
@@ -7987,3 +9601,93 @@ module Parse
|
|
|
7987
9601
|
end
|
|
7988
9602
|
end
|
|
7989
9603
|
end # Parse
|
|
9604
|
+
|
|
9605
|
+
module Parse
|
|
9606
|
+
# Wraps the public compile and pipeline entry points of Parse::Query and
|
|
9607
|
+
# the aggregation helpers so {Parse::Query.format_field} honors the model's
|
|
9608
|
+
# explicit `field:` names (for example `account_id` or `authId_sub`) instead
|
|
9609
|
+
# of camel-casing them. Without this, `where(account_id: ...)` compiled to
|
|
9610
|
+
# `accountId` and silently matched nothing.
|
|
9611
|
+
module QueryFieldAliasScope
|
|
9612
|
+
QUERY_METHODS = %i[
|
|
9613
|
+
add_constraint keys exclude_keys order includes pluck count_distinct
|
|
9614
|
+
compile compile_where prepared pipeline count results distinct first first_direct
|
|
9615
|
+
build_direct_mongodb_pipeline build_query_aggregate_pipeline build_aggregation_pipeline
|
|
9616
|
+
aggregate group_by group_by_date explain sum average min max
|
|
9617
|
+
].freeze
|
|
9618
|
+
|
|
9619
|
+
# Aggregation helper methods that format field names while running.
|
|
9620
|
+
HELPER_METHODS = %i[pipeline results count execute! sum average min max list raw].freeze
|
|
9621
|
+
|
|
9622
|
+
# The mongo-direct entry points are wrapped with their exact signatures
|
|
9623
|
+
# (not `*args, **kwargs`), so `Method#parameters` still reports the
|
|
9624
|
+
# `client:` keyword that client-binding checks rely on.
|
|
9625
|
+
module DirectMethods
|
|
9626
|
+
def results_direct(raw: false, max_time_ms: nil, session_token: nil, master: nil,
|
|
9627
|
+
acl_user: nil, acl_role: nil, client: nil, &block)
|
|
9628
|
+
block = Parse::Query.block_outside_field_aliases(block, @table)
|
|
9629
|
+
Parse::Query.with_field_aliases(@table) do
|
|
9630
|
+
super(raw: raw, max_time_ms: max_time_ms, session_token: session_token, master: master,
|
|
9631
|
+
acl_user: acl_user, acl_role: acl_role, client: client, &block)
|
|
9632
|
+
end
|
|
9633
|
+
end
|
|
9634
|
+
|
|
9635
|
+
def count_direct(session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil)
|
|
9636
|
+
Parse::Query.with_field_aliases(@table) do
|
|
9637
|
+
super(session_token: session_token, master: master, acl_user: acl_user,
|
|
9638
|
+
acl_role: acl_role, client: client)
|
|
9639
|
+
end
|
|
9640
|
+
end
|
|
9641
|
+
|
|
9642
|
+
def distinct_direct(field, return_pointers: false, order: nil, session_token: nil, master: nil,
|
|
9643
|
+
acl_user: nil, acl_role: nil, client: nil)
|
|
9644
|
+
Parse::Query.with_field_aliases(@table) do
|
|
9645
|
+
super(field, return_pointers: return_pointers, order: order, session_token: session_token,
|
|
9646
|
+
master: master, acl_user: acl_user, acl_role: acl_role, client: client)
|
|
9647
|
+
end
|
|
9648
|
+
end
|
|
9649
|
+
|
|
9650
|
+
def distinct_direct_pointers(field, order: nil, session_token: nil, master: nil,
|
|
9651
|
+
acl_user: nil, acl_role: nil, client: nil)
|
|
9652
|
+
Parse::Query.with_field_aliases(@table) do
|
|
9653
|
+
super(field, order: order, session_token: session_token, master: master,
|
|
9654
|
+
acl_user: acl_user, acl_role: acl_role, client: client)
|
|
9655
|
+
end
|
|
9656
|
+
end
|
|
9657
|
+
end
|
|
9658
|
+
|
|
9659
|
+
# @param klass [Class] the class whose methods are wrapped.
|
|
9660
|
+
# @param methods [Array<Symbol>]
|
|
9661
|
+
# @param table_of [Proc] `->(receiver) { parse_class_name }`.
|
|
9662
|
+
def self.wrap(klass, methods, table_of)
|
|
9663
|
+
private_methods = methods.select { |m| klass.private_method_defined?(m) }
|
|
9664
|
+
protected_methods = methods.select { |m| klass.protected_method_defined?(m) }
|
|
9665
|
+
mod = Module.new
|
|
9666
|
+
methods.each do |m|
|
|
9667
|
+
next unless klass.method_defined?(m) || klass.private_method_defined?(m)
|
|
9668
|
+
# A call for the table already in scope goes straight to the
|
|
9669
|
+
# original, so nested query building pays only the table lookup.
|
|
9670
|
+
mod.send(:define_method, m) do |*args, **kwargs, &blk|
|
|
9671
|
+
table = table_of.call(self)
|
|
9672
|
+
frame = Fiber[Parse::Query::FIELD_ALIAS_SCOPE_KEY]
|
|
9673
|
+
return super(*args, **kwargs, &blk) if frame && frame.open && table == frame.scope.table
|
|
9674
|
+
blk = Parse::Query.block_outside_field_aliases(blk, table) if blk
|
|
9675
|
+
Parse::Query.with_field_aliases(table) { super(*args, **kwargs, &blk) }
|
|
9676
|
+
end
|
|
9677
|
+
end
|
|
9678
|
+
# Keep each wrapped method's original visibility.
|
|
9679
|
+
mod.send(:private, *private_methods) unless private_methods.empty?
|
|
9680
|
+
mod.send(:protected, *protected_methods) unless protected_methods.empty?
|
|
9681
|
+
klass.prepend(mod)
|
|
9682
|
+
end
|
|
9683
|
+
end
|
|
9684
|
+
|
|
9685
|
+
QueryFieldAliasScope.wrap(Query, QueryFieldAliasScope::QUERY_METHODS,
|
|
9686
|
+
->(query) { query.instance_variable_get(:@table) })
|
|
9687
|
+
Query.prepend(QueryFieldAliasScope::DirectMethods)
|
|
9688
|
+
[Aggregation, GroupBy, GroupByDate].each do |helper|
|
|
9689
|
+
QueryFieldAliasScope.wrap(helper, QueryFieldAliasScope::HELPER_METHODS,
|
|
9690
|
+
->(agg) { agg.instance_variable_get(:@query)&.table })
|
|
9691
|
+
end
|
|
9692
|
+
end
|
|
9693
|
+
|