parse-stack-next 5.7.6 → 5.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +830 -0
- data/README.md +14 -4
- 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/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +317 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- 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_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 +93 -1
- 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/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -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 +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- 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/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- 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 +21 -3
- 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 +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- 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 +1100 -134
- 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 +72 -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 +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "atlas_search/index_manager"
|
|
|
8
8
|
require_relative "atlas_search/search_builder"
|
|
9
9
|
require_relative "atlas_search/result"
|
|
10
10
|
require_relative "atlas_search/session"
|
|
11
|
+
require_relative "atlas_search/protected_paths"
|
|
11
12
|
|
|
12
13
|
module Parse
|
|
13
14
|
# Atlas Search module for MongoDB Atlas full-text search capabilities.
|
|
@@ -363,6 +364,7 @@ module Parse
|
|
|
363
364
|
|
|
364
365
|
index_name = options[:index] || @default_index
|
|
365
366
|
fields = normalize_fields(options[:fields])
|
|
367
|
+
assert_search_fields_allowed!(fields, protected_fields, resolution)
|
|
366
368
|
limit = options[:limit] || 100
|
|
367
369
|
skip_val = options[:skip] || 0
|
|
368
370
|
|
|
@@ -436,6 +438,15 @@ module Parse
|
|
|
436
438
|
collection_name, resolution.permission_strings,
|
|
437
439
|
)
|
|
438
440
|
assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
|
|
441
|
+
# The caller built this stage, so any operator path, sort key, or
|
|
442
|
+
# highlight inside it may name a protected field. Walk it and
|
|
443
|
+
# refuse before it runs: a protected field that decides matches
|
|
444
|
+
# or ranking leaks its value even though the output strips it.
|
|
445
|
+
ProtectedPaths.assert_search_stage_allowed!(
|
|
446
|
+
search_stage, protected_fields, resolution,
|
|
447
|
+
collection_name: collection_name,
|
|
448
|
+
method_name: "Parse::AtlasSearch.search_with_stage",
|
|
449
|
+
)
|
|
439
450
|
|
|
440
451
|
search_pipeline!(
|
|
441
452
|
collection_name, search_stage,
|
|
@@ -512,7 +523,8 @@ module Parse
|
|
|
512
523
|
collection_name, resolution.permission_strings,
|
|
513
524
|
)
|
|
514
525
|
field_str = field.to_s
|
|
515
|
-
if
|
|
526
|
+
if ProtectedPaths.enforce?(resolution, protected_fields) &&
|
|
527
|
+
ProtectedPaths.touches?(field_str, protected_fields)
|
|
516
528
|
raise Parse::CLPScope::Denied.new(
|
|
517
529
|
collection_name, :find,
|
|
518
530
|
"Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \
|
|
@@ -689,6 +701,21 @@ module Parse
|
|
|
689
701
|
end
|
|
690
702
|
end
|
|
691
703
|
|
|
704
|
+
# A non-master caller (the public fallback when no auth kwargs are
|
|
705
|
+
# passed) must not facet on, or text-match against, a protected
|
|
706
|
+
# field: bucket values and counts would reveal it directly, and a
|
|
707
|
+
# wildcard operator lets it decide which rows are counted.
|
|
708
|
+
unless resolution.master?
|
|
709
|
+
facet_protected = Parse::CLPScope.protected_fields_for(
|
|
710
|
+
collection_name, resolution.permission_strings,
|
|
711
|
+
)
|
|
712
|
+
ProtectedPaths.assert_search_stage_allowed!(
|
|
713
|
+
search_meta_stage, facet_protected, resolution,
|
|
714
|
+
collection_name: collection_name,
|
|
715
|
+
method_name: "Parse::AtlasSearch.faceted_search",
|
|
716
|
+
)
|
|
717
|
+
end
|
|
718
|
+
|
|
692
719
|
# Execute facet query. $searchMeta MUST be the only / first
|
|
693
720
|
# stage of its pipeline — Atlas rejects anything prepended.
|
|
694
721
|
# Bypass Parse::MongoDB.aggregate (which would prepend a
|
|
@@ -797,6 +824,14 @@ module Parse
|
|
|
797
824
|
pipeline << { "$match" => mongo_filter }
|
|
798
825
|
end
|
|
799
826
|
|
|
827
|
+
# Sorting on a protected field orders rows by its value, which is
|
|
828
|
+
# the same oracle as matching on it.
|
|
829
|
+
if sort.is_a?(Hash) && !sort.empty?
|
|
830
|
+
ProtectedPaths.assert_paths_allowed!(
|
|
831
|
+
sort.keys.map(&:to_s), protected_fields, resolution,
|
|
832
|
+
collection_name: collection_name, what: "sort key",
|
|
833
|
+
)
|
|
834
|
+
end
|
|
800
835
|
pipeline << { "$sort" => (sort || { "_score" => -1 }) }
|
|
801
836
|
pipeline << { "$skip" => skip } if skip.to_i > 0
|
|
802
837
|
pipeline << { "$limit" => limit }
|
|
@@ -980,13 +1015,12 @@ module Parse
|
|
|
980
1015
|
# does inline (we can't reuse that path because of the $search-
|
|
981
1016
|
# at-stage-0 invariant).
|
|
982
1017
|
def assert_clp_find!(collection_name, resolution)
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
end
|
|
1018
|
+
# Same CLP branch evaluation as Parse::MongoDB.aggregate (public,
|
|
1019
|
+
# user, and role grants first; then pointerFields / readUserFields).
|
|
1020
|
+
# Raises Parse::CLPScope::Denied when the scope cannot find at all.
|
|
1021
|
+
Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
|
|
1022
|
+
label: "Atlas Search")
|
|
1023
|
+
nil
|
|
990
1024
|
end
|
|
991
1025
|
|
|
992
1026
|
# Resolve and return pointerFields for `find` on the collection.
|
|
@@ -994,17 +1028,13 @@ module Parse
|
|
|
994
1028
|
# current scope has no user_id (acl_role-only / public agents).
|
|
995
1029
|
# Returns nil when master-mode or no pointerFields entry exists.
|
|
996
1030
|
def resolve_pointer_fields!(collection_name, resolution)
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
"but the current Atlas Search scope has no user_id.",
|
|
1005
|
-
)
|
|
1006
|
-
end
|
|
1007
|
-
pointer_fields
|
|
1031
|
+
# nil when a public, user, or role grant already permits every row
|
|
1032
|
+
# (Parse Server ignores pointer permissions then); otherwise the
|
|
1033
|
+
# pointerFields plus readUserFields the rows must match. The older
|
|
1034
|
+
# permits? / pointer_fields_for pair missed readUserFields entirely
|
|
1035
|
+
# and over-restricted a public grant that also listed pointerFields.
|
|
1036
|
+
Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
|
|
1037
|
+
label: "Atlas Search")
|
|
1008
1038
|
end
|
|
1009
1039
|
|
|
1010
1040
|
# ATLAS-4: refuse `highlight_field:` when the field is in the
|
|
@@ -1016,8 +1046,8 @@ module Parse
|
|
|
1016
1046
|
return if highlight_field.nil?
|
|
1017
1047
|
return if resolution.nil? || resolution.master?
|
|
1018
1048
|
return if protected_fields.nil? || protected_fields.empty?
|
|
1019
|
-
|
|
1020
|
-
|
|
1049
|
+
return unless ProtectedPaths.touches?(highlight_field, protected_fields)
|
|
1050
|
+
path = highlight_field.is_a?(String) || highlight_field.is_a?(Symbol) ? highlight_field.to_s : highlight_field.inspect
|
|
1021
1051
|
raise Parse::CLPScope::Denied.new(
|
|
1022
1052
|
nil, :find,
|
|
1023
1053
|
"Parse::AtlasSearch.search refused: highlight_field '#{path}' is in " \
|
|
@@ -1026,6 +1056,35 @@ module Parse
|
|
|
1026
1056
|
)
|
|
1027
1057
|
end
|
|
1028
1058
|
|
|
1059
|
+
# Refuse a scoped text search whose paths include a protected field,
|
|
1060
|
+
# or that searches every field (no `fields:`) while the scope has
|
|
1061
|
+
# protected fields. Stripping a protected field from the RESULT does
|
|
1062
|
+
# not stop it from deciding which documents MATCH and how they RANK,
|
|
1063
|
+
# so a caller could test guesses against its value. Master scopes and
|
|
1064
|
+
# scopes with nothing protected are unaffected.
|
|
1065
|
+
#
|
|
1066
|
+
# @raise [Parse::CLPScope::Denied]
|
|
1067
|
+
def assert_search_fields_allowed!(fields, protected_fields, resolution)
|
|
1068
|
+
return if resolution.nil? || resolution.master?
|
|
1069
|
+
return if protected_fields.nil? || protected_fields.empty?
|
|
1070
|
+
if fields.nil? || fields.empty?
|
|
1071
|
+
raise Parse::CLPScope::Denied.new(
|
|
1072
|
+
nil, :find,
|
|
1073
|
+
"Parse::AtlasSearch.search refused: a search over every field would " \
|
|
1074
|
+
"match on protectedFields for the current scope; pass fields: with " \
|
|
1075
|
+
"the fields to search.",
|
|
1076
|
+
)
|
|
1077
|
+
end
|
|
1078
|
+
list = fields.is_a?(Array) ? fields : [fields]
|
|
1079
|
+
hit = list.find { |f| ProtectedPaths.touches?(f, protected_fields) }
|
|
1080
|
+
return unless hit
|
|
1081
|
+
raise Parse::CLPScope::Denied.new(
|
|
1082
|
+
nil, :find,
|
|
1083
|
+
"Parse::AtlasSearch.search refused: field '#{hit}' is in protectedFields " \
|
|
1084
|
+
"for the current scope; matching on it would reveal its value.",
|
|
1085
|
+
)
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1029
1088
|
# Drop `_highlights` entries whose `path` matches a
|
|
1030
1089
|
# protectedFields entry. Defense-in-depth complement to
|
|
1031
1090
|
# {.assert_highlight_field_allowed!} — that gate refuses the
|
|
@@ -1035,13 +1094,14 @@ module Parse
|
|
|
1035
1094
|
def strip_protected_highlights!(documents, protected_fields)
|
|
1036
1095
|
return if documents.nil? || documents.empty?
|
|
1037
1096
|
return if protected_fields.nil? || protected_fields.empty?
|
|
1038
|
-
protected_set = protected_fields.to_set
|
|
1039
1097
|
documents.each do |doc|
|
|
1040
1098
|
next unless doc.is_a?(Hash)
|
|
1041
1099
|
highlights = doc["_highlights"]
|
|
1042
1100
|
next unless highlights.is_a?(Array)
|
|
1043
1101
|
doc["_highlights"] = highlights.reject do |h|
|
|
1044
|
-
|
|
1102
|
+
# A highlight entry with no usable path is dropped too: it
|
|
1103
|
+
# cannot be shown to address an unprotected field.
|
|
1104
|
+
h.is_a?(Hash) && ProtectedPaths.touches?(h["path"] || h[:path], protected_fields)
|
|
1045
1105
|
end
|
|
1046
1106
|
end
|
|
1047
1107
|
end
|
|
@@ -1172,6 +1232,18 @@ module Parse
|
|
|
1172
1232
|
Parse::PipelineSecurity.refuse_protected_field_references!(
|
|
1173
1233
|
[{ "$match" => filter }], collection_name, resolution,
|
|
1174
1234
|
)
|
|
1235
|
+
# Predicate KEYS decide matches too: `{ "ssn" => /^1/ }` is the
|
|
1236
|
+
# same oracle as the `$expr` form above. Refuse any key (top
|
|
1237
|
+
# level or nested under $and/$or/$nor/$not, dotted or `_p_`
|
|
1238
|
+
# storage form) that touches a protected field.
|
|
1239
|
+
unless resolution.master?
|
|
1240
|
+
protected_fields = Parse::CLPScope.protected_fields_for(
|
|
1241
|
+
collection_name, resolution.permission_strings,
|
|
1242
|
+
)
|
|
1243
|
+
ProtectedPaths.assert_filter_allowed!(
|
|
1244
|
+
filter, protected_fields, resolution, collection_name: collection_name,
|
|
1245
|
+
)
|
|
1246
|
+
end
|
|
1175
1247
|
end
|
|
1176
1248
|
|
|
1177
1249
|
filter
|
data/lib/parse/authorization.rb
CHANGED
|
@@ -97,6 +97,19 @@ module Parse
|
|
|
97
97
|
@mutex.synchronize { @data.delete(key) }
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
# Drop every entry whose value equals `value`. Lets the identity plane
|
|
101
|
+
# forget every token of one user after a revocation, since it is keyed
|
|
102
|
+
# by token and holds the user id as the value.
|
|
103
|
+
# @param value [Object]
|
|
104
|
+
# @return [Integer] number of entries removed.
|
|
105
|
+
def invalidate_value(value)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
before = @data.size
|
|
108
|
+
@data.delete_if { |_key, entry| entry[:value] == value }
|
|
109
|
+
before - @data.size
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
100
113
|
# Drop every entry.
|
|
101
114
|
def clear
|
|
102
115
|
@mutex.synchronize { @data.clear }
|
|
@@ -236,6 +249,35 @@ module Parse
|
|
|
236
249
|
@identity_cache.invalidate(session_token.to_s)
|
|
237
250
|
end
|
|
238
251
|
|
|
252
|
+
# Forget every cached identity entry that resolves to `user_id`. Call
|
|
253
|
+
# after an event that revokes the user's sessions: a password change,
|
|
254
|
+
# account deletion, a session destroy, or "log out everywhere". The SDK
|
|
255
|
+
# calls it itself on those paths.
|
|
256
|
+
#
|
|
257
|
+
# The identity plane is keyed by token, so there is no direct way to
|
|
258
|
+
# name a user's entries. A generation-capable plane (the keyspaced
|
|
259
|
+
# Redis identity plane) bumps the user's generation, which rejects
|
|
260
|
+
# every entry for that user, including tokens this process never
|
|
261
|
+
# resolved. The default {MemoryCache} drops the matching values. A
|
|
262
|
+
# custom plane that supports neither is left to its TTL.
|
|
263
|
+
#
|
|
264
|
+
# The role entry is dropped too: it is cheap to rebuild and a deleted
|
|
265
|
+
# user should not keep a role closure around.
|
|
266
|
+
# @param user_id [String]
|
|
267
|
+
# @return [void]
|
|
268
|
+
def invalidate_user(user_id)
|
|
269
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
270
|
+
uid = user_id.to_s
|
|
271
|
+
cache = @identity_cache
|
|
272
|
+
if generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
273
|
+
cache.bump_generation(uid)
|
|
274
|
+
elsif cache.respond_to?(:invalidate_value)
|
|
275
|
+
cache.invalidate_value(uid)
|
|
276
|
+
end
|
|
277
|
+
@role_cache.invalidate(uid)
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
239
281
|
# Forget one user's cached role closure. Call after any `_Role.users`
|
|
240
282
|
# mutation affecting them.
|
|
241
283
|
# @param user_id [String]
|
|
@@ -244,6 +286,12 @@ module Parse
|
|
|
244
286
|
@role_cache.invalidate(user_id.to_s)
|
|
245
287
|
end
|
|
246
288
|
|
|
289
|
+
# Forget every cached role closure. Call after a `_Role.roles` hierarchy
|
|
290
|
+
# change, which can affect any user holding a role in that hierarchy.
|
|
291
|
+
def invalidate_all_roles
|
|
292
|
+
@role_cache.clear if @role_cache.respond_to?(:clear)
|
|
293
|
+
end
|
|
294
|
+
|
|
247
295
|
# Drop every entry in both planes.
|
|
248
296
|
def reset_caches!
|
|
249
297
|
@identity_cache.clear if @identity_cache.respond_to?(:clear)
|
|
@@ -267,7 +315,12 @@ module Parse
|
|
|
267
315
|
return cached unless cached.nil?
|
|
268
316
|
|
|
269
317
|
response = begin
|
|
270
|
-
|
|
318
|
+
# cache: false: a revoked or expired token must not re-resolve
|
|
319
|
+
# from a cached /users/me response after its identity entry is
|
|
320
|
+
# evicted or invalidated. The identity plane above is the only
|
|
321
|
+
# cache on this path, so its TTL and invalidation hooks bound
|
|
322
|
+
# revocation.
|
|
323
|
+
@client.current_user(session_token, cache: false)
|
|
271
324
|
rescue => e
|
|
272
325
|
raise InvalidSession, "session token lookup failed: #{e.class}: #{e.message}"
|
|
273
326
|
end
|
data/lib/parse/client/batch.rb
CHANGED
|
@@ -70,12 +70,19 @@ module Parse
|
|
|
70
70
|
def initialize(reqs = nil, transaction: false)
|
|
71
71
|
@requests = []
|
|
72
72
|
@responses = []
|
|
73
|
+
@submitted = false
|
|
73
74
|
@transaction = transaction
|
|
74
75
|
reqs = [reqs] unless reqs.is_a?(Enumerable)
|
|
75
76
|
reqs.each { |r| add(r) } if reqs.is_a?(Enumerable)
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
# Add an additional request to this batch.
|
|
80
|
+
#
|
|
81
|
+
# A request tagged to a Parse object (see {Parse::Request#tag}) is skipped
|
|
82
|
+
# when the batch already holds an identical request for that same object,
|
|
83
|
+
# so adding an object twice does not send its changes twice. Untagged
|
|
84
|
+
# requests are always kept: two identical raw requests (for example two
|
|
85
|
+
# Increment operations) are two writes and are both sent.
|
|
79
86
|
# @overload add(req)
|
|
80
87
|
# @param req [Parse::Request] the request to append.
|
|
81
88
|
# @return [Array<Parse::Request>] the set of requests.
|
|
@@ -83,16 +90,19 @@ module Parse
|
|
|
83
90
|
# @param req [Parse::BatchOperation] add all the requests from this batch operation.
|
|
84
91
|
# @return [Array<Parse::Request>] the set of requests.
|
|
85
92
|
def add(req)
|
|
86
|
-
if req.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
incoming = if req.is_a?(BatchOperation)
|
|
94
|
+
req.requests
|
|
95
|
+
elsif req.respond_to?(:change_requests)
|
|
96
|
+
req.change_requests
|
|
97
|
+
elsif req.is_a?(Array)
|
|
98
|
+
req
|
|
99
|
+
else
|
|
100
|
+
[req]
|
|
101
|
+
end
|
|
102
|
+
incoming.each do |r|
|
|
103
|
+
next unless r.is_a?(Parse::Request)
|
|
104
|
+
next if duplicate_object_request?(r)
|
|
105
|
+
@requests.push(r)
|
|
96
106
|
end
|
|
97
107
|
@requests
|
|
98
108
|
end
|
|
@@ -127,13 +137,15 @@ module Parse
|
|
|
127
137
|
@requests.clear
|
|
128
138
|
end
|
|
129
139
|
|
|
130
|
-
# @return [Boolean] true if the
|
|
140
|
+
# @return [Boolean] true if every response in the batch succeeded. A
|
|
141
|
+
# batch that was submitted with no requests is successful; one that has
|
|
142
|
+
# not been submitted is not.
|
|
131
143
|
def success?
|
|
132
|
-
return
|
|
133
|
-
@responses.
|
|
144
|
+
return @submitted == true if @responses.empty?
|
|
145
|
+
@responses.all? { |r| r.respond_to?(:success?) && r.success? }
|
|
134
146
|
end
|
|
135
147
|
|
|
136
|
-
# @return [Boolean] true if
|
|
148
|
+
# @return [Boolean] true if at least one response in the batch failed.
|
|
137
149
|
def error?
|
|
138
150
|
return false if @responses.empty?
|
|
139
151
|
!success?
|
|
@@ -143,57 +155,169 @@ module Parse
|
|
|
143
155
|
# Parse limits requests in each batch to 50 and it is possible that a {BatchOperation}
|
|
144
156
|
# instance contains more than 50 requests. This method will slice up the array of
|
|
145
157
|
# request and send them based on the `segment` amount until they have all been submitted.
|
|
158
|
+
#
|
|
159
|
+
# The returned array always has one response per request, in request
|
|
160
|
+
# order. When a chunk fails as a whole (an HTTP error, or a response that
|
|
161
|
+
# cannot be matched to its requests), every request in that chunk gets a
|
|
162
|
+
# failed response, so a failure never shifts results onto other requests.
|
|
163
|
+
#
|
|
164
|
+
# A transactional batch (`transaction: true`) is never split. All of its
|
|
165
|
+
# requests go to Parse Server in one `POST /batch` with `transaction: true`,
|
|
166
|
+
# which commits or rolls back as a unit, regardless of `segment`. Parse
|
|
167
|
+
# Server has no fixed sub-request limit; a server configured with
|
|
168
|
+
# `requestComplexity.batchRequestLimit` rejects an oversized transaction
|
|
169
|
+
# for non-master callers, and nothing is written.
|
|
170
|
+
#
|
|
171
|
+
# When a chunk raises (for example a 5xx or a dropped connection), the
|
|
172
|
+
# other chunks are still processed and the block still sees every
|
|
173
|
+
# request, with failed responses for the chunk that raised. The first
|
|
174
|
+
# exception is then re-raised.
|
|
146
175
|
# @param segment [Integer] the number of requests to send in each batch. Default 50.
|
|
147
176
|
# @param parallelism [Integer] the number of segments dispatched in
|
|
148
177
|
# parallel. Defaults to `Parse::BatchOperation.parallelism` (2).
|
|
178
|
+
# @yieldparam request [Parse::Request] a submitted request.
|
|
179
|
+
# @yieldparam response [Parse::Response] the response for that request.
|
|
149
180
|
# @return [Array<Parse::Response>] the corresponding set of responses for
|
|
150
181
|
# each request in the batch.
|
|
151
182
|
def submit(segment = 50, parallelism: self.class.parallelism, &block)
|
|
152
183
|
@responses = []
|
|
153
|
-
@
|
|
154
|
-
|
|
155
|
-
@responses
|
|
156
|
-
|
|
184
|
+
@submitted = true
|
|
185
|
+
failure = nil
|
|
186
|
+
return @responses if @requests.empty?
|
|
187
|
+
|
|
188
|
+
if @transaction
|
|
189
|
+
# One request, one transaction. Exceptions propagate unchanged so the
|
|
190
|
+
# caller can roll back its local state.
|
|
191
|
+
@responses = align_responses(@requests, client.batch_request(self))
|
|
192
|
+
else
|
|
193
|
+
segment = 50 if segment.nil? || segment < 1
|
|
194
|
+
parallelism = 1 if parallelism.nil? || parallelism < 1
|
|
195
|
+
slices = @requests.each_slice(segment).to_a
|
|
196
|
+
outcomes = slices.threaded_map(parallelism) do |slice|
|
|
197
|
+
begin
|
|
198
|
+
[align_responses(slice, client.batch_request(BatchOperation.new(slice))), nil]
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
[Array.new(slice.size) { exception_response(e) }, e]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
@responses = outcomes.flat_map(&:first)
|
|
204
|
+
failure = outcomes.map(&:last).compact.first
|
|
157
205
|
end
|
|
158
|
-
|
|
206
|
+
|
|
159
207
|
@requests.zip(@responses).each(&block) if block_given?
|
|
208
|
+
raise failure if failure
|
|
160
209
|
@responses
|
|
161
210
|
end
|
|
162
211
|
|
|
163
212
|
alias_method :save, :submit
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Whether `req` repeats a request already in the batch for the same
|
|
217
|
+
# tagged object.
|
|
218
|
+
def duplicate_object_request?(req)
|
|
219
|
+
tag = req.tag
|
|
220
|
+
return false if tag.nil? || tag == 0
|
|
221
|
+
sig = req.signature
|
|
222
|
+
@requests.any? { |r| r.tag == tag && r.signature == sig }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Pair a chunk's requests with the result of its batch call, one
|
|
226
|
+
# response per request.
|
|
227
|
+
# @param slice [Array<Parse::Request>] the requests that were sent.
|
|
228
|
+
# @param result [Array<Parse::Response>, Parse::Response] the result of
|
|
229
|
+
# {Parse::API::Batch#batch_request}.
|
|
230
|
+
# @return [Array<Parse::Response>]
|
|
231
|
+
def align_responses(slice, result)
|
|
232
|
+
if result.is_a?(Array)
|
|
233
|
+
slice.each_with_index.map do |_req, i|
|
|
234
|
+
entry = result[i]
|
|
235
|
+
next entry if entry.is_a?(Parse::Response)
|
|
236
|
+
Parse::Response.error_response(
|
|
237
|
+
Parse::Response::ERROR_INTERNAL,
|
|
238
|
+
"Batch response had #{result.size} results for #{slice.size} requests",
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
elsif result.is_a?(Parse::Response) && result.error?
|
|
242
|
+
slice.map do
|
|
243
|
+
Parse::Response.error_response(result.code, result.error, http_status: result.http_status)
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
slice.map do
|
|
247
|
+
Parse::Response.error_response(Parse::Response::ERROR_INTERNAL, "Malformed batch response")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# A failed response standing in for a request whose chunk raised.
|
|
253
|
+
def exception_response(error)
|
|
254
|
+
code = Parse::Response::ERROR_INTERNAL
|
|
255
|
+
inner = error.respond_to?(:response) ? error.response : nil
|
|
256
|
+
code = inner.code if inner.respond_to?(:code) && inner.code.is_a?(Integer)
|
|
257
|
+
Parse::Response.error_response(code, "#{error.class}: #{error.message}")
|
|
258
|
+
end
|
|
164
259
|
end
|
|
165
260
|
end
|
|
166
261
|
|
|
167
262
|
class Array
|
|
168
263
|
|
|
169
264
|
# Submit a batch request for deleting a set of Parse::Objects.
|
|
265
|
+
#
|
|
266
|
+
# Each object whose delete succeeds has its local state updated the same
|
|
267
|
+
# way {Parse::Object#destroy} updates it. Objects whose delete fails are
|
|
268
|
+
# left untouched; inspect the returned batch's responses to find them.
|
|
269
|
+
# Destroy callbacks are not run.
|
|
170
270
|
# @example
|
|
171
271
|
# # assume Post and Author are Parse models
|
|
172
272
|
# author = Author.first
|
|
173
273
|
# posts = Post.all author: author
|
|
174
274
|
# posts.destroy # batch destroy request
|
|
175
275
|
# @return [Parse::BatchOperation] the batch operation performed.
|
|
276
|
+
# @raise ArgumentError if the array is not empty and holds no Parse objects.
|
|
176
277
|
# @see Parse::BatchOperation
|
|
177
278
|
def destroy
|
|
279
|
+
targets = select { |o| o.respond_to?(:destroy_request) }
|
|
280
|
+
if targets.empty? && !empty?
|
|
281
|
+
raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
|
|
282
|
+
"this array holds none (#{first.class})"
|
|
283
|
+
end
|
|
178
284
|
batch = Parse::BatchOperation.new
|
|
179
|
-
|
|
180
|
-
|
|
285
|
+
objects = {}
|
|
286
|
+
targets.each do |o|
|
|
287
|
+
next if objects.key?(o.object_id)
|
|
181
288
|
r = o.destroy_request
|
|
182
|
-
|
|
289
|
+
next if r.nil?
|
|
290
|
+
objects[o.object_id] = o
|
|
291
|
+
batch.add(r)
|
|
292
|
+
end
|
|
293
|
+
batch.submit do |request, response|
|
|
294
|
+
o = objects[request.tag]
|
|
295
|
+
next unless o && response.respond_to?(:success?) && response.success?
|
|
296
|
+
# Mirror Parse::Object#destroy: keep the id and mark the object
|
|
297
|
+
# destroyed so it reports `destroyed?` and a later save refuses.
|
|
298
|
+
o.instance_variable_set(:@_destroyed, true)
|
|
299
|
+
o.changes_applied! if o.respond_to?(:changes_applied!)
|
|
183
300
|
end
|
|
184
|
-
batch.submit
|
|
185
301
|
batch
|
|
186
302
|
end
|
|
187
303
|
|
|
188
304
|
# Do not alias method as :delete is already part of array.
|
|
189
305
|
# alias_method :delete, :destroy
|
|
190
306
|
|
|
191
|
-
# Submit a batch request for
|
|
307
|
+
# Submit a batch request for saving a set of Parse::Objects.
|
|
192
308
|
# Batch requests are supported implicitly and intelligently through an
|
|
193
309
|
# extension of array. When an array of Parse::Object subclasses is saved,
|
|
194
310
|
# Parse-Stack will batch all possible save operations for the objects in the
|
|
195
311
|
# array that have changed. It will also batch save 50 at a time until all items
|
|
196
312
|
# in the array are saved. Note: Parse does not allow batch saving Parse::User objects.
|
|
313
|
+
#
|
|
314
|
+
# Each object is updated only from its own responses. An object whose
|
|
315
|
+
# requests all succeed gets its id and timestamps and has its changes
|
|
316
|
+
# cleared. When an object has several requests (an attribute update plus
|
|
317
|
+
# relation updates) and only some succeed, the fields written by the
|
|
318
|
+
# successful requests are cleared and the rest stay dirty, so a later save
|
|
319
|
+
# resends only what failed. An object listed more than once is saved once.
|
|
320
|
+
# Elements that are not Parse objects are skipped.
|
|
197
321
|
# @note The objects of the array to be saved do not all have to be of the same collection.
|
|
198
322
|
# @param merge [Boolean] whether to merge the updated changes to the series of
|
|
199
323
|
# objects back to the original ones submitted. If you don't need the original objects
|
|
@@ -206,12 +330,18 @@ class Array
|
|
|
206
330
|
# posts.each { |post| post.author = author }
|
|
207
331
|
# posts.save # batch save
|
|
208
332
|
# @return [Parse::BatchOperation] the batch operation performed.
|
|
333
|
+
# @raise ArgumentError if the array is not empty and holds no Parse objects.
|
|
209
334
|
# @see Parse::BatchOperation
|
|
210
335
|
def save(merge: true, force: false)
|
|
336
|
+
targets = select { |o| o.is_a?(Parse::Object) }
|
|
337
|
+
if targets.empty? && !empty?
|
|
338
|
+
raise ArgumentError, "Array#save requires Parse::Object elements; " \
|
|
339
|
+
"this array holds none (#{first.class})"
|
|
340
|
+
end
|
|
211
341
|
batch = Parse::BatchOperation.new
|
|
212
342
|
objects = {}
|
|
213
|
-
each do |o|
|
|
214
|
-
next
|
|
343
|
+
targets.each do |o|
|
|
344
|
+
next if objects.key?(o.object_id)
|
|
215
345
|
objects[o.object_id] = o
|
|
216
346
|
batch.add o.change_requests(force)
|
|
217
347
|
end
|
|
@@ -219,16 +349,82 @@ class Array
|
|
|
219
349
|
batch.submit
|
|
220
350
|
return batch
|
|
221
351
|
end
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
352
|
+
outcomes = Hash.new { |h, k| h[k] = [] }
|
|
353
|
+
begin
|
|
354
|
+
batch.submit do |request, response|
|
|
355
|
+
outcomes[request.tag] << [request, response] if objects.key?(request.tag)
|
|
356
|
+
end
|
|
357
|
+
ensure
|
|
358
|
+
# Apply what landed even when a chunk raised, so a successful create is
|
|
359
|
+
# never left looking new (which would create it again on the next save).
|
|
360
|
+
outcomes.each do |tag, pairs|
|
|
361
|
+
Parse::BatchOperation.apply_save_outcome(objects[tag], pairs)
|
|
362
|
+
end
|
|
231
363
|
end
|
|
232
364
|
batch
|
|
233
365
|
end #save!
|
|
234
366
|
end
|
|
367
|
+
|
|
368
|
+
module Parse
|
|
369
|
+
class BatchOperation
|
|
370
|
+
# @!visibility private
|
|
371
|
+
# Apply the batch responses for one object's requests to that object.
|
|
372
|
+
# @param obj [Parse::Object] the object that was saved.
|
|
373
|
+
# @param pairs [Array<Array(Parse::Request, Parse::Response)>] its
|
|
374
|
+
# requests and their responses.
|
|
375
|
+
def self.apply_save_outcome(obj, pairs)
|
|
376
|
+
return unless obj.is_a?(Parse::Object)
|
|
377
|
+
ok, failed = pairs.partition { |_req, res| res.respond_to?(:success?) && res.success? }
|
|
378
|
+
|
|
379
|
+
ok.each do |_req, res|
|
|
380
|
+
result = res.result
|
|
381
|
+
next unless result.is_a?(Hash)
|
|
382
|
+
if obj.id.blank? && result[Parse::Model::OBJECT_ID].present?
|
|
383
|
+
obj.instance_variable_set(:@id, result[Parse::Model::OBJECT_ID])
|
|
384
|
+
end
|
|
385
|
+
created = result["createdAt"]
|
|
386
|
+
updated = result["updatedAt"] || created
|
|
387
|
+
obj.instance_variable_set(:@created_at, Parse::Date.parse(created)) if created
|
|
388
|
+
obj.instance_variable_set(:@updated_at, Parse::Date.parse(updated)) if updated
|
|
389
|
+
# beforeSave triggers can change saved fields; apply what came back.
|
|
390
|
+
obj.set_attributes!(result)
|
|
391
|
+
end
|
|
392
|
+
return if ok.empty?
|
|
393
|
+
|
|
394
|
+
if failed.empty?
|
|
395
|
+
obj.changes_applied!
|
|
396
|
+
# Settle array proxies explicitly as well: their dirty state lives on
|
|
397
|
+
# the proxy, and a proxy left dirty resends the whole array on the
|
|
398
|
+
# next save.
|
|
399
|
+
settle_collections(obj, obj.class.fields(:array).keys)
|
|
400
|
+
else
|
|
401
|
+
clear_written_fields(obj, ok.map(&:first), failed.map(&:first))
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# @!visibility private
|
|
406
|
+
# Clear dirty tracking only for the fields written by successful
|
|
407
|
+
# requests that no failed request also touched.
|
|
408
|
+
def self.clear_written_fields(obj, ok_requests, failed_requests)
|
|
409
|
+
remote = ->(reqs) { reqs.flat_map { |r| r.body.is_a?(Hash) ? r.body.keys.map(&:to_s) : [] } }
|
|
410
|
+
written = remote.call(ok_requests) - remote.call(failed_requests)
|
|
411
|
+
return if written.empty?
|
|
412
|
+
field_map = obj.class.respond_to?(:field_map) ? obj.class.field_map : {}
|
|
413
|
+
local = written.map do |key|
|
|
414
|
+
(field_map.find { |_local, rem| rem.to_s == key }&.first || key).to_s
|
|
415
|
+
end
|
|
416
|
+
settle_collections(obj, local)
|
|
417
|
+
obj.clear_attribute_changes(local) if obj.respond_to?(:clear_attribute_changes)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# @!visibility private
|
|
421
|
+
# Mark the collection proxies behind the named fields as saved.
|
|
422
|
+
def self.settle_collections(obj, names)
|
|
423
|
+
names.each do |name|
|
|
424
|
+
next unless obj.respond_to?(name)
|
|
425
|
+
value = obj.send(name)
|
|
426
|
+
value.changes_applied! if value.is_a?(Parse::CollectionProxy) && value.respond_to?(:changes_applied!)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|