parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -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
@@ -570,6 +570,58 @@ module Parse
570
570
  constraint_reduce(where)
571
571
  end
572
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
+
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
+
573
625
  # @!visibility private
574
626
  # Reduce a list of constraints into one where hash. Constraints on the
575
627
  # same field are combined so every one of them still applies:
@@ -586,11 +638,16 @@ module Parse
586
638
  #
587
639
  # Identical constraints collapse to one.
588
640
  def constraint_reduce(clauses)
589
- clauses.reduce({}) do |clause, subclause|
641
+ reduced = clauses.reduce({}) do |clause, subclause|
590
642
  subclause_json = subclause.as_json || {}
591
643
  subclause_json.each { |key, value| merge_constraint_entry!(clause, key, value) }
592
644
  clause
593
645
  end
646
+ # A raw `{ "$regex" => ... }` in a where hash (or under `$not`,
647
+ # `$elemMatch`, or an OR branch) never passes through the regex
648
+ # constraint's own check, so every compiled pattern is checked here.
649
+ Parse::RegexSecurity.validate_where!(reduced)
650
+ reduced
594
651
  end
595
652
 
596
653
  # Operators whose meaning depends on a partner operator in the same
@@ -688,6 +745,14 @@ module Parse
688
745
  @client ||= self.class.client
689
746
  end
690
747
 
748
+ # Set the client this query runs through. A client from
749
+ # {Parse::Client#become} carries a session, so on a query combined
750
+ # with OR the change is checked like any other change of authority.
751
+ # @param value [Parse::Client]
752
+ def client=(value)
753
+ guard_or_scope_change { @client = value }
754
+ end
755
+
691
756
  # Clear a specific clause of this query. This can be one of: :where, :order,
692
757
  # :includes, :skip, :limit, :count, :keys or :results.
693
758
  # @param item [:Symbol] the clause to clear.
@@ -768,6 +833,11 @@ module Parse
768
833
  # and their value.
769
834
  # @return [self]
770
835
  def conditions(expressions = {})
836
+ # Apply `:or` last, so a `:session` or `:use_master_key` in the same
837
+ # Hash is in place before the branches' authority is compared.
838
+ if expressions.is_a?(Hash) && expressions.key?(:or) && expressions.keys.last != :or
839
+ expressions = expressions.reject { |k, _| k == :or }.merge(or: expressions[:or])
840
+ end
771
841
  expressions.each do |expression, value|
772
842
  # Normalize to symbol for comparison (handles both string and symbol keys)
773
843
  expr_sym = expression.respond_to?(:to_sym) ? expression.to_sym : expression
@@ -835,6 +905,9 @@ module Parse
835
905
  not_publicly_readable if value
836
906
  elsif expr_sym == :not_publicly_writable
837
907
  not_publicly_writable if value
908
+ elsif expression == :or
909
+ # Symbol key only: a string "or" stays a literal field name.
910
+ add_or_branches(value)
838
911
  else
839
912
  add_constraint(expression, value)
840
913
  end
@@ -842,6 +915,114 @@ module Parse
842
915
  self #chaining
843
916
  end
844
917
 
918
+ # @!visibility private
919
+ # Handle the `:or => [branch, ...]` condition. Each branch is a
920
+ # constraint Hash, a {Parse::Query}, or an Array of {Parse::Constraint}.
921
+ # The branches are ORed together and the group is ANDed with the
922
+ # query's other constraints. A branch with no constraints matches every
923
+ # row, so the group adds no constraint. An empty list matches no rows.
924
+ # @param branches [Array]
925
+ def add_or_branches(branches)
926
+ unless branches.is_a?(Array)
927
+ raise ArgumentError, ":or expects an Array of constraint hashes or queries, got #{branches.class}."
928
+ end
929
+ with_or_rollback { add_or_branches_unguarded(branches) }
930
+ end
931
+
932
+ # @!visibility private
933
+ def add_or_branches_unguarded(branches)
934
+ wheres = branches.map do |branch|
935
+ case branch
936
+ when Parse::Query
937
+ unless branch.table == @table
938
+ raise ArgumentError, ":or branch queries must be of the same class #{@table} (got #{branch.table})."
939
+ end
940
+ # The branch's authority travels with its conditions, or raises
941
+ # when it differs from this query's.
942
+ merge_or_scope!(branch)
943
+ branch.where
944
+ when Hash then or_branch_hash_where(branch)
945
+ when Array
946
+ # A branch given as a list: constraints, or constraint hashes that
947
+ # are expanded the same way a Hash branch is. Anything else is
948
+ # refused, so a typo cannot empty the branch and turn it into
949
+ # match-all.
950
+ branch.flat_map do |item|
951
+ case item
952
+ when Parse::Constraint then [item]
953
+ when Hash then or_branch_hash_where(item)
954
+ else
955
+ raise ArgumentError, ":or Array branches may only hold Parse::Constraint objects or constraint hashes, got #{item.class}."
956
+ end
957
+ end
958
+ else
959
+ raise ArgumentError, ":or branches must be constraint hashes or Parse::Query objects, got #{branch.class}."
960
+ end
961
+ end
962
+ if wheres.empty?
963
+ # `$or: []` is rejected by MongoDB. Keep the "no rows" meaning.
964
+ add_constraint(:objectId.in, [])
965
+ return
966
+ end
967
+ # Refuse pipeline-only constraints in any branch before deciding
968
+ # whether the group is match-all.
969
+ compiled = wheres.map { |w| Parse::Query.compile_or_branch(w) }
970
+ return if wheres.any? { |w| Parse::Query.match_all?(w) }
971
+ add_constraint(Parse::Constraint::CompoundQueryConstraint.new(:or, compiled), nil, filter: false)
972
+ end
973
+ private :add_or_branches_unguarded
974
+
975
+ # @!visibility private
976
+ # Query options that shape a whole query and mean nothing inside one OR
977
+ # branch.
978
+ OR_BRANCH_OPTION_KEYS = %i[order keys key skip limit include includes cache read_preference].freeze
979
+
980
+ # @!visibility private
981
+ # The constraints of a Hash OR branch. The branch is built as its own
982
+ # query so a `session:` / `use_master_key:` key or a nested `:or` with a
983
+ # scoped query is applied there, and its authority then has to agree
984
+ # with this query's (adopted when this query has none, refused when it
985
+ # differs). Query-shaping options inside a branch are refused.
986
+ # @param hash [Hash]
987
+ # @return [Array<Parse::Constraint>]
988
+ def or_branch_hash_where(hash)
989
+ options = hash.keys.select { |k| k.is_a?(Symbol) && OR_BRANCH_OPTION_KEYS.include?(k) }
990
+ if options.any?
991
+ raise ArgumentError,
992
+ "#{options.map(&:inspect).join(", ")} cannot be set inside an OR branch; " \
993
+ "set it on the query itself."
994
+ end
995
+ branch = Parse::Query.new(@table, hash)
996
+ merge_or_scope!(branch)
997
+ branch.where
998
+ end
999
+ private :or_branch_hash_where
1000
+
1001
+ # @!visibility private
1002
+ # Run an OR or subquery merge so that a raise leaves this query exactly
1003
+ # as it was: constraints, authority, client, and the pinned authority.
1004
+ def with_or_rollback
1005
+ # Captured before the guarded block: a failure here changes nothing,
1006
+ # so there is nothing to restore.
1007
+ ivars = OR_SCOPE_STATE_IVARS + %i[@_or_branch_scope @_or_branch_app @read_preference]
1008
+ saved = ivars.to_h { |iv| [iv, [instance_variable_defined?(iv), instance_variable_get(iv)]] }
1009
+ saved_where = @where.dup
1010
+ begin
1011
+ yield
1012
+ rescue StandardError
1013
+ saved.each do |iv, (defined, value)|
1014
+ if defined
1015
+ instance_variable_set(iv, value)
1016
+ elsif instance_variable_defined?(iv)
1017
+ remove_instance_variable(iv)
1018
+ end
1019
+ end
1020
+ @where = saved_where
1021
+ raise
1022
+ end
1023
+ end
1024
+ private :with_or_rollback
1025
+
845
1026
  alias_method :query, :conditions
