parse-stack-next 5.7.5 → 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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +856 -0
  3. data/README.md +15 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +290 -17
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
@@ -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 !resolution.master? && protected_fields.include?(field_str)
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
- return if resolution.nil? || resolution.master?
984
- unless Parse::CLPScope.permits?(collection_name, :find, resolution.permission_strings)
985
- raise Parse::CLPScope::Denied.new(
986
- collection_name, :find,
987
- "CLP refuses find on '#{collection_name}' for the current Atlas Search scope.",
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
- return nil if resolution.nil? || resolution.master?
998
- pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
999
- return nil if pointer_fields.nil?
1000
- if resolution.user_id.nil?
1001
- raise Parse::CLPScope::Denied.new(
1002
- collection_name, :find,
1003
- "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
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
- path = highlight_field.to_s
1020
- return unless protected_fields.include?(path)
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
- h.is_a?(Hash) && protected_set.include?((h["path"] || h[:path]).to_s)
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
@@ -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
- @client.current_user(session_token)
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
@@ -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.respond_to?(:change_requests)
87
- requests = req.change_requests.select { |r| r.is_a?(Parse::Request) }
88
- @requests += requests
89
- elsif req.is_a?(Array)
90
- requests = req.select { |r| r.is_a?(Parse::Request) }
91
- @requests += requests
92
- elsif req.is_a?(BatchOperation)
93
- @requests += req.requests if req.is_a?(BatchOperation)
94
- else
95
- @requests.push(req) if req.is_a?(Parse::Request)
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 request was successful.
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 false if @responses.empty?
133
- @responses.compact.all?(&:success?)
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 the request had an error.
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
- @requests.uniq!(&:signature)
154
- parallelism = 1 if parallelism.nil? || parallelism < 1
155
- @responses = @requests.each_slice(segment).to_a.threaded_map(parallelism) do |slice|
156
- client.batch_request(BatchOperation.new(slice))
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
- @responses.flatten!
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
- each do |o|
180
- next unless o.respond_to?(:destroy_request)
285
+ objects = {}
286
+ targets.each do |o|
287
+ next if objects.key?(o.object_id)
181
288
  r = o.destroy_request
182
- batch.add(r) unless r.nil?
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 deleting a set of Parse::Objects.
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 unless o.is_a?(Parse::Object)
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
- #rebind updates
223
- batch.submit do |request, response|
224
- next unless request.tag.present? && response.present? && response.success?
225
- o = objects[request.tag]
226
- next unless o.is_a?(Parse::Object)
227
- result = response.result
228
- o.id = result["objectId"] if o.id.blank?
229
- o.set_attributes!(result)
230
- o.clear_changes!
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