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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +630 -0
- data/README.md +31 -19
- data/docs/caching.md +90 -5
- data/docs/mcp_guide.md +22 -5
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +94 -9
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +521 -70
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +50 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +263 -11
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +173 -33
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +142 -11
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +307 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +41 -5
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +43 -2
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +665 -41
- data/lib/parse/query.rb +802 -60
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -0
- data/lib/parse/vector_search/hybrid.rb +78 -16
- data/lib/parse/vector_search.rb +215 -7
- data/lib/parse/webhooks/payload.rb +82 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +329 -13
- metadata +1 -1
|
@@ -158,8 +158,11 @@ module Parse
|
|
|
158
158
|
use_master_key: NOT_PROVIDED, auto_connect: nil, auto_reconnect: nil)
|
|
159
159
|
cfg = config
|
|
160
160
|
|
|
161
|
-
# Use provided values or fall back to configuration/environment
|
|
162
|
-
|
|
161
|
+
# Use provided values or fall back to configuration/environment. An
|
|
162
|
+
# explicit or configured URL is validated here; a derived one is
|
|
163
|
+
# validated by derive_websocket_url.
|
|
164
|
+
explicit_url = url || cfg.url
|
|
165
|
+
@url = explicit_url ? validate_websocket_url!(explicit_url) : derive_websocket_url
|
|
163
166
|
@application_id = application_id || cfg.application_id ||
|
|
164
167
|
parse_client_value(:application_id)
|
|
165
168
|
@client_key = client_key || cfg.client_key ||
|
|
@@ -374,7 +377,11 @@ module Parse
|
|
|
374
377
|
if class_name.is_a?(Parse::Query)
|
|
375
378
|
query = class_name
|
|
376
379
|
class_name = query.table
|
|
377
|
-
where = query.
|
|
380
|
+
where = query.compile_rest_where
|
|
381
|
+
# The subscription runs under the query's own session (or its
|
|
382
|
+
# `become` client's); a different explicit token raises, and so
|
|
383
|
+
# does a query bound to another application than this client.
|
|
384
|
+
session_token = query.live_query_session_token(session_token, live_query_client: self)
|
|
378
385
|
end
|
|
379
386
|
|
|
380
387
|
# Refuse server-side-JS / data-mutating operators in the `where`
|
|
@@ -505,12 +512,70 @@ module Parse
|
|
|
505
512
|
nil
|
|
506
513
|
end
|
|
507
514
|
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
#
|
|
511
|
-
#
|
|
512
|
-
#
|
|
513
|
-
|
|
515
|
+
# Normalize and validate an explicit LiveQuery URL, returning the URL
|
|
516
|
+
# to connect to.
|
|
517
|
+
#
|
|
518
|
+
# - The scheme is compared case-insensitively and the URL is stripped.
|
|
519
|
+
# - `https://` is mapped to `wss://` and `http://` to `ws://`, with a
|
|
520
|
+
# one-time deprecation warning. The connector used to open them as a
|
|
521
|
+
# plaintext socket, so the `https` mapping is strictly safer.
|
|
522
|
+
# - Plaintext `ws://` (including a mapped `http://`) is refused on a
|
|
523
|
+
# host that is not this machine ({Parse::Client.loopback_host?})
|
|
524
|
+
# unless `allow_insecure` is set, in which case it warns.
|
|
525
|
+
# - Any other scheme raises `ArgumentError`.
|
|
526
|
+
#
|
|
527
|
+
# @param url [String]
|
|
528
|
+
# @param allow_insecure [Boolean]
|
|
529
|
+
# @return [String]
|
|
530
|
+
# @raise [ArgumentError]
|
|
531
|
+
def self.normalize_url(url, allow_insecure: false)
|
|
532
|
+
scheme = Parse::Client.url_scheme(url)
|
|
533
|
+
mapped = { "http" => "ws", "https" => "wss" }[scheme]
|
|
534
|
+
if mapped
|
|
535
|
+
warn_http_scheme_once(scheme, mapped)
|
|
536
|
+
scheme = mapped
|
|
537
|
+
end
|
|
538
|
+
unless %w[ws wss].include?(scheme)
|
|
539
|
+
raise ArgumentError,
|
|
540
|
+
"[Parse::LiveQuery] LiveQuery URL #{url.to_s.inspect} must use wss:// " \
|
|
541
|
+
"(or ws:// on a loopback host). Got scheme #{scheme.inspect}."
|
|
542
|
+
end
|
|
543
|
+
normalized = url.to_s.strip.sub(/\A[a-z][a-z0-9+.\-]*:/i, "#{scheme}:")
|
|
544
|
+
host = Parse::Client.url_host(normalized).to_s
|
|
545
|
+
return normalized if scheme == "wss" || Parse::Client.loopback_host?(host)
|
|
546
|
+
|
|
547
|
+
if allow_insecure
|
|
548
|
+
warn "[Parse::LiveQuery] Using insecure ws:// URL for #{host} " \
|
|
549
|
+
"(allow_insecure is enabled). Master key and session tokens " \
|
|
550
|
+
"will traverse a cleartext socket."
|
|
551
|
+
normalized
|
|
552
|
+
else
|
|
553
|
+
raise ArgumentError,
|
|
554
|
+
"[Parse::LiveQuery] Refusing insecure ws:// LiveQuery URL #{url.to_s.inspect}. " \
|
|
555
|
+
"The connect frame carries the master key and any session token in " \
|
|
556
|
+
"plaintext on this socket. Use wss:// for routable hosts, or set " \
|
|
557
|
+
"`Parse::LiveQuery.configure { |c| c.allow_insecure = true }` (or " \
|
|
558
|
+
"`live_query: { allow_insecure: true }`) for local development."
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# @!visibility private
|
|
563
|
+
def self.warn_http_scheme_once(scheme, mapped)
|
|
564
|
+
@warned_http_schemes ||= {}
|
|
565
|
+
return if @warned_http_schemes[scheme]
|
|
566
|
+
@warned_http_schemes[scheme] = true
|
|
567
|
+
warn "[Parse::LiveQuery] DEPRECATION: an #{scheme}:// LiveQuery URL is " \
|
|
568
|
+
"treated as #{mapped}://. Configure the #{mapped}:// URL directly; " \
|
|
569
|
+
"a later release will refuse #{scheme}://."
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Validate an explicit or configured LiveQuery URL the same way a
|
|
573
|
+
# derived one is, and return the URL to connect to. See
|
|
574
|
+
# {Parse::LiveQuery::Client.normalize_url}.
|
|
575
|
+
# @return [String]
|
|
576
|
+
def validate_websocket_url!(url)
|
|
577
|
+
self.class.normalize_url(url, allow_insecure: config.allow_insecure)
|
|
578
|
+
end
|
|
514
579
|
|
|
515
580
|
# Derive WebSocket URL from Parse server URL. Refuses to
|
|
516
581
|
# synthesize a `ws://` URL from an `http://` server URL on any
|
|
@@ -526,7 +591,7 @@ module Parse
|
|
|
526
591
|
scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
527
592
|
host = uri.host.to_s
|
|
528
593
|
|
|
529
|
-
if scheme == "ws" && !
|
|
594
|
+
if scheme == "ws" && !Parse::Client.loopback_host?(host)
|
|
530
595
|
if config.allow_insecure
|
|
531
596
|
warn "[Parse::LiveQuery] Deriving insecure ws:// URL for #{host} " \
|
|
532
597
|
"(allow_insecure is enabled). Master key and session tokens " \
|
|
@@ -1007,6 +1072,27 @@ module Parse
|
|
|
1007
1072
|
end
|
|
1008
1073
|
end
|
|
1009
1074
|
|
|
1075
|
+
# Release the latch set when this admin client first connected inside
|
|
1076
|
+
# {Parse.without_master_key}. Until this is called, every later
|
|
1077
|
+
# connect and reconnect withholds the master key. The next connect
|
|
1078
|
+
# made outside the block then sends it again, elevating every
|
|
1079
|
+
# subscription on the socket (a warning names how many).
|
|
1080
|
+
# @return [void]
|
|
1081
|
+
def allow_master_key_connection!
|
|
1082
|
+
return unless @master_key_withheld
|
|
1083
|
+
@master_key_withheld = false
|
|
1084
|
+
@master_key_latch_released = true
|
|
1085
|
+
nil
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
# @return [Boolean] whether this client withholds its master key on
|
|
1089
|
+
# every connect because it first connected inside
|
|
1090
|
+
# {Parse.without_master_key}.
|
|
1091
|
+
def master_key_withheld?
|
|
1092
|
+
@master_key_withheld == true
|
|
1093
|
+
end
|
|
1094
|
+
public :allow_master_key_connection!, :master_key_withheld?
|
|
1095
|
+
|
|
1010
1096
|
# Resubscribe all pending subscriptions
|
|
1011
1097
|
def resubscribe_all
|
|
1012
1098
|
subs = @monitor.synchronize { @subscriptions.values.dup }
|
|
@@ -1031,8 +1117,32 @@ module Parse
|
|
|
1031
1117
|
# bypasses ACL/CLP/protectedFields. Sending it unconditionally
|
|
1032
1118
|
# (the pre-5.1.0 behavior) silently elevated session-token
|
|
1033
1119
|
# subscriptions the caller believed were scoped.
|
|
1034
|
-
if admin_connection?
|
|
1120
|
+
if admin_connection? && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
1121
|
+
# Inside `Parse.without_master_key` the connect frame carries no
|
|
1122
|
+
# master key, as REST requests in the block do not. The socket is
|
|
1123
|
+
# then ACL-scoped for its whole lifetime, and the choice is latched:
|
|
1124
|
+
# a later reconnect (including an explicit `connect` made outside
|
|
1125
|
+
# the block) keeps withholding it, so subscriptions created on
|
|
1126
|
+
# this socket are never silently elevated.
|
|
1127
|
+
@master_key_withheld = true
|
|
1128
|
+
@connected_as_admin = false
|
|
1129
|
+
warn_master_key_withheld_once
|
|
1130
|
+
elsif admin_connection? && @master_key_withheld
|
|
1131
|
+
@connected_as_admin = false
|
|
1132
|
+
warn_master_key_withheld_once
|
|
1133
|
+
elsif admin_connection?
|
|
1134
|
+
if @master_key_latch_released
|
|
1135
|
+
@master_key_latch_released = false
|
|
1136
|
+
pending = @monitor.synchronize { @subscriptions.size }
|
|
1137
|
+
if pending.positive?
|
|
1138
|
+
warn "[Parse::LiveQuery:SECURITY] reconnecting with the master key after " \
|
|
1139
|
+
"allow_master_key_connection!: #{pending} existing subscription(s), " \
|
|
1140
|
+
"including any created inside Parse.without_master_key, now bypass " \
|
|
1141
|
+
"ACL/CLP."
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1035
1144
|
message[:masterKey] = @master_key
|
|
1145
|
+
@connected_as_admin = true
|
|
1036
1146
|
warn_master_key_connection_once
|
|
1037
1147
|
elsif @use_master_key
|
|
1038
1148
|
# Opted into admin mode but no usable master key is present —
|
|
@@ -1045,6 +1155,16 @@ module Parse
|
|
|
1045
1155
|
send_message(message)
|
|
1046
1156
|
end
|
|
1047
1157
|
|
|
1158
|
+
# One-time warning that an admin connection was opened without its
|
|
1159
|
+
# master key because `Parse.without_master_key` was active.
|
|
1160
|
+
def warn_master_key_withheld_once
|
|
1161
|
+
return if @master_key_withheld_warning_emitted
|
|
1162
|
+
@master_key_withheld_warning_emitted = true
|
|
1163
|
+
warn "[Parse::LiveQuery] admin connection (use_master_key: true) opened " \
|
|
1164
|
+
"inside Parse.without_master_key: the connect frame carries no master " \
|
|
1165
|
+
"key, so every subscription on this connection is ACL-scoped."
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1048
1168
|
# One-time loud warning that this connection bypasses ACL/CLP for
|
|
1049
1169
|
# every subscription. Master-key LiveQuery is connection-level, so
|
|
1050
1170
|
# this is the only place the risk can be surfaced.
|
|
@@ -1063,6 +1183,17 @@ module Parse
|
|
|
1063
1183
|
# silently disagree with the connection's actual authorization.
|
|
1064
1184
|
# Both are "you think you're scoped (or elevated) but you're not."
|
|
1065
1185
|
def warn_subscription_scope_mismatch(use_master_key, session_token)
|
|
1186
|
+
if @connected_as_admin && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled? &&
|
|
1187
|
+
!@block_on_admin_warning_emitted
|
|
1188
|
+
# Parse Server fixes master-key authorization per connection at
|
|
1189
|
+
# connect time, so a socket that connected with the master key
|
|
1190
|
+
# stays elevated even for a subscription made inside the block.
|
|
1191
|
+
@block_on_admin_warning_emitted = true
|
|
1192
|
+
warn "[Parse::LiveQuery:SECURITY] subscribe inside Parse.without_master_key " \
|
|
1193
|
+
"on a connection that already connected with the master key: Parse " \
|
|
1194
|
+
"Server authorizes per connection, so this subscription still " \
|
|
1195
|
+
"bypasses ACL/CLP. Use a non-admin client for streams inside the block."
|
|
1196
|
+
end
|
|
1066
1197
|
if use_master_key && !admin_connection?
|
|
1067
1198
|
return if @per_sub_master_key_warning_emitted
|
|
1068
1199
|
@per_sub_master_key_warning_emitted = true
|
data/lib/parse/lock_backend.rb
CHANGED
|
@@ -164,9 +164,10 @@ module Parse
|
|
|
164
164
|
# a holder whose lease expired and was re-acquired by someone else
|
|
165
165
|
# can never delete the new holder's key. Falls back to a
|
|
166
166
|
# best-effort GET-then-DEL for raw-Moneta stores, where the
|
|
167
|
-
# worst-case cross-holder-delete race is bounded by the
|
|
168
|
-
#
|
|
169
|
-
#
|
|
167
|
+
# worst-case cross-holder-delete race is bounded by the lease TTL.
|
|
168
|
+
# `Parse::Lock` clamps `ttl:` to 30s and the `first_or_create!`
|
|
169
|
+
# create-lock to 300s. This is a documented residual risk for the
|
|
170
|
+
# non-Redis path.
|
|
170
171
|
#
|
|
171
172
|
# @param store [Object] Moneta-shaped store.
|
|
172
173
|
# @param key [String] cache key.
|
|
@@ -271,9 +271,10 @@ module Parse
|
|
|
271
271
|
alias_method :delete, :remove
|
|
272
272
|
|
|
273
273
|
# Atomically adds all items to the array field. The request is sent
|
|
274
|
-
# directly to the Parse backend. On success the local collection
|
|
275
|
-
#
|
|
276
|
-
#
|
|
274
|
+
# directly to the Parse backend. On success the local collection takes
|
|
275
|
+
# the array the server returned (or appends the items when the reply
|
|
276
|
+
# does not include the field) without marking the field as changed, so
|
|
277
|
+
# a later save does not overwrite the server array. On
|
|
277
278
|
# an owner that has not been saved yet there is nothing to update on
|
|
278
279
|
# the server, so the items are added locally as a normal change and
|
|
279
280
|
# sent with the next save.
|
|
@@ -509,11 +510,11 @@ module Parse
|
|
|
509
510
|
# Read the owner's dirty state before touching the items, so a change
|
|
510
511
|
# that was already pending is still sent by the next save.
|
|
511
512
|
was_dirty = delegate_field_dirty?
|
|
512
|
-
# A clean
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
513
|
+
# A clean array adopts the array the server returned, so a local copy
|
|
514
|
+
# that was already out of date is corrected. With unsaved local edits
|
|
515
|
+
# pending, adopting it would discard them, so the operation is applied
|
|
516
|
+
# locally instead (as it is for a reply without the field) and the
|
|
517
|
+
# pending edits are still sent on save.
|
|
517
518
|
server = was_dirty ? nil : server_array_after_op
|
|
518
519
|
@collection = server || yield(collection.to_a.dup, items)
|
|
519
520
|
@loaded = true
|
|
@@ -528,13 +529,20 @@ module Parse
|
|
|
528
529
|
end
|
|
529
530
|
|
|
530
531
|
# The field's new array from the delegate's last atomic operation, when
|
|
531
|
-
#
|
|
532
|
+
# the server returned it.
|
|
532
533
|
# @return [Array, nil]
|
|
533
534
|
def server_array_after_op
|
|
534
|
-
return nil unless instance_of?(Parse::CollectionProxy)
|
|
535
535
|
return nil unless @delegate.respond_to?(:_last_operation_value, true)
|
|
536
536
|
value = @delegate.send(:_last_operation_value, @key)
|
|
537
|
-
value.is_a?(Array) ?
|
|
537
|
+
value.is_a?(Array) ? adopt_server_items(value) : nil
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# The local items for an array the server returned, or nil to apply the
|
|
541
|
+
# operation locally instead.
|
|
542
|
+
# @param value [Array] the field's array from the server's reply.
|
|
543
|
+
# @return [Array, nil]
|
|
544
|
+
def adopt_server_items(value)
|
|
545
|
+
Parse::Properties.deep_copy_value(value)
|
|
538
546
|
end
|
|
539
547
|
|
|
540
548
|
# Convert items to pointer format for atomic operations.
|
|
@@ -555,6 +555,17 @@ module Parse
|
|
|
555
555
|
warn "[#{self.class}] has_many :#{key} expected className=#{klassName.inspect}, ignoring incoming className=#{val[Parse::Model::KEY_CLASS_NAME].inspect}"
|
|
556
556
|
end
|
|
557
557
|
if val.is_a?(Hash) && val["__type"] == "Relation"
|
|
558
|
+
current = instance_variable_get(ivar)
|
|
559
|
+
# A server descriptor carries no membership. When the current
|
|
560
|
+
# proxy holds staged additions or removals (a fetch of a record
|
|
561
|
+
# with unsaved relation changes), keep it so they are not lost.
|
|
562
|
+
if track != true && current.is_a?(Parse::RelationCollectionProxy) &&
|
|
563
|
+
current.staged_changes?
|
|
564
|
+
# The loaded list predates this fetch. Unload it so the next
|
|
565
|
+
# read queries the server and re-applies the staged changes.
|
|
566
|
+
current.reset! if current.loaded?
|
|
567
|
+
return current
|
|
568
|
+
end
|
|
558
569
|
relation_objects = val["objects"] || []
|
|
559
570
|
val = Parse::RelationCollectionProxy.new relation_objects, delegate: self, key: key, parse_class: klassName
|
|
560
571
|
elsif val.is_a?(Hash) && val["__op"] == "AddRelation" && val["objects"].present?
|
|
@@ -190,6 +190,42 @@ module Parse
|
|
|
190
190
|
end
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
# The server returns the field's array as pointer hashes. Each entry
|
|
194
|
+
# becomes an object of the declared class, reusing the local object with
|
|
195
|
+
# the same id so fetched data is kept. The array is adopted only when
|
|
196
|
+
# the declared class is a registered model and every entry is a pointer
|
|
197
|
+
# to that class; anything else (another class, a bare string, a value
|
|
198
|
+
# that is not a pointer) makes the operation apply locally instead,
|
|
199
|
+
# rather than relabeling the entry as the declared class.
|
|
200
|
+
# @return [Array<Parse::Pointer>, nil]
|
|
201
|
+
def adopt_server_items(value)
|
|
202
|
+
return nil if @parse_class.blank? || Parse::Model.find_class(@parse_class).nil?
|
|
203
|
+
local_by_id = {}
|
|
204
|
+
collection.to_a.each do |item|
|
|
205
|
+
id = item.respond_to?(:id) ? item.id : nil
|
|
206
|
+
local_by_id[id] ||= item if id.present?
|
|
207
|
+
end
|
|
208
|
+
value.map do |entry|
|
|
209
|
+
class_name, object_id = server_pointer_parts(entry)
|
|
210
|
+
return nil unless object_id.is_a?(String) && object_id.present?
|
|
211
|
+
return nil unless Parse::Model.same_parse_class?(class_name, @parse_class)
|
|
212
|
+
local_by_id[object_id] || typecast_item(entry) || (return nil)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [Array(String, String), nil] the className and objectId of a
|
|
217
|
+
# pointer entry from a server reply, or nil when it is not a pointer.
|
|
218
|
+
def server_pointer_parts(entry)
|
|
219
|
+
case entry
|
|
220
|
+
when Parse::Pointer
|
|
221
|
+
[entry.parse_class, entry.id]
|
|
222
|
+
when Hash
|
|
223
|
+
type = entry["__type"] || entry[:__type]
|
|
224
|
+
return nil unless %w[Pointer Object].include?(type.to_s)
|
|
225
|
+
[entry["className"] || entry[:className], entry["objectId"] || entry[:objectId]]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
193
229
|
# @return [Parse::Pointer, nil] the item as a Parse object, or nil.
|
|
194
230
|
def typecast_item(item)
|
|
195
231
|
case item
|
|
@@ -24,18 +24,19 @@ module Parse
|
|
|
24
24
|
# get matching items within the relation collection.
|
|
25
25
|
#
|
|
26
26
|
# When creating a Relation proxy, all the delegate methods defined in the superclasses
|
|
27
|
-
# need to be implemented, in addition to
|
|
28
|
-
# _relation_query and _commit_relation_updates . :'key'_relation_query should return a
|
|
27
|
+
# need to be implemented, in addition to :'key'_relation_query, which should return a
|
|
29
28
|
# Parse::Query object that is properly tied to the foreign table class related to this object column.
|
|
30
29
|
# Example, if an Artist has many Song objects, then the query to be returned by this method
|
|
31
30
|
# should be a Parse::Query for the class 'Song'.
|
|
32
31
|
# Because relation changes are separate from object changes, you can call save on a
|
|
33
|
-
# relation collection to
|
|
34
|
-
#
|
|
35
|
-
# through :'key'_commit_relation_updates message. The delegate is also in charge of
|
|
36
|
-
# clearing out the change information for the collection if saved successfully.
|
|
32
|
+
# relation collection to send only its staged add and remove operations, through the
|
|
33
|
+
# owner's atomic relation operations.
|
|
37
34
|
# @see PointerCollectionProxy
|
|
38
35
|
class RelationCollectionProxy < PointerCollectionProxy
|
|
36
|
+
# @!visibility private
|
|
37
|
+
# Default for {#save}'s `session:`: keep the owner's session as it is.
|
|
38
|
+
SESSION_UNSET = Object.new.freeze
|
|
39
|
+
|
|
39
40
|
define_attribute_methods :additions, :removals
|
|
40
41
|
# @!attribute [r] removals
|
|
41
42
|
# The objects that have been newly removed to this collection
|
|
@@ -200,7 +201,7 @@ module Parse
|
|
|
200
201
|
return false unless @delegate.respond_to?(:op_add_relation!)
|
|
201
202
|
items = typecast_items(items)
|
|
202
203
|
return true if items.empty?
|
|
203
|
-
return add(*items) && true unless
|
|
204
|
+
return add(*items) && true unless owner_persisted?
|
|
204
205
|
return false unless @delegate.send(:op_add_relation!, @key, items.parse_pointers)
|
|
205
206
|
items.each do |item|
|
|
206
207
|
@removals.delete(item)
|
|
@@ -223,7 +224,7 @@ module Parse
|
|
|
223
224
|
return false unless @delegate.respond_to?(:op_remove_relation!)
|
|
224
225
|
items = typecast_items(items, strict: false)
|
|
225
226
|
return true if items.empty?
|
|
226
|
-
return remove(*items) && true unless
|
|
227
|
+
return remove(*items) && true unless owner_persisted?
|
|
227
228
|
return false unless @delegate.send(:op_remove_relation!, @key, items.parse_pointers)
|
|
228
229
|
items.each do |item|
|
|
229
230
|
@additions.delete(item)
|
|
@@ -251,11 +252,66 @@ module Parse
|
|
|
251
252
|
@collection
|
|
252
253
|
end
|
|
253
254
|
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
# @return [Boolean] true when additions or removals are staged and not
|
|
256
|
+
# yet sent.
|
|
257
|
+
def staged_changes?
|
|
258
|
+
@additions.any? || @removals.any?
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Drops the staged additions and removals and clears the dirty tracking,
|
|
262
|
+
# without sending anything. When operations were staged, the items are
|
|
263
|
+
# reloaded from the server on the next access, since the loaded list
|
|
264
|
+
# included them.
|
|
265
|
+
def clear_changes!
|
|
266
|
+
staged = staged_changes?
|
|
267
|
+
@additions = []
|
|
268
|
+
@removals = []
|
|
269
|
+
super
|
|
270
|
+
reset! if staged
|
|
271
|
+
@collection
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Sends the staged additions and removals of this relation without saving
|
|
275
|
+
# the rest of the owner. The owner must already be saved: an unsaved owner
|
|
276
|
+
# sends its relation additions when it is created.
|
|
277
|
+
#
|
|
278
|
+
# Removals and additions go out as two requests, removals first (each
|
|
279
|
+
# through the owner's atomic relation operation, so {Parse::Role} cache
|
|
280
|
+
# invalidation still runs). The two are not atomic: if the removals
|
|
281
|
+
# succeed and the additions fail, the removals stay applied, they are no
|
|
282
|
+
# longer staged, and the additions stay staged for a retry. An owner
|
|
283
|
+
# save sends relation changes the same way.
|
|
284
|
+
#
|
|
285
|
+
# The requests use the owner's session token from its last
|
|
286
|
+
# `save(session:)`, or the client's default credentials. Pass `session:`
|
|
287
|
+
# to send them as a specific user (a session token String or a
|
|
288
|
+
# {Parse::User}), or `session: nil` to send them with no session token.
|
|
289
|
+
# @param session [String, Parse::User, nil] the session to send the
|
|
290
|
+
# operations with, when given.
|
|
291
|
+
# @return [Boolean] whether the staged operations were applied. False
|
|
292
|
+
# when the owner is not saved yet or a request failed; operations that
|
|
293
|
+
# did not succeed stay staged.
|
|
294
|
+
def save(session: SESSION_UNSET)
|
|
295
|
+
return true if @additions.empty? && @removals.empty?
|
|
296
|
+
return false unless owner_persisted?
|
|
297
|
+
return false unless @delegate.respond_to?(:op_add_relation!) && @delegate.respond_to?(:op_remove_relation!)
|
|
298
|
+
applied = with_owner_session(session) do
|
|
299
|
+
if @removals.any?
|
|
300
|
+
next false unless @delegate.send(:op_remove_relation!, @key, @removals.parse_pointers)
|
|
301
|
+
@removals = []
|
|
302
|
+
end
|
|
303
|
+
if @additions.any?
|
|
304
|
+
next false unless @delegate.send(:op_add_relation!, @key, @additions.parse_pointers)
|
|
305
|
+
@additions = []
|
|
306
|
+
end
|
|
307
|
+
true
|
|
308
|
+
end
|
|
309
|
+
return false unless applied
|
|
310
|
+
clear_changes_information
|
|
311
|
+
if @delegate.respond_to?(:clear_attribute_changes, true)
|
|
312
|
+
@delegate.send(:clear_attribute_changes, [@key.to_s])
|
|
258
313
|
end
|
|
314
|
+
true
|
|
259
315
|
end
|
|
260
316
|
|
|
261
317
|
# @see #add
|
|
@@ -273,6 +329,35 @@ module Parse
|
|
|
273
329
|
!(@delegate.respond_to?(:id) && @delegate.id.blank?)
|
|
274
330
|
end
|
|
275
331
|
|
|
332
|
+
# @return [Boolean] whether the owner exists on the server, so a write to
|
|
333
|
+
# its relation can be sent. Follows {Parse::Object#persisted?}: an
|
|
334
|
+
# id-only handle to a stored record counts, but an objectId assigned
|
|
335
|
+
# client-side during a create (`parse_reference precompute:`) does not
|
|
336
|
+
# until the create returns, and neither does a destroyed owner.
|
|
337
|
+
def owner_persisted?
|
|
338
|
+
return false unless owner_saved?
|
|
339
|
+
!@delegate.respond_to?(:persisted?) || @delegate.persisted?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Run the block with the owner's session token set to `session`, then
|
|
343
|
+
# restore the owner's previous token. {SESSION_UNSET} runs it unchanged.
|
|
344
|
+
def with_owner_session(session)
|
|
345
|
+
return yield if session.equal?(SESSION_UNSET)
|
|
346
|
+
token = @delegate.send(:_validate_session_token!, session, :save)
|
|
347
|
+
had = @delegate.instance_variable_defined?(:@_session_token)
|
|
348
|
+
previous = @delegate.instance_variable_get(:@_session_token)
|
|
349
|
+
@delegate.instance_variable_set(:@_session_token, token)
|
|
350
|
+
begin
|
|
351
|
+
yield
|
|
352
|
+
ensure
|
|
353
|
+
if had
|
|
354
|
+
@delegate.instance_variable_set(:@_session_token, previous)
|
|
355
|
+
else
|
|
356
|
+
@delegate.remove_instance_variable(:@_session_token)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
276
361
|
# ActiveModel reads the current value when a change starts. Read the
|
|
277
362
|
# items directly: going through {#collection} would query the whole
|
|
278
363
|
# relation just to stage an add or remove.
|
|
@@ -279,6 +279,12 @@ module Parse
|
|
|
279
279
|
# query/constraints) — none of those have a caller scope to
|
|
280
280
|
# forward. The fast path is opt-in for performance-conscious
|
|
281
281
|
# callers that can supply explicit authorization.
|
|
282
|
+
# @note Inside {Parse.without_master_key}, the Parse Server walk (no
|
|
283
|
+
# `master:` or `as:`) still reads `_Role` with the master key as SDK
|
|
284
|
+
# metadata, so it returns the full role-name closure of `user`, not
|
|
285
|
+
# only the publicly readable roles. The block guards against
|
|
286
|
+
# accidental master-key use; it does not scope this lookup. Pass
|
|
287
|
+
# `as:` for a scope-checked answer.
|
|
282
288
|
# @example
|
|
283
289
|
# names = Parse::Role.all_for_user(user, master: true) # admin/analytics
|
|
284
290
|
# names = Parse::Role.all_for_user(user, as: current_user) # scope-checked
|
|
@@ -423,6 +429,20 @@ module Parse
|
|
|
423
429
|
end
|
|
424
430
|
|
|
425
431
|
def role_query_all(constraints, client: nil)
|
|
432
|
+
# Inside `Parse.without_master_key` the role graph is still read with
|
|
433
|
+
# the master key. It is metadata the SDK needs to enforce a scope, as
|
|
434
|
+
# Parse Server reads it itself; read without the key it returned only
|
|
435
|
+
# publicly readable roles, and that short closure was then cached for
|
|
436
|
+
# the user and served to every caller until it expired.
|
|
437
|
+
if Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
438
|
+
query = Parse::Role.query(constraints.reverse_merge(limit: :max))
|
|
439
|
+
query.client = client if client
|
|
440
|
+
# A master-key read: keep it out of the shared response cache.
|
|
441
|
+
query.cache = false
|
|
442
|
+
query.instance_variable_set(:@_metadata_master, true)
|
|
443
|
+
return query.results
|
|
444
|
+
end
|
|
445
|
+
|
|
426
446
|
# No explicit client means the historical path, unchanged. This is not
|
|
427
447
|
# only for compatibility: `Parse::Role.all` is what callers and tests
|
|
428
448
|
# observe and stub, and routing around it when nothing asked us to
|
|
@@ -957,6 +977,9 @@ module Parse
|
|
|
957
977
|
# another's role names.
|
|
958
978
|
# @param strict [Boolean] re-raise role-query failures rather than returning
|
|
959
979
|
# a partial parent closure.
|
|
980
|
+
# @note Inside {Parse.without_master_key} the walk still reads `_Role`
|
|
981
|
+
# with the master key as SDK metadata, so it returns the full parent
|
|
982
|
+
# closure, not only publicly readable roles.
|
|
960
983
|
def all_parent_role_names(max_depth: 10, client: nil, strict: false)
|
|
961
984
|
Parse::Role.expand_inheritance_upward(
|
|
962
985
|
[self], max_depth: max_depth, client: client, strict: strict,
|