846
1027
  alias_method :append, :conditions
847
1028
 
@@ -854,11 +1035,16 @@ module Parse
854
1035
  value = value.session_token
855
1036
  end
856
1037
 
857
- if value.nil? || (value.is_a?(String) && value.present?)
858
- @session_token = value
859
- else
1038
+ unless value.nil? || (value.is_a?(String) && value.present?)
860
1039
  raise ArgumentError, "Invalid session token passed to query."
861
1040
  end
1041
+ guard_or_scope_change { @session_token = value }
1042
+ end
1043
+
1044
+ # @param value [Boolean, nil] whether to send the master key (nil
1045
+ # leaves the decision to the request layer).
1046
+ def use_master_key=(value)
1047
+ guard_or_scope_change { @use_master_key = value }
862
1048
  end
863
1049
 
864
1050
  # returns the query clause for the particular clause
@@ -1008,11 +1194,16 @@ module Parse
1008
1194
  case entry
1009
1195
  when Order
1010
1196
  entry.field = Query.format_field(entry.field)
1011
- @order.push entry
1012
- when Symbol, String
1013
- o = Order.new(entry)
1014
- o.field = Query.format_field(o.field)
1015
- @order.push o
1197
+ push_order(entry)
1198
+ when String
1199
+ if entry.include?(",")
1200
+ # The REST sort string lists several fields: "title,-plays".
1201
+ order(*entry.split(",").map(&:strip).reject(&:empty?))
1202
+ next
1203
+ end
1204
+ push_order(parse_order_string(entry))
1205
+ when Symbol
1206
+ push_order(parse_order_string(entry))
1016
1207
  when Hash
1017
1208
  entry.each do |field, direction|
1018
1209
  dir_sym = direction.is_a?(String) ? direction.downcase.to_sym : direction
@@ -1023,7 +1214,7 @@ module Parse
1023
1214
  end
1024
1215
  o = Order.new(field, dir_sym)
1025
1216
  o.field = Query.format_field(o.field)
1026
- @order.push o
1217
+ push_order(o)
1027
1218
  end
1028
1219
  else
1029
1220
  raise ArgumentError,
@@ -1036,6 +1227,38 @@ module Parse
1036
1227
  self #chaining
1037
1228
  end #order
1038
1229
 
1230
+ # @!visibility private
1231
+ # One field of the REST sort-string form: "-title" is descending and
1232
+ # "+title" ascending. Parsing the sign here keeps a repeated field
1233
+ # deduplicated and gives the direct `$sort` stage a real field name.
1234
+ # @return [Parse::Order]
1235
+ def parse_order_string(entry)
1236
+ name = entry.to_s
1237
+ o = if name.start_with?("-") && name.length > 1
1238
+ Order.new(name[1..], :desc)
1239
+ elsif name.start_with?("+") && name.length > 1
1240
+ Order.new(name[1..], :asc)
1241
+ else
1242
+ Order.new(entry)
1243
+ end
1244
+ o.field = Query.format_field(o.field)
1245
+ o
1246
+ end
1247
+ private :parse_order_string
1248
+
1249
+ # @!visibility private
1250
+ # Add one sort key. A field that is already in the order keeps its
1251
+ # position and takes the new direction, so it is sent once. Parse Server
1252
+ # and MongoDB both resolve a repeated sort key the same way.
1253
+ def push_order(order)
1254
+ index = @order.index { |existing| existing.field.to_s == order.field.to_s }
1255
+ if index
1256
+ @order[index] = order
1257
+ else
1258
+ @order.push order
1259
+ end
1260
+ end
1261
+
1039
1262
  # Use with limit to paginate through results. Default is 0.
1040
1263
  # @example
1041
1264
  # # get the next 3 songs after the first 10
@@ -1184,6 +1407,7 @@ module Parse
1184
1407
  # @return [self]
1185
1408
  def add_constraints(list)
1186
1409
  list = Array.wrap(list).select { |m| m.is_a?(Parse::Constraint) }
1410
+ list.each { |c| merge_subquery_scopes!(c.value) }
1187
1411
  @where = @where + list
1188
1412
  self
1189
1413
  end
@@ -1222,11 +1446,31 @@ module Parse
1222
1446
  constraint.operand = Query.format_field(constraint.operand)
1223
1447
  end
1224
1448
  reject_vector_constraint!(constraint)
1449
+ merge_subquery_scopes!(constraint.value)
1225
1450
  @where.push constraint
1226
1451
  @results = nil
1227
1452
  self #chaining
1228
1453
  end
1229
1454
 
1455
+ # @!visibility private
1456
+ # Parse Server runs a subquery (`$inQuery`, `$notInQuery`, `$select`,
1457
+ # `$dontSelect`) under the outer request's authority, so a subquery's
1458
+ # own session or scope has to agree with this query's: it is adopted
1459
+ # when this query has none and refused when it differs, the same rule
1460
+ # as an OR branch.
1461
+ def merge_subquery_scopes!(value)
1462
+ subqueries = case value
1463
+ when Parse::Query then [value]
1464
+ when Hash
1465
+ q = value[:query] || value["query"]
1466
+ q.is_a?(Parse::Query) ? [q] : []
1467
+ else []
1468
+ end
1469
+ return if subqueries.empty?
1470
+ with_or_rollback { subqueries.each { |q| merge_or_scope!(q) } }
1471
+ end
1472
+ private :merge_subquery_scopes!
1473
+
1230
1474
  # @!visibility private
1231
1475
  # Raise {Parse::VectorSearch::ConstraintNotSupported} when a
