parse-stack-next 5.8.0 → 5.8.1

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