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
@@ -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 a permission for an objectId or user.
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] the objectId to revoke permissions.
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
- id = id.id if id.is_a?(Parse::Pointer)
295
- if id.present? && permissions.has_key?(id)
296
- will_change!
297
- permissions.delete(id)
298
- end
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].present?
903
- @write = r_perm[:write].present?
978
+ @read = self.class.grant?(r_perm[:read])
979
+ @write = self.class.grant?(r_perm[:write])
904
980
  else
905
- @read = r_perm.present?
906
- @write = w_perm.present?
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
- # @note Setting the value in this manner is not dirty tracked.
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 [void]
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
- # @note Setting the value in this manner is not dirty tracked.
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 [void]
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
- @read = false
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
- @write = false
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
- nested_keys = nested_keys_for(association_key)
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
- # clear all items in the collection
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,59 @@ module Parse
233
270
 
234
271
  alias_method :delete, :remove
235
272
 
236
- # Atomically adds all items from the array.
237
- # This request is sent directly to the Parse backend.
238
- # @param items [Array] items to uniquely add
273
+ # Atomically adds all items to the array field. The request is sent
274
+ # directly to the Parse backend. On success the local collection is
275
+ # updated to match (the items are appended) without marking the field
276
+ # as changed, so a later save does not overwrite the server array. On
277
+ # an owner that has not been saved yet there is nothing to update on
278
+ # the server, so the items are added locally as a normal change and
279
+ # sent with the next save.
280
+ # @param items [Array] items to add
239
281
  # @note Parse objects are automatically converted to pointer format
282
+ # @return [Boolean] whether the operation succeeded.
240
283
  # @see #add_unique!
241
284
  def add!(*items)
285
+ items = items.flatten
242
286
  return false unless @delegate.respond_to?(:op_add!)
243
- @delegate.send :op_add!, @key, items_to_pointers(items.flatten)
244
- reset!
287
+ return true if items.empty?
288
+ return add(*items) && true if unsaved_delegate?
289
+ apply_atomic_op(:op_add!, items) do |list, objs|
290
+ list + objs
291
+ end
245
292
  end
246
293
 
247
- # Atomically adds all items from the array that are not already part of the collection.
248
- # This request is sent directly to the Parse backend.
294
+ # Atomically adds the items that are not already part of the array
295
+ # field. The request is sent directly to the Parse backend. The local
296
+ # collection is updated as in {#add!}.
249
297
  # @param items [Array] items to uniquely add
250
298
  # @note Parse objects are automatically converted to pointer format
299
+ # @return [Boolean] whether the operation succeeded.
251
300
  # @see #add!
252
301
  def add_unique!(*items)
302
+ items = items.flatten
253
303
  return false unless @delegate.respond_to?(:op_add_unique!)
254
- @delegate.send :op_add_unique!, @key, items_to_pointers(items.flatten)
255
- reset!
304
+ return true if items.empty?
305
+ return add_unique(*items) && true if unsaved_delegate?
306
+ apply_atomic_op(:op_add_unique!, items) do |list, objs|
307
+ objs.each { |o| list << o unless list.include?(o) }
308
+ list
309
+ end
256
310
  end
257
311
 
258
- # Atomically deletes all items from the array. This request is sent
259
- # directly to the Parse backend.
312
+ # Atomically removes the items from the array field. The request is
313
+ # sent directly to the Parse backend. The local collection is updated
314
+ # as in {#add!}.
260
315
  # @param items [Array] items to remove
261
316
  # @note Parse objects are automatically converted to pointer format
317
+ # @return [Boolean] whether the operation succeeded.
262
318
  def remove!(*items)
319
+ items = items.flatten
263
320
  return false unless @delegate.respond_to?(:op_remove!)
264
- @delegate.send :op_remove!, @key, items_to_pointers(items.flatten)
265
- reset!
321
+ return true if items.empty?
322
+ return remove(*items) && true if unsaved_delegate?
323
+ apply_atomic_op(:op_remove!, items) do |list, objs|
324
+ list.reject { |x| objs.include?(x) }
325
+ end
266
326
  end