1232
1476
  # constraint targets a declared `:vector` property with an operator
@@ -1325,8 +1569,22 @@ module Parse
1325
1569
  # @param where_clauses [Array<Parse::Constraint>] a list of Parse::Constraint objects to combine.
1326
1570
  # @return [Query] the combined query with an OR clause.
1327
1571
  def or_where(where_clauses = [])
1328
- where_clauses = where_clauses.where if where_clauses.is_a?(Parse::Query)
1329
- where_clauses = Parse::Query.new(@table, where_clauses).where if where_clauses.is_a?(Hash)
1572
+ with_or_rollback { or_where_unguarded(where_clauses) }
1573
+ end
1574
+
1575
+ # @!visibility private
1576
+ def or_where_unguarded(where_clauses)
1577
+ if where_clauses.is_a?(Parse::Query)
1578
+ unless where_clauses.table == @table
1579
+ raise ArgumentError, "Parse queries must be of the same class #{@table}."
1580
+ end
1581
+ merge_or_scope!(where_clauses)
1582
+ # A query with no constraints is a branch that matches every row, so
1583
+ # the whole OR does too.
1584
+ return match_all! if Parse::Query.match_all?(where_clauses.where)
1585
+ where_clauses = where_clauses.where
1586
+ end
1587
+ where_clauses = or_branch_hash_where(where_clauses) if where_clauses.is_a?(Hash)
1330
1588
  return self if where_clauses.blank?
1331
1589
  # Reuse the existing OR only when it is the query's sole constraint.
1332
1590
  # When other constraints sit beside it, the current where is
@@ -1339,13 +1597,13 @@ module Parse
1339
1597
  # if we don't have a OR clause to reuse, then create a new one with then
1340
1598
  # current set of constraints
1341
1599
  if compound.blank?
1342
- initial_constraints = Parse::Query.compile_where(@where)
1600
+ initial_constraints = Parse::Query.compile_or_branch(@where)
1343
1601
  # Only include initial constraints if they're not empty
1344
1602
  initial_values = initial_constraints.empty? ? [] : [initial_constraints]
1345
1603
  compound = Parse::Constraint::CompoundQueryConstraint.new :or, initial_values
1346
1604
  end
1347
1605
  # then take the where clauses from the second query and append them.
1348
- new_constraints = Parse::Query.compile_where(where_clauses)
1606
+ new_constraints = Parse::Query.compile_or_branch(where_clauses)
1349
1607
  # Only add new constraints if they're not empty
1350
1608
  unless new_constraints.empty?
1351
1609
  compound.value.push new_constraints
@@ -1354,16 +1612,330 @@ module Parse
1354
1612
  @where = [compound]
1355
1613
  self #chaining
1356
1614
  end
1615
+ private :or_where_unguarded
1357
1616
 
1617
+ # Combine two queries with OR. The result is a copy of the receiver
1618
+ # (its limit, order, keys and includes) whose where is the OR of both.
1619
+ #
1620
+ # * A receiver with no constraints is a starting point, the same as
1621
+ # {#or_where}: `Model.query | q` gives an OR whose only branch is `q`,
1622
+ # so `[q1, q2].reduce(Model.query, :|)` gives `q1 OR q2`.
1623
+ # * A right-hand side with no constraints is a branch that matches
1624
+ # every row, so `q | Model.query` has no constraints.
1625
+ #
1626
+ # The auth scope (session token, master-key flag, `scope_to_user`,
1627
+ # `scope_to_role`) carries over from whichever side set it. Two
1628
+ # different scopes raise `ArgumentError`, since the OR could only run
1629
+ # under one of them.
1358
1630
  # @see #or_where
1359
1631
  # @return [Query] the combined query with an OR clause.
1632
+ # @raise [ArgumentError] when the classes or auth scopes differ, or a
1633
+ # branch holds a pipeline-only constraint.
1360
1634
  def |(other_query)
1361
1635
  raise ArgumentError, "Parse queries must be of the same class #{@table}." unless @table == other_query.table
1362
1636
  copy_query = self.clone
1363
- copy_query.or_where other_query.where
1637
+ copy_query.send(:merge_or_scope!, other_query)
1638
+ if Parse::Query.match_all?(other_query.where)
1639
+ # Still refuse a pipeline-only receiver rather than silently
1640
+ # widening it.
1641
+ Parse::Query.compile_or_branch(@where)
1642
+ copy_query.match_all!
1643
+ else
1644
+ copy_query.or_where other_query.where
1645
+ end
1364
1646
  copy_query
1365
1647
  end
1366
1648
 
