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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. 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
- attr_writer :client
213
- attr_accessor :key, :cache, :use_master_key, :verbose_aggregate, :read_preference
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
- # Special handling for aggregation pipeline constraints
411
- # Instead of overwriting, concatenate the pipeline arrays
412
- if clause.key?("__aggregation_pipeline") && subclause_json.key?("__aggregation_pipeline")
413
- clause["__aggregation_pipeline"].concat(subclause_json["__aggregation_pipeline"])
414
- # Don't merge the __aggregation_pipeline key using deep_merge
415
- subclause_without_pipeline = subclause_json.reject { |k, v| k == "__aggregation_pipeline" }
416
- clause.deep_merge!(subclause_without_pipeline)
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
- clause.deep_merge!(subclause_json)
675
+ append_and_constraint!(clause, key, value)
419
676
  end
420
-
421
- clause
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
- if value.nil? || (value.is_a?(String) && value.present?)
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
- @order.push entry
765
- when Symbol, String
766
- o = Order.new(entry)
767
- o.field = Query.format_field(o.field)
768
- @order.push o
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
- @order.push o
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
- where_clauses = where_clauses.where if where_clauses.is_a?(Parse::Query)
1082
- where_clauses = Parse::Query.new(@table, where_clauses).where if where_clauses.is_a?(Hash)
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
- # we can only have one compound query constraint. If we need to add another OR clause
1085
- # let's find the one we have (if any)
1086
- compound = @where.find { |f| f.is_a?(Parse::Constraint::CompoundQueryConstraint) }
1087
- # create a set of clauses that are not an OR clause.
1088
- remaining_clauses = @where.select { |f| f.is_a?(Parse::Constraint::CompoundQueryConstraint) == false }
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.compile_where(remaining_clauses)
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.compile_where(where_clauses)
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.or_where other_query.where
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 = Parse::Model.const_get(@table) rescue nil
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
- aggregation = aggregate(pipeline, verbose: @verbose_aggregate)
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
- if limit_or_constraints.is_a?(Hash)
1485
- conditions(limit_or_constraints)
1486
- # Check if limit was set in constraints, otherwise use 1
1487
- # Handle :max case - if @limit is :max, default to 1 for first()
1488
- fetch_count = (@limit.is_a?(Numeric) ? @limit : nil) || 1
1489
- # Set @limit to ensure query only fetches the needed records
1490
- @results = nil if @limit != fetch_count
1491
- @limit = fetch_count
1492
- else
1493
- fetch_count = case limit_or_constraints
1494
- when Numeric then limit_or_constraints.to_i
1495
- when String
1496
- unless limit_or_constraints =~ /\A-?\d+\z/
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
- limit_or_constraints.to_i
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
- # Allow limit to be overridden via options
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
- @results = nil if @limit != limit
1551
- @limit = limit
1552
- # Add updated_at descending order if not already present
1553
- order(:updated_at.desc) unless @order.any? { |o| o.operand == :updated_at }
1554
- # Apply any additional keyword options as conditions (e.g., keys:, includes:)
1555
- conditions(options) unless options.empty?
1556
- limit == 1 ? results.first : results.first(limit)
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
- unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session || has_ambient_session
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
- else
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
- return client_kwarg.merge(explicit.to_h { |k| [k, options[k]] }) if explicit.any?
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
- mongo_constraints = convert_constraints_for_direct_mongodb(compiled_where)
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
- mongo_field = convert_field_for_direct_mongodb(key.to_s)
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: Unwind the array (since $lookup returns array, but we want single object)
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
- "$unwind" => {
3064
- "path" => "$#{lookup_result_field}",
3065
- "preserveNullAndEmptyArrays" => true,
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: compile_where,
4836
+ where: compile_rest_where,
3565
4837
  fields: fields,
3566
4838
  keys: keys,
3567
4839
  watch: watch,
3568
- session_token: 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
- # Append the provided pipeline stages
3771
- complete_pipeline.concat(pipeline)
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
- # Add $sort stage from order constraints if any exist
3774
- unless @order.empty?
3775
- sort_stage = {}
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
- # Add $skip stage if specified
3786
- if @skip > 0
3787
- complete_pipeline << { "$skip" => @skip }
3788
- end
5059
+ # Add $skip stage if specified
5060
+ if @skip > 0
5061
+ complete_pipeline << { "$skip" => @skip }
5062
+ end
3789
5063
 
3790
- # Add $limit stage if specified
3791
- if @limit.is_a?(Numeric) && @limit > 0
3792
- complete_pipeline << { "$limit" => @limit }
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
- constraints.any? do |field, value|
4395
- if value.is_a?(Hash)
4396
- # Check for both string and symbol keys since constraints can come from
4397
- # different sources (JSON parsing vs Ruby symbol keys)
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
- q[:limit] = @limit if @limit.is_a?(Numeric) && @limit > 0
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
- aggregation = aggregate(pipeline, verbose: @verbose_aggregate)
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 = Parse::Model.const_get(@table) rescue nil
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 = Parse::Model.const_get(@table)
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 = Parse::Model.const_get(@table)
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 = Parse::Model.const_get(@table) rescue nil
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 = Parse::Model.const_get(@table) rescue nil
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
- # Filter to only queries that have constraints
5797
- queries = queries.filter { |q| q.where.present? && !q.where.empty? }
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 do |query|
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 @client and @results instance variables are intentionally NOT cloned.
5854
- # The cloned query will use the default client when executed.
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
- [:count, :where, :order, :keys, :exclude_keys, :includes, :limit, :skip, :cache, :use_master_key, :hint].each do |param|
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
- # Field names are automatically converted from camelCase to snake_case.
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
- def initialize(data)
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 (snake_case symbols)
6151
- # @return [Array<Symbol>]
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 with snake_case symbol keys
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
- def results(&block)
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
- @query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate)).raw || []
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
- aggregation = @query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate))
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
+