267
327
 
268
328
  # Atomically deletes all items in the array, and marks the field as `undefined` directly
@@ -276,8 +336,15 @@ module Parse
276
336
  end
277
337
 
278
338
  # Locally restores previous attributes (not from the persistent store)
339
+ # and clears the dirty tracking of the collection. The values are
340
+ # restored directly: going through the public writers would report a
341
+ # new change to the owner while undoing one.
279
342
  def rollback!
280
- restore_attributes
343
+ changed.each do |attr|
344
+ instance_variable_set(:"@#{attr}", attribute_was(attr))
345
+ end
346
+ clear_changes_information
347
+ @collection
281
348
  end
282
349
 
283
350
  # clears all dirty tracked information.
@@ -416,6 +483,60 @@ module Parse
416
483
 
417
484
  private
418
485
 
486
+ # @return [Boolean] true when the owner has no objectId yet, so an
487
+ # atomic server operation has nothing to apply to.
488
+ def unsaved_delegate?
489
+ @delegate.respond_to?(:id) && @delegate.id.blank?
490
+ end
491
+
492
+ # @return [Boolean] whether the owner reports this field as changed.
493
+ def delegate_field_dirty?
494
+ return false unless @delegate.respond_to?(:changed)
495
+ @delegate.changed.include?(@key.to_s)
496
+ rescue StandardError
497
+ false
498
+ end
499
+
500
+ # Run an atomic operation on the owner and, when it succeeds, apply the
501
+ # same change to the local collection without marking the field dirty.
502
+ # The block receives a copy of the current items and the operation's
503
+ # items and returns the new contents.
504
+ # @return [Boolean] whether the operation succeeded.
505
+ def apply_atomic_op(op, items)
506
+ return true if items.empty?
507
+ success = @delegate.send(op, @key, items_to_pointers(items))
508
+ return false unless success
509
+ # Read the owner's dirty state before touching the items, so a change
510
+ # that was already pending is still sent by the next save.
511
+ was_dirty = delegate_field_dirty?
512
+ # A clean plain array adopts the array the server returned, so a local
513
+ # copy that was already out of date is corrected. With unsaved local
514
+ # edits pending, adopting it would discard them, so the operation is
515
+ # applied locally instead (as it is for pointer collections, or a reply
516
+ # without the field) and the pending edits are still sent on save.
517
+ server = was_dirty ? nil : server_array_after_op
518
+ @collection = server || yield(collection.to_a.dup, items)
519
+ @loaded = true
520
+ unless was_dirty
521
+ # Plain array properties detect in-place edits by comparing against
522
+ # a snapshot; take a new one so this update is not seen as an edit.
523
+ if @delegate.respond_to?(:_rebaseline_mutable_value!, true)
524
+ @delegate.send(:_rebaseline_mutable_value!, @key)
525
+ end
526
+ end
527
+ true
528
+ end
529
+
530
+ # The field's new array from the delegate's last atomic operation, when
531
+ # this is a plain array proxy and the server returned it.
532
+ # @return [Array, nil]
533
+ def server_array_after_op
534
+ return nil unless instance_of?(Parse::CollectionProxy)
535
+ return nil unless @delegate.respond_to?(:_last_operation_value, true)
536
+ value = @delegate.send(:_last_operation_value, @key)
537
+ value.is_a?(Array) ? Parse::Properties.deep_copy_value(value) : nil
538
+ end
539
+
419
540
  # Convert items to pointer format for atomic operations.
420
541
  # Parse objects/pointers are converted to pointer hashes, other items pass through.
421
542
  # @param items [Array] items to convert
@@ -378,10 +378,22 @@ module Parse
378
378
  }
379
379
 
380
380
  define_method(key) do |*args, &block|
381
- return [] if @id.nil?
382
381
  query = Parse::Query.new(klassName, limit: :max)
383
382
 
