parse-stack-next 5.7.6 → 5.8.0

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