1649
+ # @!visibility private
1650
+ # Auth scope ivars an OR must agree on.
1651
+ OR_SCOPE_IVARS = %i[session_token use_master_key acl_user acl_role].freeze
1652
+
1653
+ # @!visibility private
1654
+ # The authority this query runs under, as one comparable Hash: the
1655
+ # session token, the scoped user id, the scoped role name, and an
1656
+ # explicit master-key flag. An explicit `use_master_key = false` beside
1657
+ # a session, user, or role adds nothing, since those already run without
1658
+ # the master key. An empty Hash is the default authority.
1659
+ # @return [Hash]
1660
+ def or_effective_scope
1661
+ scope = {}
1662
+ scope[:session] = @session_token unless @session_token.nil?
1663
+ # A client from Parse::Client#become sends its bound session on every
1664
+ # request that names no token and does not ask for the master key,
1665
+ # so it counts as that session. A non-default client with neither a
1666
+ # session nor a master key runs without the master key.
1667
+ own_client = or_explicit_client
1668
+ if own_client && @session_token.nil? && @use_master_key != true
1669
+ bound = own_client.respond_to?(:session_token) ? own_client.session_token : nil
1670
+ if bound.is_a?(String) && !bound.strip.empty?
1671
+ scope[:session] = bound
1672
+ elsif !(own_client.respond_to?(:master_key) && own_client.master_key.present?)
1673
+ scope[:master] = false
1674
+ end
1675
+ end
1676
+ scope[:user] = Parse::Query.or_scope_value(:acl_user, @acl_user) unless @acl_user.nil?
1677
+ scope[:role] = Parse::Query.or_scope_value(:acl_role, @acl_role) unless @acl_role.nil?
1678
+ unless @use_master_key.nil? || (@use_master_key == false && !scope.empty?)
1679
+ scope[:master] = @use_master_key
1680
+ end
1681
+ scope.delete(:master) if scope[:master] == false && scope.size > 1
1682
+ scope
1683
+ end
1684
+ protected :or_effective_scope
1685
+
1686
+ # @!visibility private
1687
+ # The client set on this query when it is not the default client, or
1688
+ # nil. Read from the instance variable so it never memoizes the default.
1689
+ # @return [Parse::Client, nil]
1690
+ def or_explicit_client
1691
+ own = @client
1692
+ return nil if own.nil?
1693
+ default = begin
1694
+ self.class.client
1695
+ rescue StandardError
1696
+ nil
1697
+ end
1698
+ return nil if default && own.equal?(default)
1699
+ own
1700
+ end
1701
+ protected :or_explicit_client
1702
+
1703
+ # @!visibility private
1704
+ # Instance variables that make up a query's authority.
1705
+ OR_SCOPE_STATE_IVARS = %i[@session_token @use_master_key @acl_user @acl_role @client].freeze
1706
+
1707
+ # @!visibility private
1708
+ # Run an assignment that may change this query's authority. On a query
1709
+ # combined with OR, a change that leaves the pinned authority raises
1710
+ # and the previous values are restored, so a rescued error never
1711
+ # leaves the query running under a different principal.
1712
+ def guard_or_scope_change
1713
+ return yield if @_or_branch_scope.nil?
1714
+ saved = OR_SCOPE_STATE_IVARS.to_h { |iv| [iv, instance_variable_get(iv)] }
1715
+ begin
1716
+ yield
1717
+ enforce_or_branch_scope!
1718
+ rescue StandardError
1719
+ saved.each { |iv, v| instance_variable_set(iv, v) }
1720
+ raise
1721
+ end
1722
+ end
1723
+ private :guard_or_scope_change
1724
+
1725
+ # @!visibility private
1726
+ # Combine `other`'s authority with this query's for an OR. Both must
1727
+ # run under the same authority: a query with the default authority
1728
+ # adopts the other's, and two different authorities (a session and a
1729
+ # scoped user, a session and the master key, two users) raise. The
1730
+ # resulting authority is then pinned whichever side carried it, so a
1731
+ # later change to this query's
1732
+ # session, master-key flag, or scope raises instead of running the
1733
+ # other query's rows under a different principal. Read preference is
1734
+ # carried over but a mismatch keeps this query's value.
1735
+ # @param other [Parse::Query]
1736
+ def merge_or_scope!(other)
1737
+ merge_or_application!(other)
1738
+ theirs = other.or_effective_scope
1739
+ if theirs.empty?
1740
+ # The other query has the default authority: pin this query's own
1741
+ # authority, so the result does not depend on operand order.
1742
+ mine = or_effective_scope
1743
+ unless mine.empty?
1744
+ @_or_branch_scope = mine
1745
+ @_or_branch_app = or_application_identity
1746
+ end
1747
+ else
1748
+ mine = or_effective_scope
1749
+ if mine.empty?
1750
+ OR_SCOPE_IVARS.each do |ivar|
1751
+ instance_variable_set(:"@#{ivar}", other.instance_variable_get(:"@#{ivar}"))
1752
+ end
1753
+ adopt_or_client!(other, theirs)
1754
+ # Fail closed if the adopted state still does not reproduce the
1755
+ # other query's authority.
1756
+ unless or_effective_scope == theirs
1757
+ raise ArgumentError,
1758
+ "Cannot combine with OR a query running under #{Parse::Query.describe_or_scope(theirs)} " \
1759
+ "through its own client. Run the queries separately."
1760
+ end
1761
+ elsif mine != theirs
1762
+ raise ArgumentError,
1763
+ "Queries combined with OR must run under the same authority " \
1764
+ "(#{Parse::Query.describe_or_scope(mine)} vs #{Parse::Query.describe_or_scope(theirs)}). " \
1765
+ "Run them separately, or set the same scope on each."
1766
+ end
1767
+ @_or_branch_scope = theirs
1768
+ @_or_branch_app = or_application_identity
1769
+ end
1770
+ @read_preference ||= other.read_preference
1771
+ self
1772
+ end
1773
+ protected :merge_or_scope!
1774
+
1775
+ # @!visibility private
1776
+ # The client set on this query when it is a real Parse client other
1777
+ # than the default one, or nil.
1778
+ # @return [Parse::Client, nil]
1779
+ def or_application_client
1780
+ own = or_explicit_client
1781
+ return nil unless own.respond_to?(:application_id) && own.respond_to?(:server_url)
1782
+ own
1783
+ end
1784
+ protected :or_application_client
1785
+
1786
+ # @!visibility private
1787
+ # The application this query runs against, as [application id, server
1788
+ # URL], from its own client or the default client. nil when no client
1789
+ # can be resolved.
1790
+ # @return [Array<String>, nil]
1791
+ def or_application_identity
1792
+ target = or_application_client || begin
1793
+ self.class.client
1794
+ rescue StandardError
1795
+ nil
1796
+ end
1797
+ return nil unless target.respond_to?(:application_id) && target.respond_to?(:server_url)
1798
+ [target.application_id.to_s, target.server_url.to_s.chomp("/")]
1799
+ end
1800
+ protected :or_application_identity
1801
+
1802
+ # @!visibility private
1803
+ # Queries combined with OR must target the same application, so a
1804
+ # session token or master-key authority from one app is never sent to
1805
+ # another. An empty receiver on the default client (no constraints, no
1806
+ # authority) takes the other query's client, as it takes its authority;
1807
+ # any other mismatch raises.
1808
+ def merge_or_application!(other)
1809
+ mine = or_application_identity
1810
+ theirs = other.or_application_identity
1811
+ return if mine.nil? || theirs.nil? || mine == theirs
1812
+ their_client = other.or_application_client
1813
+ if their_client && or_application_client.nil? && @_or_branch_scope.nil? &&
1814
+ or_effective_scope.empty? && Parse::Query.match_all?(@where)
1815
+ @client = their_client
1816
+ return
1817
+ end
1818
+ raise ArgumentError,
1819
+ "Queries combined with OR must run against the same Parse application " \
1820
+ "(#{mine.first} at #{mine.last} vs #{theirs.first} at #{theirs.last}). " \
1821
+ "Run them separately on their own clients."
1822
+ end
1823
+ private :merge_or_application!
1824
+
1825
+ # @!visibility private
1826
+ # Carry `other`'s client-bound authority onto this query. A query with
1827
+ # no client of its own takes `other`'s client, which keeps its server
1828
+ # and session. A query with its own client takes the derived session
1829
+ # token (or the no-master-key flag) explicitly instead.
1830
+ def adopt_or_client!(other, theirs)
1831
+ their_client = other.or_explicit_client
1832
+ return if their_client.nil?
1833
+ if or_explicit_client.nil?
1834
+ @client = their_client
1835
+ elsif other.session_token.nil? && theirs.key?(:session)
1836
+ @session_token = theirs[:session]
1837
+ elsif theirs[:master] == false
1838
+ @use_master_key = false
1839
+ end
1840
+ end
1841
+ private :adopt_or_client!
1842
+
1843
+ # @!visibility private
1844
+ # Raise when this query's authority no longer matches the one an OR
1845
+ # branch was combined under.
1846
+ def enforce_or_branch_scope!
1847
+ return if @_or_branch_scope.nil?
1848
+ if @_or_branch_app && (app = or_application_identity) && app != @_or_branch_app
1849
+ raise ArgumentError,
1850
+ "This query was combined with OR against Parse application #{@_or_branch_app.first}; " \
1851
+ "running it against #{app.first} would send its authority to another application. " \
1852
+ "Build a new query instead."
1853
+ end
1854
+ current = or_effective_scope
1855
+ return if current == @_or_branch_scope
1856
+ raise ArgumentError,
1857
+ "This query was combined with OR under #{Parse::Query.describe_or_scope(@_or_branch_scope)}; " \
1858
+ "changing it to #{Parse::Query.describe_or_scope(current)} would run the combined " \
1859
+ "branches under a different authority. Build a new query instead."
1860
+ end
1861
+ private :enforce_or_branch_scope!
1862
+
1863
+ # @!visibility private
1864
+ # On a query combined with OR, auth kwargs passed straight to a direct
1865
+ # or Atlas terminal (`results_direct(master: true)`) must name the same
1866
+ # authority the query was pinned to, and an explicit client the same
1867
+ # application. A narrower `master: false` is allowed.
1868
+ def enforce_explicit_auth_against_or_scope!(session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil)
1869
+ return if @_or_branch_scope.nil?
1870
+ if client && @_or_branch_app && client.respond_to?(:application_id) && client.respond_to?(:server_url)
1871
+ app = [client.application_id.to_s, client.server_url.to_s.chomp("/")]
1872
+ unless app == @_or_branch_app
1873
+ raise ArgumentError,
1874
+ "This query was combined with OR against Parse application #{@_or_branch_app.first}; " \
1875
+ "the client: passed here targets #{app.first}."
1876
+ end
1877
+ end
1878
+ given = {}
1879
+ token = session_token.respond_to?(:session_token) ? session_token.session_token : session_token
1880
+ given[:session] = token unless token.nil?
1881
+ given[:user] = Parse::Query.or_scope_value(:acl_user, acl_user) unless acl_user.nil?
1882
+ given[:role] = Parse::Query.or_scope_value(:acl_role, acl_role) unless acl_role.nil?
1883
+ given[:master] = true if master && master != false
1884
+ return if given.empty? || given == @_or_branch_scope
1885
+ raise ArgumentError,
1886
+ "This query was combined with OR under #{Parse::Query.describe_or_scope(@_or_branch_scope)}; " \
1887
+ "the auth passed here (#{Parse::Query.describe_or_scope(given)}) would run it under a " \
1888
+ "different authority."
1889
+ end
1890
+ private :enforce_explicit_auth_against_or_scope!
1891
+
1892
+ # @!visibility private
1893
+ # A redacted, human-readable form of an authority Hash for errors.
1894
+ def self.describe_or_scope(scope)
1895
+ return "the default authority" if scope.empty?
1896
+ scope.map do |kind, value|
1897
+ case kind
1898
+ when :session then "a session token"
1899
+ when :user then "scoped user #{value}"
1900
+ when :role then "scoped role #{value}"
1901
+ when :master then value ? "the master key" : "no master key"
1902
+ end
1903
+ end.join(" + ")
1904
+ end
1905
+
1906
+ # @!visibility private
1907
+ # A comparable form of one auth scope value.
1908
+ def self.or_scope_value(ivar, value)
1909
+ case ivar
1910
+ when :acl_user
1911
+ value.respond_to?(:id) ? value.id : value.to_s
1912
+ when :acl_role
1913
+ name = value.respond_to?(:name) ? value.name : value.to_s
1914
+ name.to_s.delete_prefix("role:")
1915
+ else
1916
+ value
1917
+ end
1918
+ end
1919
+
1920
+ # @!visibility private
1921
+ # Drop every where constraint, so the query matches all rows.
1922
+ # @return [self]
1923
+ def match_all!
1924
+ @where = []
1925
+ @results = nil
1926
+ self
1927
+ end
1928
+ protected :match_all!
1929
+
1930
+ # @!visibility private
1931
+ # Whether a list of constraints places no restriction on the rows (an
1932
+ # empty list, or constraints that compile to nothing).
1933
+ # @param where [Array<Parse::Constraint>]
1934
+ # @return [Boolean]
1935
+ def self.match_all?(where)
1936
+ where.blank? || compile_markers(where).empty?
1937
+ end
1938
+
1367
1939
  # Add a "field is NOT between" condition — the logical negation of