384
- query.where(foreign_field => self) unless opts[:scope_only] == true
383
+ unless opts[:scope_only] == true
384
+ if @id.blank?
385
+ # An unsaved owner has no related records. Return a query
386
+ # that matches nothing, so chaining still works, without
387
+ # sending a constraint on a pointer with a null objectId.
388
+ query.where(:objectId.in => [])
389
+ query.define_singleton_method(:results) do |*_a, **_o, &blk|
390
+ blk ? [].each(&blk) : []
391
+ end
392
+ query.define_singleton_method(:count) { |*_a, **_o| 0 }
393
+ else
394
+ query.where(foreign_field => self)
395
+ end
396
+ end
385
397
 
386
398
  if scope.is_a?(Proc)
387
399
  # magic, override the singleton method_missing with accessing object level methods
@@ -483,6 +495,7 @@ module Parse
483
495
  }
484
496
 
485
497
  self.field_map.merge!(key => parse_field)
498
+ Parse::Model.model_registry_changed!
486
499
  # dirty tracking
487
500
  define_attribute_methods key
488
501
 
@@ -510,7 +523,7 @@ module Parse
510
523
  unless val.is_a?(Parse::PointerCollectionProxy)
511
524
  results = []
512
525
  #results = val.parse_objects if val.respond_to?(:parse_objects)
513
- val = proxyKlass.new results, delegate: self, key: key
526
+ val = proxyKlass.new results, delegate: self, key: key, parse_class: klassName
514
527
  instance_variable_set(ivar, val)
515
528
  end
516
529
  val
@@ -554,10 +567,21 @@ module Parse
554
567
  _collection.loaded = true
555
568
  _collection.remove val["objects"].parse_objects(klassName)
556
569
  val = _collection
557
- elsif val.is_a?(Array)
558
- # Otherwise create a new collection based on what the user
559
- # defined; always coerce array elements to the declared class.
560
- val = proxyKlass.new val.parse_objects(klassName), delegate: self, key: key, parse_class: klassName
570
+ elsif val.is_a?(Array) || (track == true && val.is_a?(Parse::CollectionProxy) && !val.is_a?(proxyKlass))
571
+ _collection = proxyKlass.new [], delegate: self, key: key, parse_class: klassName
572
+ items = if track == true
573
+ # Assignment from application code: validate every item.
574
+ # An item of another class, nil, or a value that is not an
575
+ # object, pointer or objectId raises ArgumentError rather
576
+ # than being stored under the declared class.
577
+ _collection.send(:typecast_items, val.to_a)
578
+ else
579
+ # Server data: coerce elements to the declared class.
580
+ val.parse_objects(klassName)
581
+ end
582
+ _collection.set_collection!(items)
583
+ _collection.loaded = true
584
+ val = _collection
561
585
  end
562
586
 
563
587
  # send dirty tracking if set
@@ -579,11 +603,16 @@ module Parse
579
603
  # for more information.
580
604
  if data_type == :relation
581
605
  # return a query given the foreign table class name.
606
+ # The $relatedTo key is the remote column, which differs from the
607
+ # local name when `field:` is given.
582
608
  define_method("#{key}_relation_query") do
583
- Parse::Query.new(klassName, key.to_sym.related_to => self.pointer, limit: :max)
609
+ Parse::Query.new(klassName, parse_field.related_to => self.pointer, limit: :max)
584
610
  end
585
- # fetch the contents of the relation
611
+ # fetch the contents of the relation. An owner without an
612
+ # objectId has nothing on the server; a query for it would send
613
+ # a null objectId, which Parse Server rejects.
586
614
  define_method("#{key}_fetch!") do
615
+ next [] if @id.blank?
587
616
  q = self.send :"#{key}_relation_query"
588
617
  q.results || []
589
618
  end
@@ -142,7 +142,9 @@ module Parse
142
142
  end
143
143
 
144
144
  define_method(key) do |*args, &block|
145
- return nil if @id.nil?
145
+ # An unsaved owner has no related record, unless the scope alone
146
+ # selects it (`scope_only: true` does not use the owner id).
147
+ return nil if @id.blank? && opts[:scope_only] != true
146
148
  query = Parse::Query.new(klassName, limit: 1)
147
149
  query.where(foreign_field => self) unless opts[:scope_only] == true
148
150