parse-stack-next 5.7.6 → 5.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
|
@@ -29,6 +29,9 @@ module Parse
|
|
|
29
29
|
string: ::GraphQL::Types::String,
|
|
30
30
|
integer: ::GraphQL::Types::Int,
|
|
31
31
|
float: ::GraphQL::Types::Float,
|
|
32
|
+
# A :number property may hold fractional values, which GraphQL's
|
|
33
|
+
# 32-bit Int cannot represent, so it is exposed as Float.
|
|
34
|
+
number: ::GraphQL::Types::Float,
|
|
32
35
|
boolean: ::GraphQL::Types::Boolean,
|
|
33
36
|
date: ::GraphQL::Types::ISO8601DateTime,
|
|
34
37
|
timezone: ::GraphQL::Types::String,
|
|
@@ -374,7 +374,11 @@ module Parse
|
|
|
374
374
|
if class_name.is_a?(Parse::Query)
|
|
375
375
|
query = class_name
|
|
376
376
|
class_name = query.table
|
|
377
|
-
where = query.
|
|
377
|
+
where = query.compile_rest_where
|
|
378
|
+
# The subscription runs under the query's own session (or its
|
|
379
|
+
# `become` client's); a different explicit token raises, and so
|
|
380
|
+
# does a query bound to another application than this client.
|
|
381
|
+
session_token = query.live_query_session_token(session_token, live_query_client: self)
|
|
378
382
|
end
|
|
379
383
|
|
|
380
384
|
# Refuse server-side-JS / data-mutating operators in the `where`
|
|
@@ -1007,6 +1011,27 @@ module Parse
|
|
|
1007
1011
|
end
|
|
1008
1012
|
end
|
|
1009
1013
|
|
|
1014
|
+
# Release the latch set when this admin client first connected inside
|
|
1015
|
+
# {Parse.without_master_key}. Until this is called, every later
|
|
1016
|
+
# connect and reconnect withholds the master key. The next connect
|
|
1017
|
+
# made outside the block then sends it again, elevating every
|
|
1018
|
+
# subscription on the socket (a warning names how many).
|
|
1019
|
+
# @return [void]
|
|
1020
|
+
def allow_master_key_connection!
|
|
1021
|
+
return unless @master_key_withheld
|
|
1022
|
+
@master_key_withheld = false
|
|
1023
|
+
@master_key_latch_released = true
|
|
1024
|
+
nil
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# @return [Boolean] whether this client withholds its master key on
|
|
1028
|
+
# every connect because it first connected inside
|
|
1029
|
+
# {Parse.without_master_key}.
|
|
1030
|
+
def master_key_withheld?
|
|
1031
|
+
@master_key_withheld == true
|
|
1032
|
+
end
|
|
1033
|
+
public :allow_master_key_connection!, :master_key_withheld?
|
|
1034
|
+
|
|
1010
1035
|
# Resubscribe all pending subscriptions
|
|
1011
1036
|
def resubscribe_all
|
|
1012
1037
|
subs = @monitor.synchronize { @subscriptions.values.dup }
|
|
@@ -1031,8 +1056,32 @@ module Parse
|
|
|
1031
1056
|
# bypasses ACL/CLP/protectedFields. Sending it unconditionally
|
|
1032
1057
|
# (the pre-5.1.0 behavior) silently elevated session-token
|
|
1033
1058
|
# subscriptions the caller believed were scoped.
|
|
1034
|
-
if admin_connection?
|
|
1059
|
+
if admin_connection? && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
1060
|
+
# Inside `Parse.without_master_key` the connect frame carries no
|
|
1061
|
+
# master key, as REST requests in the block do not. The socket is
|
|
1062
|
+
# then ACL-scoped for its whole lifetime, and the choice is latched:
|
|
1063
|
+
# a later reconnect (including an explicit `connect` made outside
|
|
1064
|
+
# the block) keeps withholding it, so subscriptions created on
|
|
1065
|
+
# this socket are never silently elevated.
|
|
1066
|
+
@master_key_withheld = true
|
|
1067
|
+
@connected_as_admin = false
|
|
1068
|
+
warn_master_key_withheld_once
|
|
1069
|
+
elsif admin_connection? && @master_key_withheld
|
|
1070
|
+
@connected_as_admin = false
|
|
1071
|
+
warn_master_key_withheld_once
|
|
1072
|
+
elsif admin_connection?
|
|
1073
|
+
if @master_key_latch_released
|
|
1074
|
+
@master_key_latch_released = false
|
|
1075
|
+
pending = @monitor.synchronize { @subscriptions.size }
|
|
1076
|
+
if pending.positive?
|
|
1077
|
+
warn "[Parse::LiveQuery:SECURITY] reconnecting with the master key after " \
|
|
1078
|
+
"allow_master_key_connection!: #{pending} existing subscription(s), " \
|
|
1079
|
+
"including any created inside Parse.without_master_key, now bypass " \
|
|
1080
|
+
"ACL/CLP."
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1035
1083
|
message[:masterKey] = @master_key
|
|
1084
|
+
@connected_as_admin = true
|
|
1036
1085
|
warn_master_key_connection_once
|
|
1037
1086
|
elsif @use_master_key
|
|
1038
1087
|
# Opted into admin mode but no usable master key is present —
|
|
@@ -1045,6 +1094,16 @@ module Parse
|
|
|
1045
1094
|
send_message(message)
|
|
1046
1095
|
end
|
|
1047
1096
|
|
|
1097
|
+
# One-time warning that an admin connection was opened without its
|
|
1098
|
+
# master key because `Parse.without_master_key` was active.
|
|
1099
|
+
def warn_master_key_withheld_once
|
|
1100
|
+
return if @master_key_withheld_warning_emitted
|
|
1101
|
+
@master_key_withheld_warning_emitted = true
|
|
1102
|
+
warn "[Parse::LiveQuery] admin connection (use_master_key: true) opened " \
|
|
1103
|
+
"inside Parse.without_master_key: the connect frame carries no master " \
|
|
1104
|
+
"key, so every subscription on this connection is ACL-scoped."
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1048
1107
|
# One-time loud warning that this connection bypasses ACL/CLP for
|
|
1049
1108
|
# every subscription. Master-key LiveQuery is connection-level, so
|
|
1050
1109
|
# this is the only place the risk can be surfaced.
|
|
@@ -1063,6 +1122,17 @@ module Parse
|
|
|
1063
1122
|
# silently disagree with the connection's actual authorization.
|
|
1064
1123
|
# Both are "you think you're scoped (or elevated) but you're not."
|
|
1065
1124
|
def warn_subscription_scope_mismatch(use_master_key, session_token)
|
|
1125
|
+
if @connected_as_admin && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled? &&
|
|
1126
|
+
!@block_on_admin_warning_emitted
|
|
1127
|
+
# Parse Server fixes master-key authorization per connection at
|
|
1128
|
+
# connect time, so a socket that connected with the master key
|
|
1129
|
+
# stays elevated even for a subscription made inside the block.
|
|
1130
|
+
@block_on_admin_warning_emitted = true
|
|
1131
|
+
warn "[Parse::LiveQuery:SECURITY] subscribe inside Parse.without_master_key " \
|
|
1132
|
+
"on a connection that already connected with the master key: Parse " \
|
|
1133
|
+
"Server authorizes per connection, so this subscription still " \
|
|
1134
|
+
"bypasses ACL/CLP. Use a non-admin client for streams inside the block."
|
|
1135
|
+
end
|
|
1066
1136
|
if use_master_key && !admin_connection?
|
|
1067
1137
|
return if @per_sub_master_key_warning_emitted
|
|
1068
1138
|
@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.
|
data/lib/parse/model/acl.rb
CHANGED
|
@@ -147,6 +147,25 @@ module Parse
|
|
|
147
147
|
@delegate = owner
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# Deep-copies the permissions table so a `dup`/`clone` of an ACL never
|
|
151
|
+
# shares mutable state with the original. ActiveModel dirty tracking
|
|
152
|
+
# stores a clone of the ACL as the "before" value on the first change;
|
|
153
|
+
# with a shallow copy, later in-place edits (`apply`, `delete`,
|
|
154
|
+
# `Permission#no_read!`) also rewrote that saved value. `rollback!`
|
|
155
|
+
# then restored the edited ACL and the change history reported the old
|
|
156
|
+
# and new values as identical.
|
|
157
|
+
# @!visibility private
|
|
158
|
+
def initialize_copy(other)
|
|
159
|
+
super
|
|
160
|
+
src = other.instance_variable_get(:@permissions)
|
|
161
|
+
@permissions = nil
|
|
162
|
+
return if src.nil?
|
|
163
|
+
@permissions = {}
|
|
164
|
+
src.each do |key, perm|
|
|
165
|
+
@permissions[key] = perm.is_a?(Permission) ? adopt_permission(perm.dup) : perm
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
150
169
|
# Create a new ACL with default Public read/write permissions and any
|
|
151
170
|
# overrides from the input hash format.
|
|
152
171
|
# @param read [Boolean] the read permissions for PUBLIC (default: true)
|
|
@@ -268,6 +287,21 @@ module Parse
|
|
|
268
287
|
as_json == other_acl.as_json
|
|
269
288
|
end
|
|
270
289
|
|
|
290
|
+
# Strict equality used by Hash keys and `Array#uniq`. Unlike {#==}, a
|
|
291
|
+
# plain Hash is never `eql?` to an ACL, since the two cannot share a
|
|
292
|
+
# {#hash} value. Two ACLs granting the same privileges are `eql?` and
|
|
293
|
+
# hash alike. The hash follows the current permissions, so an ACL
|
|
294
|
+
# mutated after use as a Hash key must be rehashed like any Hash key.
|
|
295
|
+
# @return [Boolean]
|
|
296
|
+
def eql?(other_acl)
|
|
297
|
+
other_acl.is_a?(Parse::ACL) && as_json == other_acl.as_json
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @return [Integer] a hash value consistent with {#eql?}.
|
|
301
|
+
def hash
|
|
302
|
+
[Parse::ACL, as_json].hash
|
|
303
|
+
end
|
|
304
|
+
|
|
271
305
|
# Set the public read and write permissions.
|
|
272
306
|
# @param read [Boolean] the read permission state.
|
|
273
307
|
# @param write [Boolean] the write permission state.
|
|
@@ -285,17 +319,23 @@ module Parse
|
|
|
285
319
|
@delegate.acl_will_change! if @delegate.respond_to?(:acl_will_change!)
|
|
286
320
|
end
|
|
287
321
|
|
|
288
|
-
# Removes
|
|
322
|
+
# Removes every permission for a user, role or the public entry.
|
|
289
323
|
# @overload delete(object)
|
|
290
|
-
# @param object [Parse::User] the user to revoke permissions.
|
|
324
|
+
# @param object [Parse::User, Parse::Pointer] the user to revoke permissions.
|
|
325
|
+
# @overload delete(role)
|
|
326
|
+
# @param role [Parse::Role] the role to revoke permissions.
|
|
291
327
|
# @overload delete(id)
|
|
292
|
-
# @param id [String]
|
|
328
|
+
# @param id [String, Symbol] a user objectId, a role key (`"role:Admin"`),
|
|
329
|
+
# a role name whose `role:` entry exists, or `:public` / `"*"`.
|
|
330
|
+
# @return [Parse::ACL::Permission, nil] the removed permission, or nil
|
|
331
|
+
# when there was no matching entry.
|
|
293
332
|
def delete(id)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
333
|
+
key = normalize_permission_key(id)
|
|
334
|
+
return nil unless key.present? && permissions.has_key?(key)
|
|
335
|
+
will_change!
|
|
336
|
+
perm = permissions.delete(key)
|
|
337
|
+
perm.acl = nil if perm.is_a?(Permission) && perm.acl.equal?(self)
|
|
338
|
+
perm
|
|
299
339
|
end
|
|
300
340
|
|
|
301
341
|
# Apply a new permission with a given objectId, tag or :public.
|
|
@@ -331,7 +371,7 @@ module Parse
|
|
|
331
371
|
if permission.is_a?(ACL::Permission)
|
|
332
372
|
if permissions[id.to_s] != permission
|
|
333
373
|
will_change! # dirty track
|
|
334
|
-
permissions[id.to_s] = permission
|
|
374
|
+
permissions[id.to_s] = adopt_permission(permission)
|
|
335
375
|
end
|
|
336
376
|
end
|
|
337
377
|
|
|
@@ -351,6 +391,10 @@ module Parse
|
|
|
351
391
|
# @param write [Boolean] the write permission.
|
|
352
392
|
def apply_role(name, read = nil, write = nil)
|
|
353
393
|
name = name.name if name.is_a?(Parse::Role)
|
|
394
|
+
name = name.to_s
|
|
395
|
+
# Accept an already-prefixed key so "role:Admin" does not become
|
|
396
|
+
# "role:role:Admin".
|
|
397
|
+
name = name.delete_prefix("role:") if name.start_with?("role:")
|
|
354
398
|
apply("role:#{name}", read, write)
|
|
355
399
|
end
|
|
356
400
|
|
|
@@ -822,6 +866,17 @@ module Parse
|
|
|
822
866
|
|
|
823
867
|
private
|
|
824
868
|
|
|
869
|
+
# Points a Permission at this ACL so its own mutators can mark the ACL
|
|
870
|
+
# (and the owning object) dirty. A Permission already owned by a
|
|
871
|
+
# different ACL is copied first, so one ACL's edit never reaches another.
|
|
872
|
+
# @return [Permission]
|
|
873
|
+
def adopt_permission(perm)
|
|
874
|
+
owner = perm.acl
|
|
875
|
+
perm = perm.dup if owner && !owner.equal?(self)
|
|
876
|
+
perm.acl = self
|
|
877
|
+
perm
|
|
878
|
+
end
|
|
879
|
+
|
|
825
880
|
# Normalizes a user or role input to the appropriate permission key format.
|
|
826
881
|
# @param user_or_role [String, Parse::User, Parse::Role] the input to normalize
|
|
827
882
|
# @return [String, nil] the normalized key or nil if invalid
|
|
@@ -885,6 +940,27 @@ module Parse
|
|
|
885
940
|
# @return [Boolean] whether this permission is allowed.
|
|
886
941
|
attr_reader :write
|
|
887
942
|
|
|
943
|
+
# @!visibility private
|
|
944
|
+
# The ACL this permission belongs to. Its mutators notify this ACL so
|
|
945
|
+
# the change is dirty tracked on the owning object.
|
|
946
|
+
attr_accessor :acl
|
|
947
|
+
|
|
948
|
+
# Converts a permission flag to a strict boolean. Only `true`, the
|
|
949
|
+
# strings "true" and "1" (any case, surrounding whitespace ignored) and
|
|
950
|
+
# non-zero numbers grant. Everything else, including the string
|
|
951
|
+
# "false", denies.
|
|
952
|
+
# @!visibility private
|
|
953
|
+
# @return [Boolean]
|
|
954
|
+
def self.grant?(value)
|
|
955
|
+
case value
|
|
956
|
+
when true then true
|
|
957
|
+
when false, nil then false
|
|
958
|
+
when String then %w[true 1].include?(value.strip.downcase)
|
|
959
|
+
when Numeric then !value.zero?
|
|
960
|
+
else value.present?
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
|
|
888
964
|
# Create a new permission with the given read and write privileges.
|
|
889
965
|
# @overload new(read = nil, write = nil)
|
|
890
966
|
# @param read [Boolean] whether reading is allowed.
|
|
@@ -899,20 +975,34 @@ module Parse
|
|
|
899
975
|
def initialize(r_perm = nil, w_perm = nil)
|
|
900
976
|
if r_perm.is_a?(Hash)
|
|
901
977
|
r_perm = r_perm.symbolize_keys
|
|
902
|
-
@read = r_perm[:read]
|
|
903
|
-
@write = r_perm[:write]
|
|
978
|
+
@read = self.class.grant?(r_perm[:read])
|
|
979
|
+
@write = self.class.grant?(r_perm[:write])
|
|
904
980
|
else
|
|
905
|
-
@read =
|
|
906
|
-
@write =
|
|
981
|
+
@read = self.class.grant?(r_perm)
|
|
982
|
+
@write = self.class.grant?(w_perm)
|
|
907
983
|
end
|
|
908
984
|
end
|
|
909
985
|
|
|
986
|
+
# A copy is detached from the original's ACL; the copying ACL adopts it.
|
|
987
|
+
# @!visibility private
|
|
988
|
+
def initialize_copy(other)
|
|
989
|
+
super
|
|
990
|
+
@acl = nil
|
|
991
|
+
end
|
|
992
|
+
|
|
910
993
|
# @return [Boolean] whether two permission instances have the same permissions.
|
|
911
994
|
def ==(per)
|
|
912
995
|
return false unless per.is_a?(self.class)
|
|
913
996
|
@read == per.read && @write == per.write
|
|
914
997
|
end
|
|
915
998
|
|
|
999
|
+
alias_method :eql?, :==
|
|
1000
|
+
|
|
1001
|
+
# @return [Integer] a hash value consistent with {#eql?}.
|
|
1002
|
+
def hash
|
|
1003
|
+
[self.class, @read, @write].hash
|
|
1004
|
+
end
|
|
1005
|
+
|
|
916
1006
|
# @return [Hash] A Parse-compatible ACL-hash. Omission or false on a
|
|
917
1007
|
# priviledge means don't include it
|
|
918
1008
|
def as_json(*args)
|
|
@@ -940,19 +1030,27 @@ module Parse
|
|
|
940
1030
|
@read.present? || @write.present?
|
|
941
1031
|
end
|
|
942
1032
|
|
|
943
|
-
# Sets the *read* value of the permission. Defaults to true.
|
|
944
|
-
#
|
|
1033
|
+
# Sets the *read* value of the permission. Defaults to true. When the
|
|
1034
|
+
# permission belongs to an ACL, a real change marks that ACL (and its
|
|
1035
|
+
# owning object) dirty so the change is sent on the next save.
|
|
945
1036
|
# @version 1.7.2
|
|
946
|
-
# @return [
|
|
1037
|
+
# @return [Boolean] the new read value.
|
|
947
1038
|
def read!(value = true)
|
|
1039
|
+
value = self.class.grant?(value)
|
|
1040
|
+
return value if value == @read
|
|
1041
|
+
@acl&.will_change!
|
|
948
1042
|
@read = value
|
|
949
1043
|
end
|
|
950
1044
|
|
|
951
|
-
# Sets the *write* value of the permission. Defaults to true.
|
|
952
|
-
#
|
|
1045
|
+
# Sets the *write* value of the permission. Defaults to true. When the
|
|
1046
|
+
# permission belongs to an ACL, a real change marks that ACL (and its
|
|
1047
|
+
# owning object) dirty so the change is sent on the next save.
|
|
953
1048
|
# @version 1.7.2
|
|
954
|
-
# @return [
|
|
1049
|
+
# @return [Boolean] the new write value.
|
|
955
1050
|
def write!(value = true)
|
|
1051
|
+
value = self.class.grant?(value)
|
|
1052
|
+
return value if value == @write
|
|
1053
|
+
@acl&.will_change!
|
|
956
1054
|
@write = value
|
|
957
1055
|
end
|
|
958
1056
|
|
|
@@ -960,14 +1058,14 @@ module Parse
|
|
|
960
1058
|
# @version 1.7.2
|
|
961
1059
|
# @return [void]
|
|
962
1060
|
def no_read!
|
|
963
|
-
|
|
1061
|
+
read!(false)
|
|
964
1062
|
end
|
|
965
1063
|
|
|
966
1064
|
# Sets the *write* value of the permission to false.
|
|
967
1065
|
# @version 1.7.2
|
|
968
1066
|
# @return [void]
|
|
969
1067
|
def no_write!
|
|
970
|
-
|
|
1068
|
+
write!(false)
|
|
971
1069
|
end
|
|
972
1070
|
end
|
|
973
1071
|
end
|
|
@@ -183,6 +183,7 @@ module Parse
|
|
|
183
183
|
self.fields.merge!(key => :pointer, parse_field => :pointer)
|
|
184
184
|
# Mapping between local attribute name and the remote column name
|
|
185
185
|
self.field_map.merge!(key => parse_field)
|
|
186
|
+
Parse::Model.model_registry_changed!
|
|
186
187
|
|
|
187
188
|
# Agent metadata: a belongs_to pointer can carry a semantic description
|
|
188
189
|
# (and per-value enum descriptions) just like a `property` can. This
|
|
@@ -229,8 +230,10 @@ module Parse
|
|
|
229
230
|
# hash, lets try to build a Pointer of that type.
|
|
230
231
|
|
|
231
232
|
if val.is_a?(Hash) && (val["__type"] == "Pointer" || val["__type"] == "Object")
|
|
232
|
-
# Get nested fetched keys for this field if available
|
|
233
|
-
|
|
233
|
+
# Get nested fetched keys for this field if available. Query
|
|
234
|
+
# keys are recorded under the remote column name, which differs
|
|
235
|
+
# from the local name for multi-word fields.
|
|
236
|
+
nested_keys = nested_keys_for(association_key) || nested_keys_for(parse_field)
|
|
234
237
|
# Always trust the declared klassName — never the className the
|
|
235
238
|
# server (or attacker-controlled mass assignment) supplied. This
|
|
236
239
|
# prevents type confusion where a pointer to a different class
|
|
@@ -272,21 +275,40 @@ module Parse
|
|
|
272
275
|
end
|
|
273
276
|
|
|
274
277
|
# We only support pointers, either by object or by transforming a hash.
|
|
278
|
+
# Assignment from application code (track == true) also accepts an
|
|
279
|
+
# objectId String, which becomes a pointer of the declared class,
|
|
280
|
+
# and refuses an object or pointer of another class. A pointer
|
|
281
|
+
# hash is wire data: its className is ignored in favor of the
|
|
282
|
+
# declared class, as before.
|
|
275
283
|
define_method(set_attribute_method) do |val, track = true|
|
|
276
284
|
if val == Parse::Properties::DELETE_OP
|
|
277
285
|
val = nil
|
|
278
286
|
elsif val.is_a?(Hash) && (val["__type"] == "Pointer" || val["__type"] == "Object")
|
|
279
287
|
# Get nested fetched keys for this field if available
|
|
280
|
-
nested_keys = nested_keys_for(key)
|
|
288
|
+
nested_keys = nested_keys_for(key) || nested_keys_for(parse_field)
|
|
281
289
|
# Always trust declared klassName over incoming hash className.
|
|
282
290
|
incoming_class = val[Parse::Model::KEY_CLASS_NAME]
|
|
283
291
|
if incoming_class && !Parse::Model.same_parse_class?(incoming_class, klassName)
|
|
284
292
|
warn "[#{self.class}] belongs_to :#{key} expected className=#{klassName.inspect}, ignoring incoming className=#{incoming_class.inspect}"
|
|
285
293
|
end
|
|
286
294
|
val = Parse::Object.build val, klassName, fetched_keys: nested_keys
|
|
295
|
+
elsif track == true && val.is_a?(String)
|
|
296
|
+
# A blank String (an empty form select) clears the pointer.
|
|
297
|
+
val = val.blank? ? nil : Parse::Object.build({ Parse::Model::TYPE_FIELD => Parse::Model::TYPE_POINTER,
|
|
298
|
+
Parse::Model::KEY_CLASS_NAME => klassName,
|
|
299
|
+
Parse::Model::OBJECT_ID => val }, klassName)
|
|
287
300
|
end
|
|
288
301
|
|
|
289
302
|
if track == true
|
|
303
|
+
unless val.nil? || val.is_a?(Parse::Pointer)
|
|
304
|
+
raise ArgumentError, "#{self.class}##{key} expects a #{klassName} object, pointer or objectId, got #{val.class}."
|
|
305
|
+
end
|
|
306
|
+
# The class is checked when the declared class is a registered
|
|
307
|
+
# model, matching has_many arrays.
|
|
308
|
+
if val.is_a?(Parse::Pointer) && Parse::Model.find_class(klassName) &&
|
|
309
|
+
!Parse::Model.same_parse_class?(val.parse_class, klassName)
|
|
310
|
+
raise ArgumentError, "#{self.class}##{key} expects a #{klassName} object, got #{val.parse_class}."
|
|
311
|
+
end
|
|
290
312
|
prepare_for_dirty_tracking!(key)
|
|
291
313
|
send will_change_method unless val == instance_variable_get(ivar)
|
|
292
314
|
else
|
|
@@ -67,6 +67,25 @@ module Parse
|
|
|
67
67
|
@parse_class = parse_class
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Copies (`dup` and `clone`) get their own backing array and their own
|
|
71
|
+
# dirty-tracking state. ActiveModel records a property's previous value
|
|
72
|
+
# by cloning it; with a shared array the "previous" proxy changed along
|
|
73
|
+
# with the live one, so change history showed the new value twice and
|
|
74
|
+
# `rollback!` restored nothing.
|
|
75
|
+
# @!visibility private
|
|
76
|
+
def initialize_copy(other)
|
|
77
|
+
super
|
|
78
|
+
@collection = other.instance_variable_get(:@collection).dup
|
|
79
|
+
# Pending relation operations (RelationCollectionProxy) are copied too.
|
|
80
|
+
%i[@additions @removals].each do |ivar|
|
|
81
|
+
next unless instance_variable_defined?(ivar)
|
|
82
|
+
value = instance_variable_get(ivar)
|
|
83
|
+
instance_variable_set(ivar, value.dup) if value.is_a?(Array)
|
|
84
|
+
end
|
|
85
|
+
@mutations_from_database = nil
|
|
86
|
+
@mutations_before_last_save = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
70
89
|
# true if the collection has been loaded
|
|
71
90
|
def loaded?
|
|
72
91
|
@loaded
|
|
@@ -81,10 +100,12 @@ module Parse
|
|
|
81
100
|
params.nil? ? @delegate.send(method) : @delegate.send(method, params)
|
|
82
101
|
end
|
|
83
102
|
|
|
84
|
-
# Reset the state of the collection.
|
|
103
|
+
# Reset the state of the collection. The items are dropped and the
|
|
104
|
+
# collection is marked as not loaded. This is not a change to the
|
|
105
|
+
# field, so it is not dirty tracked; use {#clear} to empty the field.
|
|
85
106
|
def reset!
|
|
86
107
|
@loaded = false
|
|
87
|
-
clear
|
|
108
|
+
@collection.clear
|
|
88
109
|
end
|
|
89
110
|
|
|
90
111
|
# @return [Boolean] true if two collection proxies have similar items.
|
|
@@ -102,9 +123,25 @@ module Parse
|
|
|
102
123
|
collection #force reload
|
|
103
124
|
end
|
|
104
125
|
|
|
105
|
-
#
|
|
126
|
+
# Remove all items from the collection. The field is marked as changed,
|
|
127
|
+
# so the next save stores the empty array.
|
|
128
|
+
# @return [Array] the (now empty) collection.
|
|
106
129
|
def clear
|
|
130
|
+
notify_will_change!
|
|
107
131
|
@collection.clear
|
|
132
|
+
@loaded = true
|
|
133
|
+
@collection
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Replace the contents of the collection with a new set of items, as
|
|
137
|
+
# Array#replace does. The field is marked as changed.
|
|
138
|
+
# @param items [Array] the new contents.
|
|
139
|
+
# @return [self]
|
|
140
|
+
def replace(items)
|
|
141
|
+
notify_will_change!
|
|
142
|
+
@collection = Array(items.is_a?(Parse::CollectionProxy) ? items.to_a : items).dup
|
|
143
|
+
@loaded = true
|
|
144
|
+
self
|
|
108
145
|
end
|
|
109
146
|
|
|
110
147
|
# @return [Array]
|
|
@@ -233,36 +270,60 @@ module Parse
|
|
|
233
270
|
|
|
234
271
|
alias_method :delete, :remove
|
|
235
272
|
|
|
236
|
-
# Atomically adds all items
|
|
237
|
-
#
|
|
238
|
-
#
|
|
273
|
+
# Atomically adds all items to the array field. The request is sent
|
|
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
|
|
278
|
+
# an owner that has not been saved yet there is nothing to update on
|
|
279
|
+
# the server, so the items are added locally as a normal change and
|
|
280
|
+
# sent with the next save.
|
|
281
|
+
# @param items [Array] items to add
|
|
239
282
|
# @note Parse objects are automatically converted to pointer format
|
|
283
|
+
# @return [Boolean] whether the operation succeeded.
|
|
240
284
|
# @see #add_unique!
|
|
241
285
|
def add!(*items)
|
|
286
|
+
items = items.flatten
|
|
242
287
|
return false unless @delegate.respond_to?(:op_add!)
|
|
243
|
-
|
|
244
|
-
|
|
288
|
+
return true if items.empty?
|
|
289
|
+
return add(*items) && true if unsaved_delegate?
|
|
290
|
+
apply_atomic_op(:op_add!, items) do |list, objs|
|
|
291
|
+
list + objs
|
|
292
|
+
end
|
|
245
293
|
end
|
|
246
294
|
|
|
247
|
-
# Atomically adds
|
|
248
|
-
#
|
|
295
|
+
# Atomically adds the items that are not already part of the array
|
|
296
|
+
# field. The request is sent directly to the Parse backend. The local
|
|
297
|
+
# collection is updated as in {#add!}.
|
|
249
298
|
# @param items [Array] items to uniquely add
|
|
250
299
|
# @note Parse objects are automatically converted to pointer format
|
|
300
|
+
# @return [Boolean] whether the operation succeeded.
|
|
251
301
|
# @see #add!
|
|
252
302
|
def add_unique!(*items)
|
|
303
|
+
items = items.flatten
|
|
253
304
|
return false unless @delegate.respond_to?(:op_add_unique!)
|
|
254
|
-
|
|
255
|
-
|
|
305
|
+
return true if items.empty?
|
|
306
|
+
return add_unique(*items) && true if unsaved_delegate?
|
|
307
|
+
apply_atomic_op(:op_add_unique!, items) do |list, objs|
|
|
308
|
+
objs.each { |o| list << o unless list.include?(o) }
|
|
309
|
+
list
|
|
310
|
+
end
|
|
256
311
|
end
|
|
257
312
|
|
|
258
|
-
# Atomically
|
|
259
|
-
# directly to the Parse backend.
|
|
313
|
+
# Atomically removes the items from the array field. The request is
|
|
314
|
+
# sent directly to the Parse backend. The local collection is updated
|
|
315
|
+
# as in {#add!}.
|
|
260
316
|
# @param items [Array] items to remove
|
|
261
317
|
# @note Parse objects are automatically converted to pointer format
|
|
318
|
+
# @return [Boolean] whether the operation succeeded.
|
|
262
319
|
def remove!(*items)
|
|
320
|
+
items = items.flatten
|
|
263
321
|
return false unless @delegate.respond_to?(:op_remove!)
|
|
264
|
-
|
|
265
|
-
|
|
322
|
+
return true if items.empty?
|
|
323
|
+
return remove(*items) && true if unsaved_delegate?
|
|
324
|
+
apply_atomic_op(:op_remove!, items) do |list, objs|
|
|
325
|
+
list.reject { |x| objs.include?(x) }
|
|
326
|
+
end
|
|
266
327
|
end
|
|
267
328
|
|
|
268
329
|
# Atomically deletes all items in the array, and marks the field as `undefined` directly
|
|
@@ -276,8 +337,15 @@ module Parse
|
|
|
276
337
|
end
|
|
277
338
|
|
|
278
339
|
# Locally restores previous attributes (not from the persistent store)
|
|
340
|
+
# and clears the dirty tracking of the collection. The values are
|
|
341
|
+
# restored directly: going through the public writers would report a
|
|
342
|
+
# new change to the owner while undoing one.
|
|
279
343
|
def rollback!
|
|
280
|
-
|
|
344
|
+
changed.each do |attr|
|
|
345
|
+
instance_variable_set(:"@#{attr}", attribute_was(attr))
|
|
346
|
+
end
|
|
347
|
+
clear_changes_information
|
|
348
|
+
@collection
|
|
281
349
|
end
|
|
282
350
|
|
|
283
351
|
# clears all dirty tracked information.
|
|
@@ -416,6 +484,67 @@ module Parse
|
|
|
416
484
|
|
|
417
485
|
private
|
|
418
486
|
|
|
487
|
+
# @return [Boolean] true when the owner has no objectId yet, so an
|
|
488
|
+
# atomic server operation has nothing to apply to.
|
|
489
|
+
def unsaved_delegate?
|
|
490
|
+
@delegate.respond_to?(:id) && @delegate.id.blank?
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# @return [Boolean] whether the owner reports this field as changed.
|
|
494
|
+
def delegate_field_dirty?
|
|
495
|
+
return false unless @delegate.respond_to?(:changed)
|
|
496
|
+
@delegate.changed.include?(@key.to_s)
|
|
497
|
+
rescue StandardError
|
|
498
|
+
false
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Run an atomic operation on the owner and, when it succeeds, apply the
|
|
502
|
+
# same change to the local collection without marking the field dirty.
|
|
503
|
+
# The block receives a copy of the current items and the operation's
|
|
504
|
+
# items and returns the new contents.
|
|
505
|
+
# @return [Boolean] whether the operation succeeded.
|
|
506
|
+
def apply_atomic_op(op, items)
|
|
507
|
+
return true if items.empty?
|
|
508
|
+
success = @delegate.send(op, @key, items_to_pointers(items))
|
|
509
|
+
return false unless success
|
|
510
|
+
# Read the owner's dirty state before touching the items, so a change
|
|
511
|
+
# that was already pending is still sent by the next save.
|
|
512
|
+
was_dirty = delegate_field_dirty?
|
|
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.
|
|
518
|
+
server = was_dirty ? nil : server_array_after_op
|
|
519
|
+
@collection = server || yield(collection.to_a.dup, items)
|
|
520
|
+
@loaded = true
|
|
521
|
+
unless was_dirty
|
|
522
|
+
# Plain array properties detect in-place edits by comparing against
|
|
523
|
+
# a snapshot; take a new one so this update is not seen as an edit.
|
|
524
|
+
if @delegate.respond_to?(:_rebaseline_mutable_value!, true)
|
|
525
|
+
@delegate.send(:_rebaseline_mutable_value!, @key)
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
true
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# The field's new array from the delegate's last atomic operation, when
|
|
532
|
+
# the server returned it.
|
|
533
|
+
# @return [Array, nil]
|
|
534
|
+
def server_array_after_op
|
|
535
|
+
return nil unless @delegate.respond_to?(:_last_operation_value, true)
|
|
536
|
+
value = @delegate.send(:_last_operation_value, @key)
|
|
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)
|
|
546
|
+
end
|
|
547
|
+
|
|
419
548
|
# Convert items to pointer format for atomic operations.
|
|
420
549
|
# Parse objects/pointers are converted to pointer hashes, other items pass through.
|
|
421
550
|
# @param items [Array] items to convert
|