1368
1940
  # `.where(field.between => value)`: `field < min OR field > max` for a
1369
1941
  # fully-bounded Range/Array, or a single one-sided comparison when the
@@ -1937,6 +2509,7 @@ module Parse
1937
2509
 
1938
2510
  # @!visibility private
1939
2511
  def _opts
2512
+ enforce_or_branch_scope!
1940
2513
  opts = {}
1941
2514
  opts[:cache] = self.cache || false
1942
2515
  # Only forward `use_master_key` when the caller actually set it.
@@ -1946,10 +2519,17 @@ module Parse
1946
2519
  # init-block comment on `@use_master_key`.
1947
2520
  opts[:use_master_key] = self.use_master_key unless self.use_master_key.nil?
1948
2521
  opts[:session_token] = self.session_token unless self.session_token.nil?
1949
- # for now, don't cache requests where we disable master_key or provide session token
1950
- # if opts[:use_master_key] == false || opts[:session_token].present?
1951
- # opts[:cache] = false
1952
- # end
2522
+ # SDK-internal metadata read (the role-graph walk used for scope
2523
+ # enforcement): keeps the master key inside `Parse.without_master_key`.
2524
+ # Set only by the SDK through an instance variable, never a public option.
2525
+ if @_metadata_master == true
2526
+ opts[:use_master_key] = true
2527
+ opts.delete(:session_token)
2528
+ opts[:metadata_master] = Parse::Client::METADATA_MASTER_REQUEST
2529
+ end
2530
+ # Session reads are kept out of the response cache by the caching
2531
+ # middleware, which sees the effective session whichever way it was
2532
+ # supplied. See Parse::Middleware::Caching.cache_session_requests.
1953
2533
  opts
1954
2534
  end
1955
2535
 
@@ -2291,7 +2871,7 @@ module Parse
2291
2871
  # @return [self]
2292
2872
  def scope_to_user(user)
2293
2873
  raise ArgumentError, "[Parse::Query] scope_to_user requires a Parse::User or User Pointer." unless user.respond_to?(:id) && user.id.is_a?(String)
2294
- @acl_user = user
2874
+ guard_or_scope_change { @acl_user = user }
2295
2875
  self
2296
2876
  end
2297
2877
 
@@ -2331,7 +2911,7 @@ module Parse
2331
2911
  # Parse::ACLScope#resolve_for_role only ever sees Parse::Role or
2332
2912
  # String. Without normalization, any String-only operation
