parse-stack-next 5.7.6 → 5.8.0
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 +830 -0
- data/README.md +14 -4
- 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/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +317 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- 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_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 +93 -1
- 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/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -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 +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- 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/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- 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 +21 -3
- 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 +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- 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 +1100 -134
- 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 +72 -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 +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
data/lib/parse/query.rb
CHANGED
|
@@ -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
|
|
@@ -400,28 +571,104 @@ module Parse
|
|
|
400
571
|
end
|
|
401
572
|
|
|
402
573
|
# @!visibility private
|
|
574
|
+
# Reduce a list of constraints into one where hash. Constraints on the
|
|
575
|
+
# same field are combined so every one of them still applies:
|
|
576
|
+
#
|
|
577
|
+
# * Operator hashes with distinct operators merge
|
|
578
|
+
# (`:plays.gt => 1` and `:plays.lt => 9` give `{"$gt" => 1, "$lt" => 9}`).
|
|
579
|
+
# * An equality next to operators becomes `$eq`
|
|
580
|
+
# (`:plays => 5` and `:plays.gt => 1` give `{"$eq" => 5, "$gt" => 1}`).
|
|
581
|
+
# * Anything that cannot merge without changing its meaning (the same
|
|
582
|
+
# operator with a different value, two different equalities, two
|
|
583
|
+
# regular expressions, two `$or` groups) is kept in a top-level `$and`,
|
|
584
|
+
# so both conditions must hold. The first constraint keeps the field
|
|
585
|
+
# key and each later conflicting one is appended to `$and`.
|
|
586
|
+
#
|
|
587
|
+
# Identical constraints collapse to one.
|
|
403
588
|
def constraint_reduce(clauses)
|
|
404
|
-
# @todo Need to add proper constraint merging
|
|
405
589
|
clauses.reduce({}) do |clause, subclause|
|
|
406
|
-
#puts "Merging Subclause: #{subclause.as_json}"
|
|
407
|
-
|
|
408
590
|
subclause_json = subclause.as_json || {}
|
|
591
|
+
subclause_json.each { |key, value| merge_constraint_entry!(clause, key, value) }
|
|
592
|
+
clause
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Operators whose meaning depends on a partner operator in the same
|
|
597
|
+
# hash. Two hashes that both use one of a group are never merged.
|
|
598
|
+
COUPLED_OPERATOR_GROUPS = [
|
|
599
|
+
%w[$regex $options],
|
|
600
|
+
%w[$near $nearSphere $maxDistance $maxDistanceInRadians $maxDistanceInMiles
|
|
601
|
+
$maxDistanceInKilometers $geoWithin $geoIntersects $within],
|
|
602
|
+
%w[$text $search],
|
|
603
|
+
].freeze
|
|
409
604
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
605
|
+
# @!visibility private
|
|
606
|
+
def merge_constraint_entry!(clause, key, value)
|
|
607
|
+
unless clause.key?(key)
|
|
608
|
+
clause[key] = value.is_a?(Array) && key == "__aggregation_pipeline" ? value.dup : value
|
|
609
|
+
return
|
|
610
|
+
end
|
|
611
|
+
existing = clause[key]
|
|
612
|
+
return if existing == value
|
|
613
|
+
|
|
614
|
+
if key == "__aggregation_pipeline" || (key == "$and" && existing.is_a?(Array) && value.is_a?(Array))
|
|
615
|
+
clause[key] = existing + value
|
|
616
|
+
elsif key.is_a?(String) && key.start_with?("__")
|
|
617
|
+
# SDK-internal routing markers keep their previous merge behavior.
|
|
618
|
+
clause[key] = existing.is_a?(Hash) && value.is_a?(Hash) ? existing.deep_merge(value) : value
|
|
619
|
+
elsif operator_hash?(existing) && operator_hash?(value)
|
|
620
|
+
if mergeable_operator_hashes?(existing, value)
|
|
621
|
+
clause[key] = existing.merge(value)
|
|
417
622
|
else
|
|
418
|
-
|
|
623
|
+
append_and_constraint!(clause, key, value)
|
|
419
624
|
end
|
|
420
|
-
|
|
421
|
-
|
|
625
|
+
elsif operator_hash?(existing) && !operator_value_key?(key)
|
|
626
|
+
eq = { "$eq" => value }
|
|
627
|
+
if mergeable_operator_hashes?(existing, eq)
|
|
628
|
+
clause[key] = existing.merge(eq)
|
|
629
|
+
else
|
|
630
|
+
append_and_constraint!(clause, key, value)
|
|
631
|
+
end
|
|
632
|
+
elsif operator_hash?(value) && !operator_value_key?(key)
|
|
633
|
+
eq = { "$eq" => existing }
|
|
634
|
+
if mergeable_operator_hashes?(eq, value)
|
|
635
|
+
clause[key] = eq.merge(value)
|
|
636
|
+
else
|
|
637
|
+
append_and_constraint!(clause, key, value)
|
|
638
|
+
end
|
|
639
|
+
else
|
|
640
|
+
append_and_constraint!(clause, key, value)
|
|
422
641
|
end
|
|
423
642
|
end
|
|
424
643
|
|
|
644
|
+
# @!visibility private
|
|
645
|
+
def append_and_constraint!(clause, key, value)
|
|
646
|
+
list = clause["$and"]
|
|
647
|
+
clause["$and"] = (list.is_a?(Array) ? list : []) + [{ key => value }]
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# @!visibility private
|
|
651
|
+
# A top-level operator key (`$or`, `$relatedTo`) holds a value, not a
|
|
652
|
+
# field constraint.
|
|
653
|
+
def operator_value_key?(key)
|
|
654
|
+
key.to_s.start_with?("$")
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# @!visibility private
|
|
658
|
+
def operator_hash?(value)
|
|
659
|
+
value.is_a?(Hash) && !value.empty? && value.each_key.all? { |k| k.to_s.start_with?("$") }
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# @!visibility private
|
|
663
|
+
def mergeable_operator_hashes?(a, b)
|
|
664
|
+
a_ops = a.transform_keys(&:to_s)
|
|
665
|
+
b_ops = b.transform_keys(&:to_s)
|
|
666
|
+
a_keys = a_ops.keys
|
|
667
|
+
b_keys = b_ops.keys
|
|
668
|
+
return false if (a_keys & b_keys).any? { |op| a_ops[op] != b_ops[op] }
|
|
669
|
+
COUPLED_OPERATOR_GROUPS.none? { |group| (a_keys & group).any? && (b_keys & group).any? }
|
|
670
|
+
end
|
|
671
|
+
|
|
425
672
|
# Applies special singleton methods to a query instance in order to
|
|
426
673
|
# automatically fetch results when using any ruby console.
|
|
427
674
|
# @!visibility private
|
|
@@ -1081,15 +1328,18 @@ module Parse
|
|
|
1081
1328
|
where_clauses = where_clauses.where if where_clauses.is_a?(Parse::Query)
|
|
1082
1329
|
where_clauses = Parse::Query.new(@table, where_clauses).where if where_clauses.is_a?(Hash)
|
|
1083
1330
|
return self if where_clauses.blank?
|
|
1084
|
-
#
|
|
1085
|
-
#
|
|
1086
|
-
|
|
1087
|
-
#
|
|
1088
|
-
|
|
1331
|
+
# Reuse the existing OR only when it is the query's sole constraint.
|
|
1332
|
+
# When other constraints sit beside it, the current where is
|
|
1333
|
+
# `(A or B) and C`, so that whole expression becomes one branch of the
|
|
1334
|
+
# new OR. Appending to the existing OR would silently drop `C`.
|
|
1335
|
+
compound = nil
|
|
1336
|
+
if @where.size == 1 && @where.first.is_a?(Parse::Constraint::CompoundQueryConstraint)
|
|
1337
|
+
compound = @where.first
|
|
1338
|
+
end
|
|
1089
1339
|
# if we don't have a OR clause to reuse, then create a new one with then
|
|
1090
1340
|
# current set of constraints
|
|
1091
1341
|
if compound.blank?
|
|
1092
|
-
initial_constraints = Parse::Query.compile_where(
|
|
1342
|
+
initial_constraints = Parse::Query.compile_where(@where)
|
|
1093
1343
|
# Only include initial constraints if they're not empty
|
|
1094
1344
|
initial_values = initial_constraints.empty? ? [] : [initial_constraints]
|
|
1095
1345
|
compound = Parse::Constraint::CompoundQueryConstraint.new :or, initial_values
|
|
@@ -1260,7 +1510,7 @@ module Parse
|
|
|
1260
1510
|
values = raw_results.map { |item| item["value"] }.compact
|
|
1261
1511
|
|
|
1262
1512
|
# Use schema-based approach to handle pointer field results
|
|
1263
|
-
parse_class =
|
|
1513
|
+
parse_class = table_model_class
|
|
1264
1514
|
is_pointer = parse_class && is_pointer_field?(parse_class, field, formatted_field)
|
|
1265
1515
|
|
|
1266
1516
|
if is_pointer && values.any?
|
|
@@ -1423,9 +1673,13 @@ module Parse
|
|
|
1423
1673
|
{ "$count" => "distinctCount" },
|
|
1424
1674
|
]
|
|
1425
1675
|
|
|
1676
|
+
# `limit(0)` selects no rows.
|
|
1677
|
+
return 0 if @limit == 0
|
|
1678
|
+
|
|
1426
1679
|
# Use the Aggregation class to execute
|
|
1427
|
-
# The aggregate method will automatically handle where conditions
|
|
1428
|
-
|
|
1680
|
+
# The aggregate method will automatically handle where conditions,
|
|
1681
|
+
# and the query's order / skip / limit pick the rows before `$group`.
|
|
1682
|
+
aggregation = aggregate(pipeline, verbose: @verbose_aggregate, window_before_pipeline: true)
|
|
1429
1683
|
raw_results = aggregation.raw
|
|
1430
1684
|
|
|
1431
1685
|
# Extract the count from the response
|
|
@@ -1481,35 +1735,36 @@ module Parse
|
|
|
1481
1735
|
return first_direct(limit_or_constraints)
|
|
1482
1736
|
end
|
|
1483
1737
|
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1738
|
+
# Fetch on a temporary state: the limit, constraints, and cached
|
|
1739
|
+
# results set for this call never stay on the query.
|
|
1740
|
+
with_temporary_query_state do
|
|
1741
|
+
if limit_or_constraints.is_a?(Hash)
|
|
1742
|
+
conditions(limit_or_constraints)
|
|
1743
|
+
# Check if limit was set in constraints, otherwise use 1
|
|
1744
|
+
# Handle :max case - if @limit is :max, default to 1 for first()
|
|
1745
|
+
fetch_count = (@limit.is_a?(Numeric) ? @limit : nil) || 1
|
|
1746
|
+
else
|
|
1747
|
+
fetch_count = case limit_or_constraints
|
|
1748
|
+
when Numeric then limit_or_constraints.to_i
|
|
1749
|
+
when String
|
|
1750
|
+
unless limit_or_constraints =~ /\A-?\d+\z/
|
|
1751
|
+
raise ArgumentError,
|
|
1752
|
+
"Invalid first() argument #{limit_or_constraints.inspect}. " \
|
|
1753
|
+
"Expected an Integer, a numeric String, or a Hash of constraints."
|
|
1754
|
+
end
|
|
1755
|
+
limit_or_constraints.to_i
|
|
1756
|
+
else
|
|
1497
1757
|
raise ArgumentError,
|
|
1498
1758
|
"Invalid first() argument #{limit_or_constraints.inspect}. " \
|
|
1499
1759
|
"Expected an Integer, a numeric String, or a Hash of constraints."
|
|
1500
1760
|
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
|
|
1761
|
+
end
|
|
1507
1762
|
@results = nil if @limit != fetch_count
|
|
1508
1763
|
@limit = fetch_count
|
|
1764
|
+
# Apply any additional keyword options as conditions (e.g., keys:, includes:)
|
|
1765
|
+
conditions(options) unless options.empty?
|
|
1766
|
+
fetch_count == 1 ? results.first : results.first(fetch_count)
|
|
1509
1767
|
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
1768
|
end
|
|
1514
1769
|
|
|
1515
1770
|
# Returns the most recently created object(s) (ordered by created_at descending).
|
|
@@ -1517,21 +1772,16 @@ module Parse
|
|
|
1517
1772
|
# @return [Parse::Object] if limit == 1
|
|
1518
1773
|
# @return [Array<Parse::Object>] if limit > 1
|
|
1519
1774
|
# @note Supports all constraint options like :keys, :includes, :limit, etc.
|
|
1775
|
+
# The query itself is not changed: the order, limit, and constraints
|
|
1776
|
+
# apply to this call only. `createdAt` descending is the primary sort;
|
|
1777
|
+
# an existing order on other fields breaks ties.
|
|
1520
1778
|
# @example
|
|
1521
1779
|
# query.latest # single most recent
|
|
1522
1780
|
# query.latest(5) # 5 most recent
|
|
1523
1781
|
# query.latest(:user.eq => x) # most recent for user
|
|
1524
1782
|
# query.latest(:user.eq => x, limit: 5) # 5 most recent for user
|
|
1525
1783
|
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)
|
|
1784
|
+
newest_by(:created_at, limit, **options)
|
|
1535
1785
|
end
|
|
1536
1786
|
|
|
1537
1787
|
# Returns the most recently updated object(s) (ordered by updated_at descending).
|
|
@@ -1539,21 +1789,53 @@ module Parse
|
|
|
1539
1789
|
# @return [Parse::Object] if limit == 1
|
|
1540
1790
|
# @return [Array<Parse::Object>] if limit > 1
|
|
1541
1791
|
# @note Supports all constraint options like :keys, :includes, :limit, etc.
|
|
1792
|
+
# The query itself is not changed, as with {#latest}.
|
|
1542
1793
|
# @example
|
|
1543
1794
|
# query.last_updated # single most recently updated
|
|
1544
1795
|
# query.last_updated(5) # 5 most recently updated
|
|
1545
1796
|
# query.last_updated(:user.eq => x) # most recently updated for user
|
|
1546
1797
|
# query.last_updated(:user.eq => x, limit: 5) # 5 most recently updated for user
|
|
1547
1798
|
def last_updated(limit = 1, **options)
|
|
1799
|
+
newest_by(:updated_at, limit, **options)
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
# @!visibility private
|
|
1803
|
+
# Shared body of {#latest} and {#last_updated}.
|
|
1804
|
+
def newest_by(field, limit, **options)
|
|
1548
1805
|
# Allow limit to be overridden via options
|
|
1549
1806
|
limit = options.delete(:limit) if options.key?(:limit)
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1807
|
+
with_temporary_query_state do
|
|
1808
|
+
@results = nil
|
|
1809
|
+
@limit = limit
|
|
1810
|
+
column = Query.format_field(field)
|
|
1811
|
+
# Put the timestamp first; keep any other existing order as a
|
|
1812
|
+
# tiebreaker. An existing order on the same column is replaced.
|
|
1813
|
+
existing = @order.reject { |o| Query.format_field(o.field) == column }
|
|
1814
|
+
@order = []
|
|
1815
|
+
order(field.to_sym.desc)
|
|
1816
|
+
@order.concat(existing)
|
|
1817
|
+
# Apply any additional keyword options as conditions (e.g., keys:, includes:)
|
|
1818
|
+
conditions(options) unless options.empty?
|
|
1819
|
+
limit == 1 ? results.first : results.first(limit)
|
|
1820
|
+
end
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
# @!visibility private
|
|
1824
|
+
# Run the block, then restore every instance variable to its value from
|
|
1825
|
+
# before the block (Arrays and Hashes are copied, so in-place appends
|
|
1826
|
+
# are undone too). Used by the fetch helpers that adjust the limit,
|
|
1827
|
+
# order, or constraints for one call.
|
|
1828
|
+
def with_temporary_query_state
|
|
1829
|
+
saved = instance_variables.each_with_object({}) do |ivar, memo|
|
|
1830
|
+
value = instance_variable_get(ivar)
|
|
1831
|
+
memo[ivar] = value.is_a?(Array) || value.is_a?(Hash) ? value.dup : value
|
|
1832
|
+
end
|
|
1833
|
+
begin
|
|
1834
|
+
yield
|
|
1835
|
+
ensure
|
|
1836
|
+
(instance_variables - saved.keys).each { |ivar| remove_instance_variable(ivar) }
|
|
1837
|
+
saved.each { |ivar, value| instance_variable_set(ivar, value) }
|
|
1838
|
+
end
|
|
1557
1839
|
end
|
|
1558
1840
|
|
|
1559
1841
|
# Retrieve a single object by its objectId.
|
|
@@ -1584,8 +1866,10 @@ module Parse
|
|
|
1584
1866
|
batch_size = 100
|
|
1585
1867
|
results = []
|
|
1586
1868
|
# determine if there is a user provided hard limit
|
|
1869
|
+
return [] if @limit == 0
|
|
1587
1870
|
_limit = (@limit.is_a?(Numeric) && @limit > 0) ? @limit : nil
|
|
1588
1871
|
compiled_query[:skip] ||= 0
|
|
1872
|
+
add_paging_tiebreaker!(compiled_query)
|
|
1589
1873
|
|
|
1590
1874
|
loop do
|
|
1591
1875
|
# always reset the batch size
|
|
@@ -1634,6 +1918,23 @@ module Parse
|
|
|
1634
1918
|
results
|
|
1635
1919
|
end
|
|
1636
1920
|
|
|
1921
|
+
# @!visibility private
|
|
1922
|
+
# Skip-based paging is only stable under a total order. When several rows
|
|
1923
|
+
# share the sort value, the server may return them in a different order
|
|
1924
|
+
# on each page request, so rows repeat on one page and are skipped on
|
|
1925
|
+
# another. Ending the order with `objectId` makes every page boundary
|
|
1926
|
+
# deterministic. Left alone when the query already orders by objectId, or
|
|
1927
|
+
# when `$near` / `$text` supply their own relevance order.
|
|
1928
|
+
# @param compiled_query [Hash] a compiled query (mutated).
|
|
1929
|
+
def add_paging_tiebreaker!(compiled_query)
|
|
1930
|
+
where = compiled_query[:where].to_s
|
|
1931
|
+
return if where.include?("$near") || where.include?("$text")
|
|
1932
|
+
order = compiled_query[:order].to_s
|
|
1933
|
+
fields = order.split(",").map { |f| f.strip.delete_prefix("-") }
|
|
1934
|
+
return if fields.include?("objectId") || fields.any? { |f| f.start_with?("$") }
|
|
1935
|
+
compiled_query[:order] = order.empty? ? "objectId" : "#{order},objectId"
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1637
1938
|
# @!visibility private
|
|
1638
1939
|
def _opts
|
|
1639
1940
|
opts = {}
|
|
@@ -1814,6 +2115,9 @@ module Parse
|
|
|
1814
2115
|
# @param mongo_direct [Boolean] if true, queries MongoDB directly bypassing Parse Server.
|
|
1815
2116
|
# Requires Parse::MongoDB to be configured. Default: false.
|
|
1816
2117
|
def results(raw: false, return_pointers: false, mongo_direct: false, &block)
|
|
2118
|
+
# `limit(0)` asks for no rows: answer without a request. Sending no
|
|
2119
|
+
# limit would return the server default page of 100.
|
|
2120
|
+
return [] if @limit == 0
|
|
1817
2121
|
# Use direct MongoDB query if requested
|
|
1818
2122
|
if mongo_direct
|
|
1819
2123
|
return results_direct(raw: raw, **mongo_direct_auth_kwargs, &block)
|
|
@@ -1921,6 +2225,9 @@ module Parse
|
|
|
1921
2225
|
unless use_master_key == true
|
|
1922
2226
|
ambient = ambient_session_token
|
|
1923
2227
|
return true if ambient.is_a?(String) && !ambient.empty?
|
|
2228
|
+
# A client bound to a user's session (`become`, `session_client`,
|
|
2229
|
+
# a webhook `user_client`) is scoped the same way.
|
|
2230
|
+
return true if client_bound_session_token
|
|
1924
2231
|
end
|
|
1925
2232
|
false
|
|
1926
2233
|
end
|
|
@@ -2083,7 +2390,7 @@ module Parse
|
|
|
2083
2390
|
# query would raise instead of running scoped — and on a master
|
|
2084
2391
|
# client the ambient is what `mongo_direct_auth_kwargs` forwards so
|
|
2085
2392
|
# the read is scoped rather than silently master.
|
|
2086
|
-
has_ambient_session = !ambient_session_token.nil?
|
|
2393
|
+
has_ambient_session = !ambient_session_token.nil? || !client_bound_session_token.nil?
|
|
2087
2394
|
# Mirror the request-layer auth resolution in Parse::Client#request:
|
|
2088
2395
|
# when the process is in "server mode" — Parse.client_mode == false
|
|
2089
2396
|
# AND the resolved Parse::Client has a master_key — and the caller
|
|
@@ -2099,7 +2406,11 @@ module Parse
|
|
|
2099
2406
|
false
|
|
2100
2407
|
end
|
|
2101
2408
|
server_mode_master = (use_master_key != false) && !Parse.client_mode && client_has_master_key
|
|
2102
|
-
|
|
2409
|
+
# Inside `Parse.without_master_key` REST sends no master key, so
|
|
2410
|
+
# neither the explicit opt-in nor the server-mode default authorizes
|
|
2411
|
+
# a direct read there.
|
|
2412
|
+
master_authorized = (use_master_key || server_mode_master) && !master_key_suppressed?
|
|
2413
|
+
unless master_authorized || @acl_user || @acl_role || has_session || has_ambient_session
|
|
2103
2414
|
raise MongoDirectRequired,
|
|
2104
2415
|
"[Parse::Query] This query uses a constraint that can only run " \
|
|
2105
2416
|
"via mongo-direct. Mongo-direct bypasses Parse Server's enforcement, " \
|
|
@@ -2189,11 +2500,81 @@ module Parse
|
|
|
2189
2500
|
# deliberate admin call and skips the ambient, exactly as the REST
|
|
2190
2501
|
# path does.
|
|
2191
2502
|
{ session_token: ambient }
|
|
2192
|
-
|
|
2503
|
+
elsif use_master_key != true && anonymous_session_block?
|
|
2504
|
+
# Inside `Parse.with_session(nil)`: REST sends neither a token nor
|
|
2505
|
+
# the master key, so the direct read runs in the public scope too.
|
|
2506
|
+
{}
|
|
2507
|
+
elsif use_master_key != true && (bound = client_bound_session_token)
|
|
2508
|
+
# The query's client carries its own session token (a client from
|
|
2509
|
+
# `Parse::Client#become`, `Parse::User#session_client`, or a webhook
|
|
2510
|
+
# payload's `user_client`). REST sends that token on every request
|
|
2511
|
+
# from the client, so the direct read is scoped to the same user.
|
|
2512
|
+
{ session_token: bound }
|
|
2513
|
+
elsif mongo_direct_master_posture?
|
|
2193
2514
|
{ master: true }
|
|
2515
|
+
else
|
|
2516
|
+
# A client with no master key, `Parse.client_mode`, or an explicit
|
|
2517
|
+
# `use_master_key = false`, and no session anywhere: REST would run
|
|
2518
|
+
# this read anonymously. Return no auth so Parse::ACLScope resolves
|
|
2519
|
+
# the public scope (or raises ACLRequired when
|
|
2520
|
+
# `require_session_token` is on) instead of reading as master.
|
|
2521
|
+
{}
|
|
2194
2522
|
end
|
|
2195
2523
|
end
|
|
2196
2524
|
|
|
2525
|
+
# The session token bound to this query's client, or nil.
|
|
2526
|
+
# @return [String, nil]
|
|
2527
|
+
# @!visibility private
|
|
2528
|
+
def client_bound_session_token
|
|
2529
|
+
c = begin
|
|
2530
|
+
client
|
|
2531
|
+
rescue StandardError
|
|
2532
|
+
nil
|
|
2533
|
+
end
|
|
2534
|
+
return nil unless c.respond_to?(:session_token)
|
|
2535
|
+
token = c.session_token
|
|
2536
|
+
token = token.session_token if token.respond_to?(:session_token)
|
|
2537
|
+
token.is_a?(String) && !token.strip.empty? ? token : nil
|
|
2538
|
+
end
|
|
2539
|
+
|
|
2540
|
+
# Whether REST would send the master key for this query: the client
|
|
2541
|
+
# holds one and the caller either asked for it explicitly or left the
|
|
2542
|
+
# choice to the server-mode default (`Parse.client_mode` off and no
|
|
2543
|
+
# `use_master_key = false`). Mirrors Parse::Client#request.
|
|
2544
|
+
# @return [Boolean]
|
|
2545
|
+
# @!visibility private
|
|
2546
|
+
def mongo_direct_master_posture?
|
|
2547
|
+
# `Parse.without_master_key` strips the master key from every REST
|
|
2548
|
+
# request in the block, an explicit `use_master_key: true` included.
|
|
2549
|
+
return false if master_key_suppressed?
|
|
2550
|
+
c = begin
|
|
2551
|
+
client
|
|
2552
|
+
rescue StandardError
|
|
2553
|
+
nil
|
|
2554
|
+
end
|
|
2555
|
+
has_key = c.respond_to?(:master_key) && !c.master_key.to_s.empty?
|
|
2556
|
+
return false unless has_key
|
|
2557
|
+
return true if use_master_key == true
|
|
2558
|
+
use_master_key != false && !Parse.client_mode
|
|
2559
|
+
end
|
|
2560
|
+
|
|
2561
|
+
# @return [Boolean] true inside a `Parse.without_master_key` block (and
|
|
2562
|
+
# not re-enabled by a nested `Parse.with_master_key`), where REST sends
|
|
2563
|
+
# no master key on any request.
|
|
2564
|
+
# @!visibility private
|
|
2565
|
+
def master_key_suppressed?
|
|
2566
|
+
Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
2567
|
+
end
|
|
2568
|
+
|
|
2569
|
+
# An explicit `master: true` passed to a direct terminal, dropped inside
|
|
2570
|
+
# a `Parse.without_master_key` block. REST strips the master key there
|
|
2571
|
+
# even when a call asks for it, so the direct read falls back to the
|
|
2572
|
+
# public scope as REST would.
|
|
2573
|
+
# @!visibility private
|
|
2574
|
+
def direct_master_kwarg(master)
|
|
2575
|
+
master == true && master_key_suppressed? ? nil : master
|
|
2576
|
+
end
|
|
2577
|
+
|
|
2197
2578
|
# Auth kwargs for the Atlas Search bridge (`#atlas_search` builder
|
|
2198
2579
|
# block). Explicit `atlas_search(...)` auth kwargs win; otherwise
|
|
2199
2580
|
# derive from the query's own scope (`#scope_to_user`, an explicit
|
|
@@ -2225,7 +2606,11 @@ module Parse
|
|
|
2225
2606
|
end
|
|
2226
2607
|
|
|
2227
2608
|
explicit = %i[session_token master acl_user acl_role].select { |k| options.key?(k) }
|
|
2228
|
-
|
|
2609
|
+
if explicit.any?
|
|
2610
|
+
given = explicit.to_h { |k| [k, options[k]] }
|
|
2611
|
+
given.delete(:master) if direct_master_kwarg(given[:master]).nil?
|
|
2612
|
+
return client_kwarg.merge(given)
|
|
2613
|
+
end
|
|
2229
2614
|
|
|
2230
2615
|
client_kwarg.merge(atlas_search_scope_kwargs)
|
|
2231
2616
|
end
|
|
@@ -2239,14 +2624,28 @@ module Parse
|
|
|
2239
2624
|
elsif @session_token.is_a?(String) && !@session_token.empty?
|
|
2240
2625
|
{ session_token: @session_token }
|
|
2241
2626
|
elsif use_master_key == true
|
|
2242
|
-
|
|
2627
|
+
# An explicit master request skips the ambient session, as on REST.
|
|
2628
|
+
# Inside `Parse.without_master_key` the key is stripped, so the
|
|
2629
|
+
# search runs in the public scope.
|
|
2630
|
+
master_key_suppressed? ? {} : { master: true }
|
|
2243
2631
|
elsif (ambient = ambient_session_token)
|
|
2244
2632
|
{ session_token: ambient }
|
|
2633
|
+
elsif anonymous_session_block?
|
|
2634
|
+
{}
|
|
2635
|
+
elsif (bound = client_bound_session_token)
|
|
2636
|
+
{ session_token: bound }
|
|
2245
2637
|
else
|
|
2246
2638
|
{}
|
|
2247
2639
|
end
|
|
2248
2640
|
end
|
|
2249
2641
|
|
|
2642
|
+
# @return [Boolean] true inside an anonymous `Parse.with_session(nil)`
|
|
2643
|
+
# block, where requests carry neither a session token nor the master key.
|
|
2644
|
+
# @!visibility private
|
|
2645
|
+
def anonymous_session_block?
|
|
2646
|
+
Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
|
|
2647
|
+
end
|
|
2648
|
+
|
|
2250
2649
|
# The fiber-local ambient session token set by `Parse.with_session`,
|
|
2251
2650
|
# or nil. A whitespace-only ambient is treated as absent so it cannot
|
|
2252
2651
|
# block the master fallback and then fail a later presence check —
|
|
@@ -2259,6 +2658,18 @@ module Parse
|
|
|
2259
2658
|
ambient if ambient.is_a?(String) && !ambient.strip.empty?
|
|
2260
2659
|
end
|
|
2261
2660
|
|
|
2661
|
+
# Like the default `inspect`, but never prints the session token, which
|
|
2662
|
+
# would otherwise reach logs and error reports.
|
|
2663
|
+
# @return [String]
|
|
2664
|
+
def inspect
|
|
2665
|
+
ivars = instance_variables.map do |ivar|
|
|
2666
|
+
value = instance_variable_get(ivar)
|
|
2667
|
+
shown = ivar == :@session_token && value ? "[FILTERED]" : value.inspect
|
|
2668
|
+
"#{ivar}=#{shown}"
|
|
2669
|
+
end
|
|
2670
|
+
"#<#{self.class.name} #{ivars.join(", ")}>"
|
|
2671
|
+
end
|
|
2672
|
+
|
|
2262
2673
|
# Check if this query contains constraints that require aggregation pipeline processing
|
|
2263
2674
|
# @return [Boolean] true if aggregation pipeline is required
|
|
2264
2675
|
def requires_aggregation_pipeline?
|
|
@@ -2318,6 +2729,9 @@ module Parse
|
|
|
2318
2729
|
# @note This is a read-only operation. Direct MongoDB queries cannot modify data.
|
|
2319
2730
|
# @see Parse::MongoDB.configure
|
|
2320
2731
|
def results_direct(raw: false, max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil, &block)
|
|
2732
|
+
# `limit(0)` asks for no rows. MongoDB rejects `$limit: 0`, and
|
|
2733
|
+
# omitting the stage would return every row.
|
|
2734
|
+
return [] if @limit == 0
|
|
2321
2735
|
require_relative "mongodb"
|
|
2322
2736
|
Parse::MongoDB.require_gem!
|
|
2323
2737
|
|
|
@@ -2356,6 +2770,7 @@ module Parse
|
|
|
2356
2770
|
acl_user = auth[:acl_user]
|
|
2357
2771
|
acl_role = auth[:acl_role]
|
|
2358
2772
|
end
|
|
2773
|
+
master = direct_master_kwarg(master)
|
|
2359
2774
|
|
|
2360
2775
|
# Execute the aggregation directly on MongoDB. The pipeline was built
|
|
2361
2776
|
# entirely from SDK constraint translation (no user-supplied stages),
|
|
@@ -2501,6 +2916,7 @@ module Parse
|
|
|
2501
2916
|
acl_user = auth[:acl_user]
|
|
2502
2917
|
acl_role = auth[:acl_role]
|
|
2503
2918
|
end
|
|
2919
|
+
master = direct_master_kwarg(master)
|
|
2504
2920
|
|
|
2505
2921
|
# SDK-built pipeline only — see results_direct for rationale.
|
|
2506
2922
|
# ACL simulation runs inside Parse::MongoDB.aggregate when
|
|
@@ -2601,6 +3017,7 @@ module Parse
|
|
|
2601
3017
|
acl_user = auth[:acl_user]
|
|
2602
3018
|
acl_role = auth[:acl_role]
|
|
2603
3019
|
end
|
|
3020
|
+
master = direct_master_kwarg(master)
|
|
2604
3021
|
raw_results = Parse::MongoDB.aggregate(@table, pipeline,
|
|
2605
3022
|
allow_internal_fields: true,
|
|
2606
3023
|
read_preference: @read_preference,
|
|
@@ -2944,9 +3361,13 @@ module Parse
|
|
|
2944
3361
|
if compiled_where.present?
|
|
2945
3362
|
# Convert field names and values for direct MongoDB access.
|
|
2946
3363
|
# `compiled_where` is already marker-free, so no further
|
|
2947
|
-
# reject pass is required.
|
|
2948
|
-
|
|
3364
|
+
# reject pass is required. Subquery constraints (`$inQuery`,
|
|
3365
|
+
# `$notInQuery`, `$select`, `$dontSelect`) have no MongoDB
|
|
3366
|
+
# equivalent and are compiled into `$lookup` joins plus a
|
|
3367
|
+
# post-join `$match`; see #direct_subquery_stages.
|
|
3368
|
+
mongo_constraints, subquery_stages = direct_subquery_stages(compiled_where)
|
|
2949
3369
|
pipeline << { "$match" => mongo_constraints } if mongo_constraints.any?
|
|
3370
|
+
pipeline.concat(subquery_stages)
|
|
2950
3371
|
end
|
|
2951
3372
|
|
|
2952
3373
|
# Handle aggregation pipeline stages (from empty_or_nil, set_equals, etc.)
|
|
@@ -3001,9 +3422,23 @@ module Parse
|
|
|
3001
3422
|
"_acl" => 1,
|
|
3002
3423
|
}
|
|
3003
3424
|
@keys.each do |key|
|
|
3004
|
-
|
|
3425
|
+
# A dotted key (`meta.k`, `owner.name`) selects its top-level
|
|
3426
|
+
# column on REST: Parse Server keeps the whole `meta` object, or
|
|
3427
|
+
# the whole `owner` pointer. Projecting the dotted path would
|
|
3428
|
+
# return a partial sub-document REST never produces.
|
|
3429
|
+
top = key.to_s.split(".", 2).first
|
|
3430
|
+
next if top.nil? || top.empty?
|
|
3431
|
+
mongo_field = convert_field_for_direct_mongodb(top)
|
|
3005
3432
|
project_stage[mongo_field] = 1
|
|
3006
3433
|
end
|
|
3434
|
+
# Keep each include's `$lookup` output. Without it the joined
|
|
3435
|
+
# document was projected away and the field decoded as a bare
|
|
3436
|
+
# pointer even though the caller asked for it to be included.
|
|
3437
|
+
@includes.each do |inc|
|
|
3438
|
+
base = inc.to_s.split(".", 2).first
|
|
3439
|
+
next if base.nil? || base.empty?
|
|
3440
|
+
project_stage["_included_#{base}"] = 1 if get_pointer_target_class(base.to_sym)
|
|
3441
|
+
end
|
|
3007
3442
|
pipeline << { "$project" => project_stage }
|
|
3008
3443
|
end
|
|
3009
3444
|
|
|
@@ -3058,11 +3493,16 @@ module Parse
|
|
|
3058
3493
|
},
|
|
3059
3494
|
}
|
|
3060
3495
|
|
|
3061
|
-
# Stage 3:
|
|
3496
|
+
# Stage 3: Collapse the `$lookup` array to its single document.
|
|
3497
|
+
# An include that resolves to nothing (dangling pointer, or a row the
|
|
3498
|
+
# scope cannot read, which the ACL rewriter filters out of the
|
|
3499
|
+
# join) becomes an explicit null, so the row converter can drop the
|
|
3500
|
+
# field the way REST does instead of leaving a bare pointer.
|
|
3062
3501
|
stages << {
|
|
3063
|
-
"$
|
|
3064
|
-
|
|
3065
|
-
|
|
3502
|
+
"$addFields" => {
|
|
3503
|
+
lookup_result_field => {
|
|
3504
|
+
"$ifNull" => [{ "$arrayElemAt" => ["$#{lookup_result_field}", 0] }, nil],
|
|
3505
|
+
},
|
|
3066
3506
|
},
|
|
3067
3507
|
}
|
|
3068
3508
|
|
|
@@ -3107,6 +3547,244 @@ module Parse
|
|
|
3107
3547
|
end
|
|
3108
3548
|
end
|
|
3109
3549
|
|
|
3550
|
+
# Subquery operators the mongo-direct path compiles into `$lookup`
|
|
3551
|
+
# joins. MongoDB has no equivalent; passed through verbatim they fail
|
|
3552
|
+
# with "unknown operator".
|
|
3553
|
+
DIRECT_SUBQUERY_OPERATORS = %w[$inQuery $notInQuery $select $dontSelect].freeze
|
|
3554
|
+
|
|
3555
|
+
# Logical operators whose clauses may hold subquery constraints.
|
|
3556
|
+
DIRECT_LOGICAL_OPERATORS = %w[$and $or $nor].freeze
|
|
3557
|
+
|
|
3558
|
+
# Split compiled constraints into a MongoDB `$match` and the extra
|
|
3559
|
+
# stages that implement subquery operators.
|
|
3560
|
+
#
|
|
3561
|
+
# * `$inQuery` / `$notInQuery`: `$lookup` the pointed-to row in the
|
|
3562
|
+
# subquery's class, filtered by the subquery's `where`, then keep rows
|
|
3563
|
+
# whose join is non-empty (or empty).
|
|
3564
|
+
# * `$select` / `$dontSelect`: `$lookup` rows of the subquery's class
|
|
3565
|
+
# matching its `where` whose `key` equals this row's field, then keep
|
|
3566
|
+
# rows whose join is non-empty (or empty).
|
|
3567
|
+
#
|
|
3568
|
+
# A subquery inside `$and` / `$or` / `$nor` (at any depth, as
|
|
3569
|
+
# `or_where` produces) gets its own `$lookup` too. The logical clause
|
|
3570
|
+
# holding it moves to the post-join `$match`, with the subquery replaced
|
|
3571
|
+
# by a test on its join result, so `$or` keeps its meaning. Constraints
|
|
3572
|
+
# with no subquery stay in the first `$match`, ahead of the joins. A
|
|
3573
|
+
# subquery in any other position (under `$not`, `$elemMatch`, `$expr`)
|
|
3574
|
+
# cannot be translated and raises ArgumentError rather than reaching
|
|
3575
|
+
# MongoDB as an unknown operator.
|
|
3576
|
+
#
|
|
3577
|
+
# The joins run through Parse::MongoDB.aggregate, so the ACL rewriter
|
|
3578
|
+
# filters the joined rows by `_rperm`, the joined class's CLP is
|
|
3579
|
+
# checked, and a `where` on the joined class's protectedFields is
|
|
3580
|
+
# refused, as Parse Server does for its subqueries. Temporary join
|
|
3581
|
+
# columns are removed with `$unset`.
|
|
3582
|
+
#
|
|
3583
|
+
# @param constraints [Hash] compiled where constraints.
|
|
3584
|
+
# @return [Array(Hash, Array<Hash>)] the `$match` body and the stages.
|
|
3585
|
+
# @raise [ArgumentError] when a subquery sits where it cannot be translated.
|
|
3586
|
+
# @api private
|
|
3587
|
+
def direct_subquery_stages(constraints)
|
|
3588
|
+
unless direct_subquery_present?(constraints)
|
|
3589
|
+
return [convert_constraints_for_direct_mongodb(constraints), []]
|
|
3590
|
+
end
|
|
3591
|
+
|
|
3592
|
+
plain = {}
|
|
3593
|
+
post = {}
|
|
3594
|
+
lookups = []
|
|
3595
|
+
temps = []
|
|
3596
|
+
constraints.each do |field, value|
|
|
3597
|
+
direct_translate_subquery_pair(field, value, plain, post, lookups, temps)
|
|
3598
|
+
end
|
|
3599
|
+
match = convert_constraints_for_direct_mongodb(plain)
|
|
3600
|
+
refuse_untranslated_subquery!(match)
|
|
3601
|
+
refuse_untranslated_subquery!(post)
|
|
3602
|
+
|
|
3603
|
+
stages = lookups
|
|
3604
|
+
stages << { "$match" => post } if post.any?
|
|
3605
|
+
stages << { "$unset" => temps } if temps.any?
|
|
3606
|
+
[match, stages]
|
|
3607
|
+
end
|
|
3608
|
+
|
|
3609
|
+
# Translate one `field => value` constraint. A constraint with no
|
|
3610
|
+
# subquery is collected into `plain` unchanged (converted later by the
|
|
3611
|
+
# caller). A subquery operator adds a `$lookup` to `lookups` and a test
|
|
3612
|
+
# on its join result to `out`. A logical operator holding a subquery is
|
|
3613
|
+
# rebuilt clause by clause into `out`.
|
|
3614
|
+
# @api private
|
|
3615
|
+
def direct_translate_subquery_pair(field, value, plain, out, lookups, temps)
|
|
3616
|
+
key = field.to_s
|
|
3617
|
+
if DIRECT_LOGICAL_OPERATORS.include?(key) && value.is_a?(Array)
|
|
3618
|
+
if direct_subquery_present?(value)
|
|
3619
|
+
direct_merge_clause!(out, key, value.map { |clause| direct_translate_subquery_clause(clause, lookups, temps) })
|
|
3620
|
+
else
|
|
3621
|
+
plain[field] = value
|
|
3622
|
+
end
|
|
3623
|
+
return
|
|
3624
|
+
end
|
|
3625
|
+
|
|
3626
|
+
ops = value.is_a?(Hash) ? value.keys.map(&:to_s) & DIRECT_SUBQUERY_OPERATORS : []
|
|
3627
|
+
if ops.empty?
|
|
3628
|
+
plain[field] = value
|
|
3629
|
+
return
|
|
3630
|
+
end
|
|
3631
|
+
others = value.reject { |k, _| DIRECT_SUBQUERY_OPERATORS.include?(k.to_s) }
|
|
3632
|
+
plain[field] = others if others.any?
|
|
3633
|
+
ops.each do |op|
|
|
3634
|
+
spec = value.key?(op) ? value[op] : value[op.to_sym]
|
|
3635
|
+
temp = "_subquery_#{temps.size}_#{key.gsub(/[^A-Za-z0-9_]/, "_")}"
|
|
3636
|
+
temps << temp
|
|
3637
|
+
lookups << direct_subquery_lookup(key, op, spec, temp)
|
|
3638
|
+
out[temp] = %w[$inQuery $select].include?(op) ? { "$ne" => [] } : { "$eq" => [] }
|
|
3639
|
+
end
|
|
3640
|
+
end
|
|
3641
|
+
|
|
3642
|
+
# Translate one clause of a logical operator into a MongoDB filter
|
|
3643
|
+
# that reads the join results.
|
|
3644
|
+
# @api private
|
|
3645
|
+
def direct_translate_subquery_clause(clause, lookups, temps)
|
|
3646
|
+
unless clause.is_a?(Hash)
|
|
3647
|
+
raise ArgumentError,
|
|
3648
|
+
"[Parse::Query] a logical operator clause holding a subquery must be a Hash, got #{clause.class}."
|
|
3649
|
+
end
|
|
3650
|
+
plain = {}
|
|
3651
|
+
out = {}
|
|
3652
|
+
clause.each do |field, value|
|
|
3653
|
+
direct_translate_subquery_pair(field, value, plain, out, lookups, temps)
|
|
3654
|
+
end
|
|
3655
|
+
converted = convert_constraints_for_direct_mongodb(plain)
|
|
3656
|
+
return converted.merge(out) if (converted.keys & out.keys).empty?
|
|
3657
|
+
# The same operator appeared twice (a String and a Symbol key).
|
|
3658
|
+
# Keep both by matching them together rather than letting one
|
|
3659
|
+
# overwrite the other.
|
|
3660
|
+
{ "$and" => [converted, out] }
|
|
3661
|
+
end
|
|
3662
|
+
|
|
3663
|
+
# Add a translated logical clause to `out` without overwriting one that
|
|
3664
|
+
# is already there under the same operator.
|
|
3665
|
+
# @api private
|
|
3666
|
+
def direct_merge_clause!(out, key, clauses)
|
|
3667
|
+
if out.key?(key)
|
|
3668
|
+
existing = out.delete(key)
|
|
3669
|
+
out["$and"] = Array(out.delete("$and")) + [{ key => existing }, { key => clauses }]
|
|
3670
|
+
else
|
|
3671
|
+
out[key] = clauses
|
|
3672
|
+
end
|
|
3673
|
+
end
|
|
3674
|
+
|
|
3675
|
+
# Fail closed when a subquery operator is still present after
|
|
3676
|
+
# translation. MongoDB has no such operator, and a subquery in a
|
|
3677
|
+
# position the SDK cannot join (under `$not`, `$elemMatch`, `$expr`)
|
|
3678
|
+
# must not run as some other filter.
|
|
3679
|
+
# @raise [ArgumentError]
|
|
3680
|
+
# @api private
|
|
3681
|
+
def refuse_untranslated_subquery!(node, context: :direct)
|
|
3682
|
+
case node
|
|
3683
|
+
when Hash
|
|
3684
|
+
node.each do |key, value|
|
|
3685
|
+
if DIRECT_SUBQUERY_OPERATORS.include?(key.to_s)
|
|
3686
|
+
if context == :aggregate
|
|
3687
|
+
raise ArgumentError,
|
|
3688
|
+
"[Parse::Query] #{key} cannot be translated into an aggregation pipeline in " \
|
|
3689
|
+
"this position. Aggregations translate only top-level $inQuery / $notInQuery " \
|
|
3690
|
+
"field constraints into joins. Use results_direct / count_direct, which also " \
|
|
3691
|
+
"translate subqueries inside $and / $or / $nor, or run the query via REST."
|
|
3692
|
+
end
|
|
3693
|
+
raise ArgumentError,
|
|
3694
|
+
"[Parse::Query] #{key} cannot run on the mongo-direct path in this position. " \
|
|
3695
|
+
"A subquery is translated into a join only as a field constraint, at the top " \
|
|
3696
|
+
"level or inside $and / $or / $nor. Run this query via REST, or move the " \
|
|
3697
|
+
"subquery out of the enclosing operator."
|
|
3698
|
+
end
|
|
3699
|
+
refuse_untranslated_subquery!(value, context: context)
|
|
3700
|
+
end
|
|
3701
|
+
when Array
|
|
3702
|
+
node.each { |child| refuse_untranslated_subquery!(child, context: context) }
|
|
3703
|
+
end
|
|
3704
|
+
nil
|
|
3705
|
+
end
|
|
3706
|
+
|
|
3707
|
+
# @return [Boolean] true when a subquery operator appears anywhere in
|
|
3708
|
+
# the constraints, at any depth.
|
|
3709
|
+
# @api private
|
|
3710
|
+
def direct_subquery_present?(constraints)
|
|
3711
|
+
case constraints
|
|
3712
|
+
when Hash
|
|
3713
|
+
constraints.any? do |key, value|
|
|
3714
|
+
DIRECT_SUBQUERY_OPERATORS.include?(key.to_s) || direct_subquery_present?(value)
|
|
3715
|
+
end
|
|
3716
|
+
when Array
|
|
3717
|
+
constraints.any? { |child| direct_subquery_present?(child) }
|
|
3718
|
+
else
|
|
3719
|
+
false
|
|
3720
|
+
end
|
|
3721
|
+
end
|
|
3722
|
+
|
|
3723
|
+
# Build the `$lookup` stage for one subquery operator.
|
|
3724
|
+
# @api private
|
|
3725
|
+
def direct_subquery_lookup(field, op, spec, temp)
|
|
3726
|
+
spec = (spec || {}).transform_keys(&:to_s)
|
|
3727
|
+
if op == "$select" || op == "$dontSelect"
|
|
3728
|
+
query = (spec["query"] || {})
|
|
3729
|
+
query = query.transform_keys(&:to_s) if query.is_a?(Hash)
|
|
3730
|
+
class_name = query["className"].to_s
|
|
3731
|
+
where = query["where"] || {}
|
|
3732
|
+
key = (spec["key"] || field).to_s
|
|
3733
|
+
else
|
|
3734
|
+
class_name = spec["className"].to_s
|
|
3735
|
+
where = spec["where"] || {}
|
|
3736
|
+
key = nil
|
|
3737
|
+
end
|
|
3738
|
+
if class_name.empty?
|
|
3739
|
+
raise ArgumentError, "[Parse::Query] #{op} on '#{field}' is missing the subquery className."
|
|
3740
|
+
end
|
|
3741
|
+
|
|
3742
|
+
sub = Parse::Query.new(class_name)
|
|
3743
|
+
sub_match, sub_stages = Parse::Query.with_field_aliases(class_name) do
|
|
3744
|
+
sub.send(:direct_subquery_stages, where.is_a?(Hash) ? where : {})
|
|
3745
|
+
end
|
|
3746
|
+
local = convert_field_for_direct_mongodb(field)
|
|
3747
|
+
|
|
3748
|
+
if key.nil?
|
|
3749
|
+
# `$inQuery` / `$notInQuery` compare a pointer column, stored as
|
|
3750
|
+
# `Class$objectId`, with the subquery row's `_id`.
|
|
3751
|
+
local = "_p_#{Query.format_field(field)}" unless local.start_with?("_p_")
|
|
3752
|
+
pipeline = [
|
|
3753
|
+
{ "$match" => { "$expr" => { "$eq" => ["$_id", "$$subquery_id"] } } },
|
|
3754
|
+
]
|
|
3755
|
+
pipeline << { "$match" => sub_match } if sub_match.any?
|
|
3756
|
+
pipeline.concat(sub_stages)
|
|
3757
|
+
pipeline << { "$limit" => 1 }
|
|
3758
|
+
{
|
|
3759
|
+
"$lookup" => {
|
|
3760
|
+
"from" => class_name,
|
|
3761
|
+
"let" => {
|
|
3762
|
+
"subquery_id" => {
|
|
3763
|
+
"$arrayElemAt" => [{ "$split" => ["$#{local}", { "$literal" => "$" }] }, 1],
|
|
3764
|
+
},
|
|
3765
|
+
},
|
|
3766
|
+
"pipeline" => pipeline,
|
|
3767
|
+
"as" => temp,
|
|
3768
|
+
},
|
|
3769
|
+
}
|
|
3770
|
+
else
|
|
3771
|
+
foreign = Parse::Query.with_field_aliases(class_name) { sub.send(:convert_field_for_direct_mongodb, key) }
|
|
3772
|
+
pipeline = []
|
|
3773
|
+
pipeline << { "$match" => sub_match } if sub_match.any?
|
|
3774
|
+
pipeline.concat(sub_stages)
|
|
3775
|
+
pipeline << { "$match" => { "$expr" => { "$eq" => ["$#{foreign}", "$$subquery_value"] } } }
|
|
3776
|
+
pipeline << { "$limit" => 1 }
|
|
3777
|
+
{
|
|
3778
|
+
"$lookup" => {
|
|
3779
|
+
"from" => class_name,
|
|
3780
|
+
"let" => { "subquery_value" => "$#{local}" },
|
|
3781
|
+
"pipeline" => pipeline,
|
|
3782
|
+
"as" => temp,
|
|
3783
|
+
},
|
|
3784
|
+
}
|
|
3785
|
+
end
|
|
3786
|
+
end
|
|
3787
|
+
|
|
3110
3788
|
# Convert constraints for direct MongoDB execution.
|
|
3111
3789
|
# @param constraints [Hash] the compiled where constraints
|
|
3112
3790
|
# @return [Hash] constraints with MongoDB-native field names
|
|
@@ -3151,6 +3829,12 @@ module Parse
|
|
|
3151
3829
|
|
|
3152
3830
|
# Convert field name for MongoDB
|
|
3153
3831
|
mongo_field = convert_field_for_direct_mongodb(field_str)
|
|
3832
|
+
# Bare objectIds against a pointer column use the storage form.
|
|
3833
|
+
value = coerce_bare_pointer_ids(field_str, value, arrays: true)
|
|
3834
|
+
# `$containedBy` (every array element is in the list) has no MongoDB
|
|
3835
|
+
# operator. Parse Server's equivalent is "no element outside the
|
|
3836
|
+
# list".
|
|
3837
|
+
value = rewrite_contained_by_for_direct(value)
|
|
3154
3838
|
|
|
3155
3839
|
# Convert value
|
|
3156
3840
|
result[mongo_field] = convert_value_for_direct_mongodb(field_str, value)
|
|
@@ -3159,6 +3843,16 @@ module Parse
|
|
|
3159
3843
|
result
|
|
3160
3844
|
end
|
|
3161
3845
|
|
|
3846
|
+
# @api private
|
|
3847
|
+
def rewrite_contained_by_for_direct(value)
|
|
3848
|
+
return value unless value.is_a?(Hash)
|
|
3849
|
+
key = value.key?("$containedBy") ? "$containedBy" : (value.key?(:$containedBy) ? :$containedBy : nil)
|
|
3850
|
+
return value if key.nil?
|
|
3851
|
+
list = value[key]
|
|
3852
|
+
rest = value.reject { |k, _| k == key }
|
|
3853
|
+
rest.merge("$not" => { "$elemMatch" => { "$nin" => Array(list) } })
|
|
3854
|
+
end
|
|
3855
|
+
|
|
3162
3856
|
# Convert a field name for direct MongoDB access.
|
|
3163
3857
|
# @param field [String] the Parse field name
|
|
3164
3858
|
# @return [String] the MongoDB field name
|
|
@@ -3667,7 +4361,8 @@ module Parse
|
|
|
3667
4361
|
# at the top-level stage.
|
|
3668
4362
|
BLOCKED_PIPELINE_STAGES = Parse::PipelineSecurity::DENIED_OPERATORS
|
|
3669
4363
|
|
|
3670
|
-
def aggregate(pipeline, verbose: nil, mongo_direct: nil, rewrite_lookups: nil, raw_values: false, raw_field_names: false
|
|
4364
|
+
def aggregate(pipeline, verbose: nil, mongo_direct: nil, rewrite_lookups: nil, raw_values: false, raw_field_names: false,
|
|
4365
|
+
window_before_pipeline: false)
|
|
3671
4366
|
validate_pipeline!(pipeline)
|
|
3672
4367
|
|
|
3673
4368
|
# Auto-rewrite LLM-style $lookup stages against logical Parse class
|
|
@@ -3767,29 +4462,31 @@ module Parse
|
|
|
3767
4462
|
end
|
|
3768
4463
|
end
|
|
3769
4464
|
|
|
3770
|
-
|
|
3771
|
-
|
|
4465
|
+
if window_before_pipeline
|
|
4466
|
+
# The query's order / skip / limit select the rows the pipeline
|
|
4467
|
+
# (a `$group` from sum, average, group_by, ...) runs over. Leading
|
|
4468
|
+
# `$match` stages of the pipeline filter rows, so they run first.
|
|
4469
|
+
leading = pipeline.take_while { |stage| stage.is_a?(Hash) && stage.keys == ["$match"] }
|
|
4470
|
+
complete_pipeline.concat(leading)
|
|
4471
|
+
complete_pipeline.concat(query_window_stages)
|
|
4472
|
+
complete_pipeline.concat(pipeline.drop(leading.size))
|
|
4473
|
+
else
|
|
4474
|
+
# Append the provided pipeline stages
|
|
4475
|
+
complete_pipeline.concat(pipeline)
|
|
3772
4476
|
|
|
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
|
|
4477
|
+
# Add $sort stage from order constraints if any exist
|
|
4478
|
+
sort = query_sort_stage
|
|
4479
|
+
complete_pipeline << sort if sort
|
|
3784
4480
|
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
4481
|
+
# Add $skip stage if specified
|
|
4482
|
+
if @skip > 0
|
|
4483
|
+
complete_pipeline << { "$skip" => @skip }
|
|
4484
|
+
end
|
|
3789
4485
|
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
4486
|
+
# Add $limit stage if specified
|
|
4487
|
+
if @limit.is_a?(Numeric) && @limit > 0
|
|
4488
|
+
complete_pipeline << { "$limit" => @limit }
|
|
4489
|
+
end
|
|
3793
4490
|
end
|
|
3794
4491
|
|
|
3795
4492
|
# Optimize pipeline by merging consecutive $match stages
|
|
@@ -3861,6 +4558,35 @@ module Parse
|
|
|
3861
4558
|
raw_values: raw_values, raw_field_names: raw_field_names)
|
|
3862
4559
|
end
|
|
3863
4560
|
|
|
4561
|
+
# @!visibility private
|
|
4562
|
+
# The `$sort` stage for the query's order, or nil.
|
|
4563
|
+
# @return [Hash, nil]
|
|
4564
|
+
def query_sort_stage
|
|
4565
|
+
return nil if @order.empty?
|
|
4566
|
+
sort_stage = {}
|
|
4567
|
+
@order.each do |order_obj|
|
|
4568
|
+
sort_stage[order_obj.field.to_s] = order_obj.direction == :desc ? -1 : 1
|
|
4569
|
+
end
|
|
4570
|
+
sort_stage.any? ? { "$sort" => sort_stage } : nil
|
|
4571
|
+
end
|
|
4572
|
+
|
|
4573
|
+
# @!visibility private
|
|
4574
|
+
# The stages that select which rows an aggregate helper groups: the
|
|
4575
|
+
# query's order, skip, and limit, in that order. Empty unless a skip or
|
|
4576
|
+
# a limit is set (an order alone does not change which rows are
|
|
4577
|
+
# grouped).
|
|
4578
|
+
# @return [Array<Hash>]
|
|
4579
|
+
def query_window_stages
|
|
4580
|
+
limited = @limit.is_a?(Numeric) && @limit > 0
|
|
4581
|
+
return [] unless @skip > 0 || limited
|
|
4582
|
+
stages = []
|
|
4583
|
+
sort = query_sort_stage
|
|
4584
|
+
stages << sort if sort
|
|
4585
|
+
stages << { "$skip" => @skip } if @skip > 0
|
|
4586
|
+
stages << { "$limit" => @limit } if limited
|
|
4587
|
+
stages
|
|
4588
|
+
end
|
|
4589
|
+
|
|
3864
4590
|
# Apply the direct-MongoDB stage converter to every stage in a pipeline.
|
|
3865
4591
|
# Idempotent on already-translated input (the per-stage converter
|
|
3866
4592
|
# passes `_p_*` references through unchanged).
|
|
@@ -4344,21 +5070,17 @@ module Parse
|
|
|
4344
5070
|
# $notInQuery: keep documents where lookup found no matches
|
|
4345
5071
|
post_lookup_match[lookup_result_field] = { "$eq" => [] }
|
|
4346
5072
|
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
5073
|
else
|
|
5074
|
+
# A subquery nested anywhere else (under `$or` / `$and` / `$nor`,
|
|
5075
|
+
# `$not`, `$elemMatch`) is not translated on this path. Lifting it
|
|
5076
|
+
# into the post-join `$match` would drop the enclosing operator and
|
|
5077
|
+
# change the query's meaning, and leaving it in place hands MongoDB
|
|
5078
|
+
# an operator it does not have. The scan below refuses it.
|
|
4358
5079
|
remaining_constraints[field] = value
|
|
4359
5080
|
end
|
|
4360
5081
|
end
|
|
4361
5082
|
|
|
5083
|
+
refuse_untranslated_subquery!(remaining_constraints, context: :aggregate)
|
|
4362
5084
|
{ constraints: remaining_constraints, lookup_stages: lookup_stages, post_lookup_match: post_lookup_match }
|
|
4363
5085
|
end
|
|
4364
5086
|
|
|
@@ -4390,18 +5112,11 @@ module Parse
|
|
|
4390
5112
|
# @return [Boolean] true if subquery constraints are present
|
|
4391
5113
|
def has_subquery_constraints?(constraints)
|
|
4392
5114
|
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
|
|
5115
|
+
# Any subquery operator at any depth (string or symbol keys, inside
|
|
5116
|
+
# logical-operator arrays too), so a nested one reaches
|
|
5117
|
+
# {#extract_subquery_to_lookup_stages} and is refused there rather
|
|
5118
|
+
# than passed to MongoDB verbatim.
|
|
5119
|
+
direct_subquery_present?(constraints)
|
|
4405
5120
|
end
|
|
4406
5121
|
|
|
4407
5122
|
alias_method :result, :results
|
|
@@ -4570,7 +5285,9 @@ module Parse
|
|
|
4570
5285
|
|
|
4571
5286
|
run_callbacks :prepare do
|
|
4572
5287
|
q = {} #query
|
|
4573
|
-
|
|
5288
|
+
# An explicit `limit(0)` is sent as 0 (no rows). Omitting it would let
|
|
5289
|
+
# the server apply its default page size.
|
|
5290
|
+
q[:limit] = @limit if @limit.is_a?(Numeric) && @limit >= 0
|
|
4574
5291
|
q[:skip] = @skip if @skip > 0
|
|
4575
5292
|
|
|
4576
5293
|
q[:include] = @includes.join(",") unless @includes.empty?
|
|
@@ -5322,6 +6039,8 @@ module Parse
|
|
|
5322
6039
|
# @param result_key [String] the key to extract from the result
|
|
5323
6040
|
# @return [Object] the aggregation result
|
|
5324
6041
|
def execute_basic_aggregation(pipeline, operation, field, result_key)
|
|
6042
|
+
# `limit(0)` selects no rows.
|
|
6043
|
+
return nil if @limit == 0
|
|
5325
6044
|
# Add match stage if there are where conditions
|
|
5326
6045
|
compiled_where = compile_where
|
|
5327
6046
|
if compiled_where.present?
|
|
@@ -5331,8 +6050,9 @@ module Parse
|
|
|
5331
6050
|
pipeline.unshift({ "$match" => stringified_where })
|
|
5332
6051
|
end
|
|
5333
6052
|
|
|
5334
|
-
# Use the Aggregation class to execute
|
|
5335
|
-
|
|
6053
|
+
# Use the Aggregation class to execute. The query's order / skip /
|
|
6054
|
+
# limit pick the rows before `$group`.
|
|
6055
|
+
aggregation = aggregate(pipeline, verbose: @verbose_aggregate, window_before_pipeline: true)
|
|
5336
6056
|
raw_results = aggregation.raw
|
|
5337
6057
|
|
|
5338
6058
|
# Extract the result from the response
|
|
@@ -5360,7 +6080,7 @@ module Parse
|
|
|
5360
6080
|
formatted = Query.format_field(field)
|
|
5361
6081
|
# For pointer fields, MongoDB stores them with _p_ prefix
|
|
5362
6082
|
# Check if this field is defined as a pointer in the Parse class
|
|
5363
|
-
parse_class =
|
|
6083
|
+
parse_class = table_model_class
|
|
5364
6084
|
if parse_class && is_pointer_field?(parse_class, field, formatted)
|
|
5365
6085
|
"_p_#{formatted}"
|
|
5366
6086
|
else
|
|
@@ -5410,6 +6130,44 @@ module Parse
|
|
|
5410
6130
|
nil
|
|
5411
6131
|
end
|
|
5412
6132
|
|
|
6133
|
+
# @!visibility private
|
|
6134
|
+
# Rewrite bare objectId strings compared to a pointer column into the
|
|
6135
|
+
# "Class$objectId" storage form, for the aggregate and mongo-direct
|
|
6136
|
+
# paths. Only applies when the column is a declared pointer with a known
|
|
6137
|
+
# target class; values already in storage form, pointer hashes, and
|
|
6138
|
+
# non-string values are left alone.
|
|
6139
|
+
#
|
|
6140
|
+
# @param field [String, Symbol] the constraint key.
|
|
6141
|
+
# @param value [Object] the compiled constraint value.
|
|
6142
|
+
# @param arrays [Boolean] also rewrite `$in` / `$nin` / `$all` arrays.
|
|
6143
|
+
# @return [Object] the value, rewritten when it applies.
|
|
6144
|
+
def coerce_bare_pointer_ids(field, value, arrays:)
|
|
6145
|
+
return value unless value.is_a?(String) || value.is_a?(Hash)
|
|
6146
|
+
name = field.to_s
|
|
6147
|
+
name = name.delete_prefix("_p_")
|
|
6148
|
+
return value if name.include?(".")
|
|
6149
|
+
klass = table_model_class
|
|
6150
|
+
return value unless klass
|
|
6151
|
+
target = get_pointer_target_class_for(klass, name)
|
|
6152
|
+
return value unless target
|
|
6153
|
+
bare = ->(v) { v.is_a?(String) && !v.empty? && !v.include?("$") }
|
|
6154
|
+
to_storage = ->(v) { bare.(v) ? "#{target}$#{v}" : v }
|
|
6155
|
+
case value
|
|
6156
|
+
when String
|
|
6157
|
+
to_storage.(value)
|
|
6158
|
+
when Hash
|
|
6159
|
+
return value if value.key?("__type") || value.key?(:__type)
|
|
6160
|
+
value.each_with_object({}) do |(op, op_value), out|
|
|
6161
|
+
out[op] = case op.to_s
|
|
6162
|
+
when "$eq", "$ne" then to_storage.(op_value)
|
|
6163
|
+
when "$in", "$nin", "$all"
|
|
6164
|
+
arrays && op_value.is_a?(Array) ? op_value.map(&to_storage) : op_value
|
|
6165
|
+
else op_value
|
|
6166
|
+
end
|
|
6167
|
+
end
|
|
6168
|
+
end
|
|
6169
|
+
end
|
|
6170
|
+
|
|
5413
6171
|
# Handle a constraint value that is a bare String inside `$in`/`$nin`
|
|
5414
6172
|
# against a column positively identified as a pointer, when the
|
|
5415
6173
|
# target class cannot be resolved (no local belongs_to AND no peer
|
|
@@ -5444,10 +6202,27 @@ module Parse
|
|
|
5444
6202
|
|
|
5445
6203
|
# Check if a field is a pointer field using schema information
|
|
5446
6204
|
# @param field [Symbol, String] the field name to check
|
|
6205
|
+
# @api private
|
|
6206
|
+
# The model class registered for this query's table. Resolves through
|
|
6207
|
+
# each model's `parse_class` (Parse::Model.find_class), so it works for
|
|
6208
|
+
# Parse class names that are not valid Ruby constants (`"contacts"`,
|
|
6209
|
+
# `"_User"`) and for models whose `parse_class` differs from the Ruby
|
|
6210
|
+
# class name. Falls back to a constant lookup for a table named after a
|
|
6211
|
+
# Ruby class that has not been registered under that name. Before 5.8
|
|
6212
|
+
# the query layer used `Parse::Model.const_get(@table)` alone, which
|
|
6213
|
+
# raised (and was rescued to nil) for such names, so pointer fields were
|
|
6214
|
+
# silently treated as plain fields: mongo-direct pipelines addressed
|
|
6215
|
+
# `owner` instead of `_p_owner` and matched nothing.
|
|
6216
|
+
#
|
|
6217
|
+
# @return [Class, nil]
|
|
6218
|
+
def table_model_class
|
|
6219
|
+
Parse::Model.find_class(@table) || (Parse::Model.const_get(@table) rescue nil)
|
|
6220
|
+
end
|
|
6221
|
+
|
|
5447
6222
|
# @return [Boolean] true if the field is a pointer field
|
|
5448
6223
|
def field_is_pointer?(field)
|
|
5449
6224
|
begin
|
|
5450
|
-
parse_class =
|
|
6225
|
+
parse_class = table_model_class
|
|
5451
6226
|
return false unless parse_class.respond_to?(:fields)
|
|
5452
6227
|
|
|
5453
6228
|
# If the field already has _p_ prefix, strip it to get the original field name
|
|
@@ -5500,7 +6275,7 @@ module Parse
|
|
|
5500
6275
|
# @api private
|
|
5501
6276
|
def field_is_known_to_schema?(field)
|
|
5502
6277
|
begin
|
|
5503
|
-
parse_class =
|
|
6278
|
+
parse_class = table_model_class
|
|
5504
6279
|
return false unless parse_class.respond_to?(:fields)
|
|
5505
6280
|
|
|
5506
6281
|
fields_to_check = [field.to_s, field.to_sym]
|
|
@@ -5555,7 +6330,7 @@ module Parse
|
|
|
5555
6330
|
def convert_pointer_value_with_schema(value, field_name, **options)
|
|
5556
6331
|
return value unless value # nil/empty values pass through
|
|
5557
6332
|
|
|
5558
|
-
parse_class =
|
|
6333
|
+
parse_class = table_model_class
|
|
5559
6334
|
is_pointer = parse_class && is_pointer_field?(parse_class, field_name, Query.format_field(field_name))
|
|
5560
6335
|
target_class = parse_class ? get_pointer_target_class_for(parse_class, field_name) : nil
|
|
5561
6336
|
|
|
@@ -5649,6 +6424,12 @@ module Parse
|
|
|
5649
6424
|
next
|
|
5650
6425
|
end
|
|
5651
6426
|
|
|
6427
|
+
# A bare objectId compared to a pointer column becomes the
|
|
6428
|
+
# "Class$objectId" storage form. Compared as-is it never equals the
|
|
6429
|
+
# stored value, so equality matched nothing and `$ne` matched every
|
|
6430
|
+
# row. (`$in` / `$nin` arrays are converted below.)
|
|
6431
|
+
value = coerce_bare_pointer_ids(field, value, arrays: false)
|
|
6432
|
+
|
|
5652
6433
|
# Convert field name to aggregation format
|
|
5653
6434
|
# If field already has _p_ prefix, don't reformat it
|
|
5654
6435
|
if field.to_s.start_with?("_p_")
|
|
@@ -5696,7 +6477,7 @@ module Parse
|
|
|
5696
6477
|
class_name = nil
|
|
5697
6478
|
|
|
5698
6479
|
# First try to get it from the schema
|
|
5699
|
-
parse_class =
|
|
6480
|
+
parse_class = table_model_class
|
|
5700
6481
|
if parse_class
|
|
5701
6482
|
class_name = get_pointer_target_class_for(parse_class, field)
|
|
5702
6483
|
end
|
|
@@ -6111,20 +6892,62 @@ module Parse
|
|
|
6111
6892
|
|
|
6112
6893
|
# Wrapper class for custom aggregation results (from $group, $project, etc.)
|
|
6113
6894
|
# Provides both hash-style access and method-style access to fields.
|
|
6114
|
-
#
|
|
6895
|
+
#
|
|
6896
|
+
# Two naming modes:
|
|
6897
|
+
#
|
|
6898
|
+
# * `:default` (the default): field names are converted from camelCase to
|
|
6899
|
+
# snake_case symbols. `#to_h` returns those symbol keys, and the original
|
|
6900
|
+
# string keys stay readable through `#[]` and `#raw`.
|
|
6901
|
+
# * `:server`: field names are kept exactly as the aggregation returned
|
|
6902
|
+
# them. `#to_h` and `#keys` use the original String keys (`"totalPlays"`,
|
|
6903
|
+
# `"ExternalID"`), nested values are untouched, and distinct keys whose
|
|
6904
|
+
# snake_case forms collide (`"totalPlays"` and `"total_plays"`) both
|
|
6905
|
+
# survive. Method-style access still accepts a snake_case name when it
|
|
6906
|
+
# identifies exactly one key, and raises when it is ambiguous.
|
|
6115
6907
|
#
|
|
6116
6908
|
# @example
|
|
6117
6909
|
# result = AggregationResult.new({ "_id" => "Rock", "totalPlays" => 500 })
|
|
6118
6910
|
# result["_id"] # => "Rock"
|
|
6119
6911
|
# result[:total_plays] # => 500
|
|
6120
6912
|
# result.total_plays # => 500
|
|
6913
|
+
# result.to_h # => { _id: "Rock", total_plays: 500 }
|
|
6914
|
+
#
|
|
6915
|
+
# server = AggregationResult.new({ "_id" => "Rock", "totalPlays" => 500 }, field_names: :server)
|
|
6916
|
+
# server.to_h # => { "_id" => "Rock", "totalPlays" => 500 }
|
|
6121
6917
|
#
|
|
6122
6918
|
class AggregationResult
|
|
6919
|
+
# Naming modes accepted by `field_names:` across the SDK's result APIs.
|
|
6920
|
+
FIELD_NAME_MODES = %i[default server].freeze
|
|
6921
|
+
|
|
6922
|
+
# Normalize a `field_names:` option. nil means `:default`.
|
|
6923
|
+
#
|
|
6924
|
+
# @param value [Symbol, String, nil]
|
|
6925
|
+
# @return [Symbol] `:default` or `:server`
|
|
6926
|
+
# @raise [ArgumentError] for any other value.
|
|
6927
|
+
def self.normalize_field_names!(value)
|
|
6928
|
+
return :default if value.nil?
|
|
6929
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
6930
|
+
return mode if FIELD_NAME_MODES.include?(mode)
|
|
6931
|
+
raise ArgumentError,
|
|
6932
|
+
"field_names: must be one of #{FIELD_NAME_MODES.inspect} (got #{value.inspect})."
|
|
6933
|
+
end
|
|
6934
|
+
|
|
6935
|
+
# @return [Symbol] `:default` or `:server`.
|
|
6936
|
+
attr_reader :field_names
|
|
6937
|
+
|
|
6123
6938
|
# @param data [Hash] the raw aggregation result hash
|
|
6124
|
-
|
|
6939
|
+
# @param field_names [Symbol, nil] `:default` (snake_case symbol keys) or
|
|
6940
|
+
# `:server` (keys exactly as returned).
|
|
6941
|
+
def initialize(data, field_names: nil)
|
|
6942
|
+
@field_names = self.class.normalize_field_names!(field_names)
|
|
6125
6943
|
@data = {}
|
|
6126
6944
|
@raw_data = data
|
|
6127
6945
|
|
|
6946
|
+
if @field_names == :server
|
|
6947
|
+
data.each { |key, value| @data[key.to_s] = value }
|
|
6948
|
+
return
|
|
6949
|
+
end
|
|
6950
|
+
|
|
6128
6951
|
# Convert keys to snake_case and store
|
|
6129
6952
|
data.each do |key, value|
|
|
6130
6953
|
snake_key = Parse::Query.to_snake_case(key.to_s)
|
|
@@ -6137,6 +6960,7 @@ module Parse
|
|
|
6137
6960
|
# @param key [String, Symbol] the field name
|
|
6138
6961
|
# @return [Object] the field value
|
|
6139
6962
|
def [](key)
|
|
6963
|
+
return @data[server_key_for(key)] if server?
|
|
6140
6964
|
@data[key.to_s] || @data[key.to_sym]
|
|
6141
6965
|
end
|
|
6142
6966
|
|
|
@@ -6144,18 +6968,23 @@ module Parse
|
|
|
6144
6968
|
# @param key [String, Symbol] the field name
|
|
6145
6969
|
# @return [Boolean]
|
|
6146
6970
|
def key?(key)
|
|
6971
|
+
return @data.key?(server_key_for(key)) if server?
|
|
6147
6972
|
@data.key?(key.to_s) || @data.key?(key.to_sym)
|
|
6148
6973
|
end
|
|
6149
6974
|
|
|
6150
|
-
# Get all keys
|
|
6151
|
-
#
|
|
6975
|
+
# Get all keys: snake_case symbols by default, or the original String
|
|
6976
|
+
# keys in `:server` mode.
|
|
6977
|
+
# @return [Array<Symbol>, Array<String>]
|
|
6152
6978
|
def keys
|
|
6979
|
+
return @data.keys if server?
|
|
6153
6980
|
@data.keys.select { |k| k.is_a?(Symbol) }
|
|
6154
6981
|
end
|
|
6155
6982
|
|
|
6156
|
-
# Convert to hash
|
|
6983
|
+
# Convert to hash: snake_case symbol keys by default, or the original
|
|
6984
|
+
# String keys (a copy, nested values untouched) in `:server` mode.
|
|
6157
6985
|
# @return [Hash]
|
|
6158
6986
|
def to_h
|
|
6987
|
+
return @data.dup if server?
|
|
6159
6988
|
@data.select { |k, _| k.is_a?(Symbol) }
|
|
6160
6989
|
end
|
|
6161
6990
|
|
|
@@ -6168,8 +6997,18 @@ module Parse
|
|
|
6168
6997
|
@raw_data
|
|
6169
6998
|
end
|
|
6170
6999
|
|
|
7000
|
+
# @return [Boolean] true in `:server` naming mode.
|
|
7001
|
+
def server?
|
|
7002
|
+
@field_names == :server
|
|
7003
|
+
end
|
|
7004
|
+
|
|
6171
7005
|
# Method-style access to fields
|
|
6172
7006
|
def method_missing(method_name, *args, &block)
|
|
7007
|
+
if server?
|
|
7008
|
+
key = server_key_for(method_name, strict: true)
|
|
7009
|
+
return @data[key] if key
|
|
7010
|
+
return super
|
|
7011
|
+
end
|
|
6173
7012
|
key = method_name.to_sym
|
|
6174
7013
|
if @data.key?(key)
|
|
6175
7014
|
@data[key]
|
|
@@ -6179,12 +7018,32 @@ module Parse
|
|
|
6179
7018
|
end
|
|
6180
7019
|
|
|
6181
7020
|
def respond_to_missing?(method_name, include_private = false)
|
|
7021
|
+
return !server_key_for(method_name).nil? || super if server?
|
|
6182
7022
|
@data.key?(method_name.to_sym) || super
|
|
6183
7023
|
end
|
|
6184
7024
|
|
|
6185
7025
|
def inspect
|
|
6186
7026
|
"#<Parse::AggregationResult #{to_h.inspect}>"
|
|
6187
7027
|
end
|
|
7028
|
+
|
|
7029
|
+
private
|
|
7030
|
+
|
|
7031
|
+
# Resolve a requested name to a stored key in `:server` mode: an exact
|
|
7032
|
+
# match first, else the single key whose snake_case form equals it. Two
|
|
7033
|
+
# or more snake_case matches are ambiguous (e.g. `totalPlays` and
|
|
7034
|
+
# `total_plays` both stored): `strict:` raises naming them, otherwise nil.
|
|
7035
|
+
def server_key_for(name, strict: false)
|
|
7036
|
+
str = name.to_s
|
|
7037
|
+
return str if @data.key?(str)
|
|
7038
|
+
matches = @data.keys.select { |k| Parse::Query.to_snake_case(k) == str }
|
|
7039
|
+
return matches.first if matches.length == 1
|
|
7040
|
+
if matches.length > 1 && strict
|
|
7041
|
+
raise ArgumentError,
|
|
7042
|
+
"Parse::AggregationResult: #{str.inspect} matches several fields " \
|
|
7043
|
+
"(#{matches.inspect}); read one with result[#{matches.first.inspect}]."
|
|
7044
|
+
end
|
|
7045
|
+
nil
|
|
7046
|
+
end
|
|
6188
7047
|
end
|
|
6189
7048
|
|
|
6190
7049
|
# Helper class for executing arbitrary MongoDB aggregation pipelines.
|
|
@@ -6303,7 +7162,13 @@ module Parse
|
|
|
6303
7162
|
#
|
|
6304
7163
|
# @yield a block to iterate for each object in the result
|
|
6305
7164
|
# @return [Array<Parse::Object, AggregationResult>] array of results
|
|
6306
|
-
|
|
7165
|
+
#
|
|
7166
|
+
# @param field_names [Symbol, nil] naming mode for AggregationResult rows:
|
|
7167
|
+
# `:default` (snake_case symbol keys from `#to_h`) or `:server` (keys
|
|
7168
|
+
# exactly as the aggregation returned them). Parse::Object rows are
|
|
7169
|
+
# unaffected; use `#as_json` for their server-named form.
|
|
7170
|
+
def results(field_names: nil, &block)
|
|
7171
|
+
@result_field_names = AggregationResult.normalize_field_names!(field_names)
|
|
6307
7172
|
response = execute!
|
|
6308
7173
|
|
|
6309
7174
|
if @mongo_direct && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
@@ -6333,7 +7198,7 @@ module Parse
|
|
|
6333
7198
|
if looks_like_parse_document?(item)
|
|
6334
7199
|
@query.send(:decode, [item]).first
|
|
6335
7200
|
else
|
|
6336
|
-
AggregationResult.new(item)
|
|
7201
|
+
AggregationResult.new(item, field_names: @result_field_names)
|
|
6337
7202
|
end
|
|
6338
7203
|
end
|
|
6339
7204
|
|
|
@@ -6351,7 +7216,7 @@ module Parse
|
|
|
6351
7216
|
@query.send(:redact_excluded_keys!, [parse_doc])
|
|
6352
7217
|
@query.send(:decode, [parse_doc]).first
|
|
6353
7218
|
else
|
|
6354
|
-
AggregationResult.new(Parse::MongoDB.convert_aggregation_document(raw))
|
|
7219
|
+
AggregationResult.new(Parse::MongoDB.convert_aggregation_document(raw), field_names: @result_field_names)
|
|
6355
7220
|
end
|
|
6356
7221
|
end
|
|
6357
7222
|
|
|
@@ -6667,7 +7532,9 @@ module Parse
|
|
|
6667
7532
|
pipeline << sort if sort
|
|
6668
7533
|
pipeline << { "$project" => { "_id" => 0, "objectId" => "$_id", "count" => 1 } }
|
|
6669
7534
|
|
|
6670
|
-
|
|
7535
|
+
return [] if @query.instance_variable_get(:@limit) == 0
|
|
7536
|
+
@query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate),
|
|
7537
|
+
window_before_pipeline: true).raw || []
|
|
6671
7538
|
end
|
|
6672
7539
|
|
|
6673
7540
|
# Count the number of items in each group.
|
|
@@ -6799,6 +7666,9 @@ module Parse
|
|
|
6799
7666
|
# what the caller meant.
|
|
6800
7667
|
validate_sort_target_for_operation!(operation)
|
|
6801
7668
|
|
|
7669
|
+
# `limit(0)` selects no rows, so there are no groups.
|
|
7670
|
+
return {} if @query.instance_variable_get(:@limit) == 0
|
|
7671
|
+
|
|
6802
7672
|
# Format the group field name
|
|
6803
7673
|
formatted_group_field = @query.send(:format_aggregation_field, @group_field)
|
|
6804
7674
|
|
|
@@ -6853,8 +7723,10 @@ module Parse
|
|
|
6853
7723
|
},
|
|
6854
7724
|
}
|
|
6855
7725
|
|
|
6856
|
-
# Use the Aggregation class to execute
|
|
6857
|
-
|
|
7726
|
+
# Use the Aggregation class to execute. The query's order / skip /
|
|
7727
|
+
# limit pick the rows before `$group`.
|
|
7728
|
+
aggregation = @query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate),
|
|
7729
|
+
window_before_pipeline: true)
|
|
6858
7730
|
raw_results = aggregation.raw
|
|
6859
7731
|
|
|
6860
7732
|
# Convert array of results to hash
|
|
@@ -6922,6 +7794,10 @@ module Parse
|
|
|
6922
7794
|
pipeline << { "$match" => mongo_constraints } if mongo_constraints.any?
|
|
6923
7795
|
end
|
|
6924
7796
|
|
|
7797
|
+
# The query's order / skip / limit pick the rows before `$group`.
|
|
7798
|
+
window = @query.send(:query_window_stages)
|
|
7799
|
+
pipeline.concat(@query.send(:translate_pipeline_for_direct_mongodb, window)) if window.any?
|
|
7800
|
+
|
|
6925
7801
|
# Add unwind stage if flatten_arrays is enabled
|
|
6926
7802
|
if @flatten_arrays
|
|
6927
7803
|
pipeline << { "$unwind" => "$#{mongo_group_field}" }
|
|
@@ -7987,3 +8863,93 @@ module Parse
|
|
|
7987
8863
|
end
|
|
7988
8864
|
end
|
|
7989
8865
|
end # Parse
|
|
8866
|
+
|
|
8867
|
+
module Parse
|
|
8868
|
+
# Wraps the public compile and pipeline entry points of Parse::Query and
|
|
8869
|
+
# the aggregation helpers so {Parse::Query.format_field} honors the model's
|
|
8870
|
+
# explicit `field:` names (for example `account_id` or `authId_sub`) instead
|
|
8871
|
+
# of camel-casing them. Without this, `where(account_id: ...)` compiled to
|
|
8872
|
+
# `accountId` and silently matched nothing.
|
|
8873
|
+
module QueryFieldAliasScope
|
|
8874
|
+
QUERY_METHODS = %i[
|
|
8875
|
+
add_constraint keys exclude_keys order includes pluck count_distinct
|
|
8876
|
+
compile compile_where prepared pipeline count results distinct first first_direct
|
|
8877
|
+
build_direct_mongodb_pipeline build_query_aggregate_pipeline build_aggregation_pipeline
|
|
8878
|
+
aggregate group_by group_by_date explain sum average min max
|
|
8879
|
+
].freeze
|
|
8880
|
+
|
|
8881
|
+
# Aggregation helper methods that format field names while running.
|
|
8882
|
+
HELPER_METHODS = %i[pipeline results count execute! sum average min max list raw].freeze
|
|
8883
|
+
|
|
8884
|
+
# The mongo-direct entry points are wrapped with their exact signatures
|
|
8885
|
+
# (not `*args, **kwargs`), so `Method#parameters` still reports the
|
|
8886
|
+
# `client:` keyword that client-binding checks rely on.
|
|
8887
|
+
module DirectMethods
|
|
8888
|
+
def results_direct(raw: false, max_time_ms: nil, session_token: nil, master: nil,
|
|
8889
|
+
acl_user: nil, acl_role: nil, client: nil, &block)
|
|
8890
|
+
block = Parse::Query.block_outside_field_aliases(block, @table)
|
|
8891
|
+
Parse::Query.with_field_aliases(@table) do
|
|
8892
|
+
super(raw: raw, max_time_ms: max_time_ms, session_token: session_token, master: master,
|
|
8893
|
+
acl_user: acl_user, acl_role: acl_role, client: client, &block)
|
|
8894
|
+
end
|
|
8895
|
+
end
|
|
8896
|
+
|
|
8897
|
+
def count_direct(session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil)
|
|
8898
|
+
Parse::Query.with_field_aliases(@table) do
|
|
8899
|
+
super(session_token: session_token, master: master, acl_user: acl_user,
|
|
8900
|
+
acl_role: acl_role, client: client)
|
|
8901
|
+
end
|
|
8902
|
+
end
|
|
8903
|
+
|
|
8904
|
+
def distinct_direct(field, return_pointers: false, order: nil, session_token: nil, master: nil,
|
|
8905
|
+
acl_user: nil, acl_role: nil, client: nil)
|
|
8906
|
+
Parse::Query.with_field_aliases(@table) do
|
|
8907
|
+
super(field, return_pointers: return_pointers, order: order, session_token: session_token,
|
|
8908
|
+
master: master, acl_user: acl_user, acl_role: acl_role, client: client)
|
|
8909
|
+
end
|
|
8910
|
+
end
|
|
8911
|
+
|
|
8912
|
+
def distinct_direct_pointers(field, order: nil, session_token: nil, master: nil,
|
|
8913
|
+
acl_user: nil, acl_role: nil, client: nil)
|
|
8914
|
+
Parse::Query.with_field_aliases(@table) do
|
|
8915
|
+
super(field, order: order, session_token: session_token, master: master,
|
|
8916
|
+
acl_user: acl_user, acl_role: acl_role, client: client)
|
|
8917
|
+
end
|
|
8918
|
+
end
|
|
8919
|
+
end
|
|
8920
|
+
|
|
8921
|
+
# @param klass [Class] the class whose methods are wrapped.
|
|
8922
|
+
# @param methods [Array<Symbol>]
|
|
8923
|
+
# @param table_of [Proc] `->(receiver) { parse_class_name }`.
|
|
8924
|
+
def self.wrap(klass, methods, table_of)
|
|
8925
|
+
private_methods = methods.select { |m| klass.private_method_defined?(m) }
|
|
8926
|
+
protected_methods = methods.select { |m| klass.protected_method_defined?(m) }
|
|
8927
|
+
mod = Module.new
|
|
8928
|
+
methods.each do |m|
|
|
8929
|
+
next unless klass.method_defined?(m) || klass.private_method_defined?(m)
|
|
8930
|
+
# A call for the table already in scope goes straight to the
|
|
8931
|
+
# original, so nested query building pays only the table lookup.
|
|
8932
|
+
mod.send(:define_method, m) do |*args, **kwargs, &blk|
|
|
8933
|
+
table = table_of.call(self)
|
|
8934
|
+
frame = Fiber[Parse::Query::FIELD_ALIAS_SCOPE_KEY]
|
|
8935
|
+
return super(*args, **kwargs, &blk) if frame && frame.open && table == frame.scope.table
|
|
8936
|
+
blk = Parse::Query.block_outside_field_aliases(blk, table) if blk
|
|
8937
|
+
Parse::Query.with_field_aliases(table) { super(*args, **kwargs, &blk) }
|
|
8938
|
+
end
|
|
8939
|
+
end
|
|
8940
|
+
# Keep each wrapped method's original visibility.
|
|
8941
|
+
mod.send(:private, *private_methods) unless private_methods.empty?
|
|
8942
|
+
mod.send(:protected, *protected_methods) unless protected_methods.empty?
|
|
8943
|
+
klass.prepend(mod)
|
|
8944
|
+
end
|
|
8945
|
+
end
|
|
8946
|
+
|
|
8947
|
+
QueryFieldAliasScope.wrap(Query, QueryFieldAliasScope::QUERY_METHODS,
|
|
8948
|
+
->(query) { query.instance_variable_get(:@table) })
|
|
8949
|
+
Query.prepend(QueryFieldAliasScope::DirectMethods)
|
|
8950
|
+
[Aggregation, GroupBy, GroupByDate].each do |helper|
|
|
8951
|
+
QueryFieldAliasScope.wrap(helper, QueryFieldAliasScope::HELPER_METHODS,
|
|
8952
|
+
->(agg) { agg.instance_variable_get(:@query)&.table })
|
|
8953
|
+
end
|
|
8954
|
+
end
|
|
8955
|
+
|