2333
2913
  # (e.g. #start_with?, #sub) silently NoMethodErrors on Symbol.
2334
- @acl_role = role.is_a?(Symbol) ? role.to_s : role
2914
+ guard_or_scope_change { @acl_role = role.is_a?(Symbol) ? role.to_s : role }
2335
2915
  self
2336
2916
  end
2337
2917
 
@@ -2477,6 +3057,10 @@ module Parse
2477
3057
 
2478
3058
  # @!visibility private
2479
3059
  def mongo_direct_scope_kwargs
3060
+ enforce_or_branch_scope!
3061
+ # SDK-internal metadata read (see `_opts`): master mode on the direct
3062
+ # path too, whatever `Parse.without_master_key` says.
3063
+ return { master: Parse::ACLScope::METADATA_MASTER } if @_metadata_master == true
2480
3064
  if @acl_user
2481
3065
  # Pre-resolved User pointer. Hand it to Parse::ACLScope as
2482
3066
  # acl_user: so the same three-layer simulation runs (top-level
@@ -2544,9 +3128,10 @@ module Parse
2544
3128
  # @return [Boolean]
2545
3129
  # @!visibility private
2546
3130
  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?
3131
+ # Inside `Parse.without_master_key` this still answers true: the
3132
+ # resulting `master: true` is dropped by Parse::ACLScope.resolve!,
3133
+ # which runs the read in the public scope (or raises under
3134
+ # `require_session_token`) with the block-specific message.
2550
3135
  c = begin
2551
3136
  client
2552
3137
  rescue StandardError
@@ -2566,15 +3151,6 @@ module Parse
2566
3151
  Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
2567
3152
  end
2568
3153
 
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
-
2578
3154
  # Auth kwargs for the Atlas Search bridge (`#atlas_search` builder
2579
3155
  # block). Explicit `atlas_search(...)` auth kwargs win; otherwise
2580
3156
  # derive from the query's own scope (`#scope_to_user`, an explicit
@@ -2606,9 +3182,11 @@ module Parse
2606
3182
  end
2607
3183
 
2608
3184
  explicit = %i[session_token master acl_user acl_role].select { |k| options.key?(k) }
2609
- if explicit.any?
3185
+ if explicit.any? || options.key?(:client)
2610
3186
  given = explicit.to_h { |k| [k, options[k]] }
2611
- given.delete(:master) if direct_master_kwarg(given[:master]).nil?
3187
+ enforce_explicit_auth_against_or_scope!(client: options[:client], **given)
3188
+ end
3189
+ if explicit.any?
2612
3190
  return client_kwarg.merge(given)
2613
3191
  end
2614
3192
 
@@ -2617,6 +3195,7 @@ module Parse
2617
3195
 
2618
3196
  # @!visibility private
2619
3197
  def atlas_search_scope_kwargs
3198
+ enforce_or_branch_scope!
2620
3199
  if @acl_user
2621
3200
  { acl_user: @acl_user }
2622
3201
  elsif @acl_role
@@ -2625,9 +3204,9 @@ module Parse
2625
3204
  { session_token: @session_token }
2626
3205
  elsif use_master_key == true
2627
3206
  # 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 }
3207
+ # Inside `Parse.without_master_key` Parse::AtlasSearch drops it and
3208
+ # the search runs in the public scope.
3209
+ { master: true }
2631
3210
  elsif (ambient = ambient_session_token)
2632
3211
  { session_token: ambient }
2633
3212
  elsif anonymous_session_block?
@@ -2729,6 +3308,8 @@ module Parse
2729
3308
  # @note This is a read-only operation. Direct MongoDB queries cannot modify data.
2730
3309
  # @see Parse::MongoDB.configure
2731
3310
  def results_direct(raw: false, max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil, &block)
3311
+ enforce_explicit_auth_against_or_scope!(session_token: session_token, master: master,
3312
+ acl_user: acl_user, acl_role: acl_role, client: client)
2732
3313
  # `limit(0)` asks for no rows. MongoDB rejects `$limit: 0`, and
2733
3314
  # omitting the stage would return every row.
2734
3315
  return [] if @limit == 0
@@ -2770,7 +3351,6 @@ module Parse
2770
3351
  acl_user = auth[:acl_user]
2771
3352
  acl_role = auth[:acl_role]
2772
3353
  end
2773
- master = direct_master_kwarg(master)
2774
3354
 
2775
3355
  # Execute the aggregation directly on MongoDB. The pipeline was built
2776
3356
  # entirely from SDK constraint translation (no user-supplied stages),
@@ -2878,6 +3458,8 @@ module Parse
2878
3458
  # @note This is a read-only operation. Direct MongoDB queries cannot modify data.
2879
3459
  # @see Parse::MongoDB.configure
2880
3460
  def count_direct(session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil)
3461
+ enforce_explicit_auth_against_or_scope!(session_token: session_token, master: master,
3462
+ acl_user: acl_user, acl_role: acl_role, client: client)
2881
3463
  require_relative "mongodb"
2882
3464
  Parse::MongoDB.require_gem!
2883
3465
 
@@ -2916,7 +3498,6 @@ module Parse
2916
3498
  acl_user = auth[:acl_user]
2917
3499
  acl_role = auth[:acl_role]
2918
3500
  end
2919
- master = direct_master_kwarg(master)
2920
3501
 
2921
3502
  # SDK-built pipeline only — see results_direct for rationale.
2922
3503
  # ACL simulation runs inside Parse::MongoDB.aggregate when
@@ -2957,6 +3538,8 @@ module Parse
2957
3538
  def distinct_direct(field, return_pointers: false, order: nil,
2958
3539
  session_token: nil, master: nil, acl_user: nil, acl_role: nil,
2959
3540
  client: nil)
3541
+ enforce_explicit_auth_against_or_scope!(session_token: session_token, master: master,
3542
+ acl_user: acl_user, acl_role: acl_role, client: client)
2960
3543
  require_relative "mongodb"
2961
3544
  Parse::MongoDB.require_gem!
2962
3545
 
@@ -3017,7 +3600,6 @@ module Parse
3017
3600
  acl_user = auth[:acl_user]
3018
3601
  acl_role = auth[:acl_role]
3019
3602
  end
3020
- master = direct_master_kwarg(master)
3021
3603
  raw_results = Parse::MongoDB.aggregate(@table, pipeline,
3022
3604
  allow_internal_fields: true,
3023
3605
  read_preference: @read_preference,
@@ -4255,11 +4837,11 @@ module Parse
4255
4837
  lq_client = client || Parse::LiveQuery.client
4256
4838
  lq_client.subscribe(
4257
4839
  @table,
4258
- where: compile_where,
4840
+ where: compile_rest_where,
4259
4841
  fields: fields,
4260
4842
  keys: keys,
4261
4843
  watch: watch,
4262
- session_token: session_token || @session_token,
4844
+ session_token: live_query_session_token(session_token, live_query_client: lq_client),
4263
4845
  use_master_key: use_master_key,
4264
4846
  &block
4265
4847
  )
@@ -5145,7 +5727,12 @@ module Parse
5145
5727
  # Keys like ["project.name", "project.status"] define which subfields to fetch on nested objects
5146
5728
  nested_keys = Parse::Query.parse_keys_to_nested_keys(@keys) if @keys.present?
5147
5729
 
5148
- list.map { |m| Parse::Object.build(m, @table, fetched_keys: fetch_keys, nested_fetched_keys: nested_keys) }.compact
5730
+ built = list.map { |m| Parse::Object.build(m, @table, fetched_keys: fetch_keys, nested_fetched_keys: nested_keys) }.compact
5731
+ # Record session owners against the client that fetched them, so a
5732
+ # later delete of an already-gone session can still drop its owner's
5733
+ # cached identities.
5734
+ Parse::Session._remember_owners!(built, client) if @table == Parse::Model::CLASS_SESSION && defined?(Parse::Session)
5735
+ built
5149
5736
  end
5150
5737
 
5151
5738
  # Validates includes against keys and field types, printing debug warnings for:
@@ -5295,7 +5882,7 @@ module Parse
5295
5882
  q[:excludeKeys] = @exclude_keys.join(",") if encode && @exclude_keys&.any?
5296
5883
  q[:order] = @order.join(",") unless @order.empty?
5297
5884
  unless @where.empty?
5298
- q[:where] = Parse::Query.compile_where(@where)
5885
+ q[:where] = coerce_rest_pointer_ids(Parse::Query.compile_where(@where))
5299
5886
  q[:where] = q[:where].to_json if encode
5300
5887
  end
5301
5888
 
@@ -5328,6 +5915,94 @@ module Parse
5328
5915
  self.class.compile_where(@where || [])
5329
5916
  end
5330
5917
 
5918
+ # @return [Hash] the where clause as sent to Parse Server over REST or
5919
+ # LiveQuery: {#compile_where} with bare objectIds on declared pointer
5920
+ # fields rewritten into Pointer hashes, so they match the stored
5921
+ # pointer.
5922
+ def compile_rest_where
5923
+ refuse_pipeline_only!("a LiveQuery subscription or push target")
5924
+ coerce_rest_pointer_ids(compile_where)
5925
+ end
5926
+
5927
+ # @!visibility private
5928
+ # Compile this query as a subquery of another (`$inQuery`,
5929
+ # `$notInQuery`, `$select`, `$dontSelect`). Parse Server evaluates it
5930
+ # from the REST form, so a pipeline-only constraint, which that form
5931
+ # drops, is refused rather than turning the subquery into match-all.
5932
+ # @return [Hash]
5933
+ def compile_subquery
5934
+ refuse_pipeline_only!("a subquery")
5935
+ enforce_or_branch_scope!
5936
+ compile(encode: false, includeClassName: true)
5937
+ end
5938
+
5939
+ # @!visibility private
5940
+ # Raise when this query holds a constraint that only the aggregation
5941
+ # pipeline or mongo-direct can run, for a target that receives only
5942
+ # the REST where form.
5943
+ def refuse_pipeline_only!(target)
5944
+ markers = compile_markers
5945
+ return unless Parse::Query.or_branch_marker?(markers)
5946
+ names = Parse::Query.pipeline_only_constraint_names(@where)
5947
+ raise ArgumentError,
5948
+ "This query cannot be used as #{target}: " \
5949
+ "#{names.empty? ? "a pipeline-only constraint" : names.join(", ")} needs the aggregation " \
5950
+ "pipeline or mongo-direct, and Parse Server receives only the REST where form, " \
5951
+ "which would drop it."
5952
+ end
5953
+ private :refuse_pipeline_only!
5954
+
5955
+ # @!visibility private
5956
+ # The session a LiveQuery subscription built from this query must use:
5957
+ # the query's own session token or the session of its `become` client.
5958
+ # LiveQuery has no equivalent of `scope_to_user` / `scope_to_role`, so
5959
+ # those raise, and an explicit token that differs from the query's
5960
+ # authority raises too.
5961
+ #
5962
+ # A query that carries any authority must target the same Parse
5963
+ # application as the LiveQuery client, compared by application id, so
5964
+ # one app's session token is never sent to another app's LiveQuery
5965
+ # server. A query with the default authority sends no query-derived
5966
+ # credential, so it is not checked.
5967
+ # @param explicit [String, nil] a `session_token:` passed by the caller.
5968
+ # @param live_query_client [Parse::LiveQuery::Client, nil] the client
5969
+ # the subscription will be sent on.
5970
+ # @return [String, nil]
5971
+ def live_query_session_token(explicit = nil, live_query_client: nil)
5972
+ enforce_or_branch_scope!
5973
+ scope = or_effective_scope
5974
+ if scope.key?(:user) || scope.key?(:role)
5975
+ raise ArgumentError,
5976
+ "A query scoped with scope_to_user / scope_to_role cannot be subscribed to: " \
5977
+ "LiveQuery authorizes by session token only. Subscribe with that user's session instead."
5978
+ end
5979
+ explicit = explicit.session_token if explicit.respond_to?(:session_token)
5980
+ own = scope[:session]
5981
+ if explicit.is_a?(String) && !explicit.empty? && own && explicit != own
5982
+ raise ArgumentError,
5983
+ "The session_token: passed to subscribe differs from the session this query runs under."
5984
+ end
5985
+ ensure_live_query_application!(live_query_client) unless scope.empty?
5986
+ (explicit.is_a?(String) && !explicit.empty?) ? explicit : own
5987
+ end
5988
+
5989
+ # @!visibility private
5990
+ # Raise when a LiveQuery client targets a different Parse application
5991
+ # than this query, so a credential bound to one app never reaches
5992
+ # another. Compared by application id: the LiveQuery URL is a WebSocket
5993
+ # endpoint and never matches the REST server URL.
5994
+ def ensure_live_query_application!(live_query_client)
5995
+ return if live_query_client.nil? || !live_query_client.respond_to?(:application_id)
5996
+ query_app = or_application_identity&.first
5997
+ lq_app = live_query_client.application_id.to_s
5998
+ return if query_app.nil? || query_app.empty? || lq_app.empty? || query_app == lq_app
5999
+ raise ArgumentError,
6000
+ "This query runs under an authority bound to application #{query_app.inspect}, but the " \
6001
+ "LiveQuery client targets #{lq_app.inspect}. Its credentials are not sent to another " \
6002
+ "application; subscribe on a LiveQuery client for the same application."
6003
+ end
6004
+ private :ensure_live_query_application!
6005
+
5331
6006
  # @return [Hash] the un-stripped reduced where hash, including any
5332
6007
  # SDK-internal markers like `"__mongo_direct_only"` and
5333
6008
  # `"__aggregation_pipeline"`. Used by the routing layer to decide
@@ -6168,6 +6843,56 @@ module Parse
6168
6843
  end
6169
6844
  end
6170
6845
 
6846
+ # @!visibility private
6847
+ # Rewrite bare objectId strings compared to a declared pointer column
6848
+ # into Pointer hashes, for the REST where clause. Parse Server compares
6849
+ # a pointer column against its "Class$objectId" storage form, so a bare
6850
+ # objectId never matched (and `$ne` matched every row). Same rules as
6851
+ # {#coerce_bare_pointer_ids}: only declared pointers with a known target
6852
+ # class, only `$eq` / `$ne` / `$in` / `$nin` / `$all` and plain
6853
+ # equality, recursing into `$or` / `$and` / `$nor`.
6854
+ # @param where [Hash] a compiled where clause.
6855
+ # @return [Hash]
6856
+ def coerce_rest_pointer_ids(where)
6857
+ return where unless where.is_a?(Hash) && !where.empty?
6858
+ klass = table_model_class
6859
+ return where unless klass
6860
+ where.each_with_object({}) do |(field, value), out|
6861
+ name = field.to_s
6862
+ out[field] = if %w[$or $and $nor].include?(name) && value.is_a?(Array)
6863
+ value.map { |branch| coerce_rest_pointer_ids(branch) }
6864
+ elsif name.start_with?("$") || name.include?(".")
6865
+ value
6866
+ elsif (target = get_pointer_target_class_for(klass, name))
6867
+ rest_pointer_value(target, value)
6868
+ else
6869
+ value
6870
+ end
6871
+ end
6872
+ end
6873
+
6874
+ # @!visibility private
6875
+ def rest_pointer_value(target, value)
6876
+ bare = ->(v) { v.is_a?(String) && !v.empty? && !v.include?("$") }
6877
+ to_pointer = ->(v) { bare.(v) ? { "__type" => "Pointer", "className" => target, "objectId" => v } : v }
6878
+ case value
6879
+ when String
6880
+ to_pointer.(value)
6881
+ when Hash
6882
+ return value if value.key?("__type") || value.key?(:__type)
6883
+ value.each_with_object({}) do |(op, op_value), out|
6884
+ out[op] = case op.to_s
6885
+ when "$eq", "$ne" then to_pointer.(op_value)
6886
+ when "$in", "$nin", "$all"
6887
+ op_value.is_a?(Array) ? op_value.map(&to_pointer) : op_value
6888
+ else op_value
6889
+ end
6890
+ end
6891
+ else
6892
+ value
6893
+ end
6894
+ end
6895
+
6171
6896
  # Handle a constraint value that is a bare String inside `$in`/`$nin`
6172
6897
  # against a column positively identified as a pointer, when the
6173
6898
  # target class cannot be resolved (no local belongs_to AND no peer
@@ -6571,20 +7296,25 @@ module Parse
6571
7296
  raise ArgumentError, "All queries passed to Parse::Query.or must be for the same Parse class."
6572
7297
  end
6573
7298
 
6574
- # Start with an empty query for this table
7299
+ # Start with an empty query for this table, carrying the members'
7300
+ # common auth scope (raises when two members set different scopes).
6575
7301
  result = self.new(table)
7302
+ # Run against the members' application: seed the client from the
7303
+ # first member that sets one (the members are then checked to agree).
7304
+ seed = queries.lazy.map { |q| q.send(:or_application_client) }.find(&:itself)
7305
+ result.instance_variable_set(:@client, seed) if seed
7306
+ queries.each { |query| result.send(:merge_or_scope!, query) }
6576
7307
 
6577
- # Filter to only queries that have constraints
6578
- queries = queries.filter { |q| q.where.present? && !q.where.empty? }
7308
+ # Refuse pipeline-only constraints in any member first, so one is
7309
+ # never dropped or widened.
7310
+ queries.each { |query| compile_or_branch(query.where) }
7311
+
7312
+ # A query with no constraints matches every row, so an OR that
7313
+ # includes one does too.
7314
+ return result if queries.any? { |q| match_all?(q.where) }
6579
7315
 
6580
7316
  # Add each query's complete constraint set as an OR branch
6581
- queries.each do |query|
6582
- # Compile the where constraints to check if they result in empty conditions
6583
- compiled_where = Parse::Query.compile_where(query.where)
6584
- unless compiled_where.empty?
6585
- result.or_where(query.where)
6586
- end
6587
- end
7317
+ queries.each { |query| result.or_where(query.where) }
6588
7318
 
6589
7319
  result
6590
7320
  end
@@ -6631,13 +7361,19 @@ module Parse
6631
7361
 
6632
7362
  # Creates a deep copy of this query object, allowing independent modifications
6633
7363
  # @return [Parse::Query] a new query object with the same constraints
6634
- # @note The @client and @results instance variables are intentionally NOT cloned.
6635
- # The cloned query will use the default client when executed.
7364
+ # @note The @results instance variable is not cloned. A client that sets
7365
+ # the query's authority (session-bound or without a master key) is kept
7366
+ # by reference; otherwise the copy uses the default client.
6636
7367
  def clone
6637
7368
  cloned_query = Parse::Query.new(self.instance_variable_get(:@table))
6638
7369
  # Note: :client is intentionally excluded - it contains non-serializable objects
6639
7370
  # (Redis connections, Faraday connections) and should be obtained lazily
6640
- [:count, :where, :order, :keys, :exclude_keys, :includes, :limit, :skip, :cache, :use_master_key, :hint].each do |param|
7371
+ # The auth scope and read routing travel with the copy, so a cloned or
7372
+ # combined query never runs under a broader authority than the
7373
+ # original.
7374
+ [:count, :where, :order, :keys, :exclude_keys, :includes, :limit, :skip, :cache, :use_master_key, :hint,
7375
+ :session_token, :acl_user, :acl_role, :read_preference, :key, :acl_query_mongo_direct,
7376
+ :_or_branch_scope, :_or_branch_app].each do |param|
6641
7377
  if instance_variable_defined?(:"@#{param}")
6642
7378
  value = instance_variable_get(:"@#{param}")
6643
7379
  if value.is_a?(Array) || value.is_a?(Hash)
@@ -6655,6 +7391,12 @@ module Parse
6655
7391
  cloned_query.instance_variable_set(:"@#{param}", cloned_value)
6656
7392
  end
6657
7393
  end
7394
+ # A client set on the query (one from Parse::Client#become or
7395
+ # #anonymous, or a client for another application) decides where the
7396
+ # query's session token and authority are sent, so the copy keeps it.
7397
+ # Only the memoized default client is resolved lazily again.
7398
+ own_client = or_application_client
7399
+ cloned_query.instance_variable_set(:@client, own_client) if own_client
6658
7400
  cloned_query.instance_variable_set(:@results, nil)
6659
7401
  cloned_query
6660
7402
  end