parse-stack-next 5.7.6 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  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 +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -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
@@ -542,6 +555,17 @@ module Parse
542
555
  warn "[#{self.class}] has_many :#{key} expected className=#{klassName.inspect}, ignoring incoming className=#{val[Parse::Model::KEY_CLASS_NAME].inspect}"
543
556
  end
544
557
  if val.is_a?(Hash) && val["__type"] == "Relation"
558
+ current = instance_variable_get(ivar)
559
+ # A server descriptor carries no membership. When the current
560
+ # proxy holds staged additions or removals (a fetch of a record
561
+ # with unsaved relation changes), keep it so they are not lost.
562
+ if track != true && current.is_a?(Parse::RelationCollectionProxy) &&
563
+ current.staged_changes?
564
+ # The loaded list predates this fetch. Unload it so the next
565
+ # read queries the server and re-applies the staged changes.
566
+ current.reset! if current.loaded?
567
+ return current
568
+ end
545
569
  relation_objects = val["objects"] || []
546
570
  val = Parse::RelationCollectionProxy.new relation_objects, delegate: self, key: key, parse_class: klassName
547
571
  elsif val.is_a?(Hash) && val["__op"] == "AddRelation" && val["objects"].present?
@@ -554,10 +578,21 @@ module Parse
554
578
  _collection.loaded = true
555
579
  _collection.remove val["objects"].parse_objects(klassName)
556
580
  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
581
+ elsif val.is_a?(Array) || (track == true && val.is_a?(Parse::CollectionProxy) && !val.is_a?(proxyKlass))
582
+ _collection = proxyKlass.new [], delegate: self, key: key, parse_class: klassName
583
+ items = if track == true
584
+ # Assignment from application code: validate every item.
585
+ # An item of another class, nil, or a value that is not an
586
+ # object, pointer or objectId raises ArgumentError rather
587
+ # than being stored under the declared class.
588
+ _collection.send(:typecast_items, val.to_a)
589
+ else
590
+ # Server data: coerce elements to the declared class.
591
+ val.parse_objects(klassName)
592
+ end
593
+ _collection.set_collection!(items)
594
+ _collection.loaded = true
595
+ val = _collection
561
596
  end
562
597
 
563
598
  # send dirty tracking if set
@@ -579,11 +614,16 @@ module Parse
579
614
  # for more information.
580
615
  if data_type == :relation
581
616
  # return a query given the foreign table class name.
617
+ # The $relatedTo key is the remote column, which differs from the
618
+ # local name when `field:` is given.
582
619
  define_method("#{key}_relation_query") do
583
- Parse::Query.new(klassName, key.to_sym.related_to => self.pointer, limit: :max)
620
+ Parse::Query.new(klassName, parse_field.related_to => self.pointer, limit: :max)
584
621
  end
585
- # fetch the contents of the relation
622
+ # fetch the contents of the relation. An owner without an
623
+ # objectId has nothing on the server; a query for it would send
624
+ # a null objectId, which Parse Server rejects.
586
625
  define_method("#{key}_fetch!") do
626
+ next [] if @id.blank?
587
627
  q = self.send :"#{key}_relation_query"
588
628
  q.results || []
589
629
  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
 
@@ -32,15 +32,50 @@ module Parse
32
32
  # @overload add(parse_objects)
33
33
  # Add an array of Parse::Objects or Parse::Pointers to this collection.
34
34
  # @param parse_objects [Array<Parse::Object,Parse::Pointer>] the array to append.
35
+ # An objectId String is accepted and becomes a pointer of the declared
36
+ # class.
37
+ # @raise [ArgumentError] if an item is nil, of another Parse class, or
38
+ # cannot be turned into a pointer.
35
39
  # @return [Array<Parse::Object>] the collection
36
40
  def add(*items)
37
- notify_will_change! if items.count > 0
38
- items.flatten.parse_objects.each do |item|
39
- collection.push(item) if item.is_a?(Parse::Pointer)
40
- end
41
+ items = typecast_items(items)
42
+ return @collection if items.empty?
43
+ notify_will_change!
44
+ items.each { |item| collection.push(item) }
41
45
  @collection
42
46
  end
43
47
 
48
+ alias_method :push, :add
49
+
50
+ # Add items that are not already part of the collection.
51
+ # @param items [Array<Parse::Object,Parse::Pointer,String>] items to uniquely add
52
+ # @raise [ArgumentError] (see #add)
53
+ # @return [Array<Parse::Object>] the collection
54
+ def add_unique(*items)
55
+ items = typecast_items(items)
56
+ return @collection if items.empty?
57
+ notify_will_change!
58
+ @collection = collection | items
59
+ @collection
60
+ end
61
+
62
+ alias_method :push_unique, :add_unique
63
+
64
+ # @see #add
65
+ def <<(*list)
66
+ add(*list)
67
+ self
68
+ end
69
+
70
+ # Replace the contents of the collection. The items are validated as in
71
+ # {#add}.
72
+ # @param items [Array<Parse::Object,Parse::Pointer,String>] the new contents.
73
+ # @raise [ArgumentError] (see #add)
74
+ # @return [self]
75
+ def replace(items)
76
+ super(typecast_items(Array(items.is_a?(Parse::CollectionProxy) ? items.to_a : items)))
77
+ end
78
+
44
79
  # Removes Parse::Objects from the collection.
45
80
  # @overload remove(parse_object)
46
81
  # Remove a Parse::Object or Parse::Pointer to this collection.
@@ -48,40 +83,37 @@ module Parse
48
83
  # @overload remove(parse_objects)
49
84
  # Remove an array of Parse::Objects or Parse::Pointers from this collection.
50
85
  # @param parse_objects [Array<Parse::Object,Parse::Pointer>] the array of objects to remove.
86
+ # An objectId String removes the object of the declared class with that id.
51
87
  # @return [Array<Parse::Object>] the collection
52
88
  def remove(*items)
53
- notify_will_change! if items.count > 0
54
- items.flatten.parse_objects.each do |item|
55
- collection.delete item
56
- end
89
+ items = typecast_items(items, strict: false)
90
+ return @collection if items.empty?
91
+ notify_will_change!
92
+ items.each { |item| collection.delete item }
57
93
  @collection
58
94
  end
59
95
 
96
+ alias_method :delete, :remove
97
+
60
98
  # Atomically add a set of Parse::Objects to this collection.
61
- # This is done by making the API request directly with Parse server; the
62
- # local object is not updated with changes.
63
99
  # @see CollectionProxy#add!
64
100
  # @see #add_unique!
65
101
  def add!(*items)
66
- super(items.flatten.parse_pointers)
102
+ super(*typecast_items(items))
67
103
  end
68
104
 
69
105
  # Atomically add a set of Parse::Objects to this collection for those not already
70
106
  # in the collection.
71
- # This is done by making the API request directly with Parse server; the
72
- # local object is not updated with changes.
73
107
  # @see CollectionProxy#add_unique!
74
108
  # @see #add!
75
109
  def add_unique!(*items)
76
- super(items.flatten.parse_pointers)
110
+ super(*typecast_items(items))
77
111
  end
78
112
 
79
113
  # Atomically remove a set of Parse::Objects to this collection.
80
- # This is done by making the API request directly with Parse server; the
81
- # local object is not updated with changes.
82
114
  # @see CollectionProxy#remove!
83
115
  def remove!(*items)
84
- super(items.flatten.parse_pointers)
116
+ super(*typecast_items(items, strict: false))
85
117
  end
86
118
 
87
119
  # Force fetch the set of pointer objects in this collection.
@@ -130,5 +162,101 @@ module Parse
130
162
  opts = defaults.merge(opts)
131
163
  super(opts)
132
164
  end
165
+
166
+ private
167
+
168
+ # Convert the given items to Parse objects of the collection's class.
169
+ # Parse objects and pointers are kept, pointer hashes are built, and an
170
+ # objectId String becomes a pointer of the declared class.
171
+ # @param items [Array] the items to convert (nested arrays are flattened).
172
+ # @param strict [Boolean] when true, an item that cannot be converted, or
173
+ # belongs to another Parse class, raises. When false it is skipped.
174
+ # @raise [ArgumentError] in strict mode, for an invalid item.
175
+ # @return [Array<Parse::Pointer>]
176
+ def typecast_items(items, strict: true)
177
+ items.flatten.each_with_object([]) do |item, list|
178
+ obj = typecast_item(item)
179
+ if obj.nil?
180
+ next unless strict
181
+ raise ArgumentError, "Invalid item #{item.inspect} for #{collection_label}: " \
182
+ "expected a Parse::Object, Parse::Pointer or objectId String."
183
+ end
184
+ unless item_class_allowed?(obj.parse_class)
185
+ next unless strict
186
+ raise ArgumentError, "Invalid item for #{collection_label}: expected a " \
187
+ "#{@parse_class} object, got #{obj.parse_class}."
188
+ end
189
+ list << obj
190
+ end
191
+ end
192
+
193
+ # The server returns the field's array as pointer hashes. Each entry
194
+ # becomes an object of the declared class, reusing the local object with
195
+ # the same id so fetched data is kept. The array is adopted only when
196
+ # the declared class is a registered model and every entry is a pointer
197
+ # to that class; anything else (another class, a bare string, a value
198
+ # that is not a pointer) makes the operation apply locally instead,
199
+ # rather than relabeling the entry as the declared class.
200
+ # @return [Array<Parse::Pointer>, nil]
201
+ def adopt_server_items(value)
202
+ return nil if @parse_class.blank? || Parse::Model.find_class(@parse_class).nil?
203
+ local_by_id = {}
204
+ collection.to_a.each do |item|
205
+ id = item.respond_to?(:id) ? item.id : nil
206
+ local_by_id[id] ||= item if id.present?
207
+ end
208
+ value.map do |entry|
209
+ class_name, object_id = server_pointer_parts(entry)
210
+ return nil unless object_id.is_a?(String) && object_id.present?
211
+ return nil unless Parse::Model.same_parse_class?(class_name, @parse_class)
212
+ local_by_id[object_id] || typecast_item(entry) || (return nil)
213
+ end
214
+ end
215
+
216
+ # @return [Array(String, String), nil] the className and objectId of a
217
+ # pointer entry from a server reply, or nil when it is not a pointer.
218
+ def server_pointer_parts(entry)
219
+ case entry
220
+ when Parse::Pointer
221
+ [entry.parse_class, entry.id]
222
+ when Hash
223
+ type = entry["__type"] || entry[:__type]
224
+ return nil unless %w[Pointer Object].include?(type.to_s)
225
+ [entry["className"] || entry[:className], entry["objectId"] || entry[:objectId]]
226
+ end
227
+ end
228
+
229
+ # @return [Parse::Pointer, nil] the item as a Parse object, or nil.
230
+ def typecast_item(item)
231
+ case item
232
+ when Parse::Pointer
233
+ item
234
+ when Hash
235
+ type = item["__type"] || item[:__type]
236
+ return nil if type.present? && !%w[Pointer Object].include?(type.to_s)
237
+ # A pointer hash is wire data: with a declared class its className
238
+ # is ignored (with a warning), as for server data.
239
+ [item].parse_objects(@parse_class.presence).first
240
+ when String
241
+ return nil if item.blank? || @parse_class.blank?
242
+ Parse::Object.build({ Parse::Model::TYPE_FIELD => Parse::Model::TYPE_POINTER,
243
+ Parse::Model::KEY_CLASS_NAME => @parse_class,
244
+ Parse::Model::OBJECT_ID => item }, @parse_class)
245
+ end
246
+ end
247
+
248
+ # @return [Boolean] whether an item of `klass` may be part of this
249
+ # collection. The class is checked when the declared class is a
250
+ # registered model; a declaration naming no model (for example a
251
+ # has_many whose `as:` was left to default) keeps accepting any class.
252
+ def item_class_allowed?(klass)
253
+ return true if @parse_class.blank? || Parse::Model.find_class(@parse_class).nil?
254
+ klass.present? && Parse::Model.same_parse_class?(klass, @parse_class)
255
+ end
256
+
257
+ def collection_label
258
+ owner = @delegate.respond_to?(:parse_class) ? @delegate.parse_class : @delegate.class
259
+ @key ? "#{owner}##{@key}" : self.class.name
260
+ end
133
261
  end
134
262
  end
@@ -24,18 +24,19 @@ module Parse
24
24
  # get matching items within the relation collection.
25
25
  #
26
26
  # When creating a Relation proxy, all the delegate methods defined in the superclasses
27
- # need to be implemented, in addition to a few others with the key parameter:
28
- # _relation_query and _commit_relation_updates . :'key'_relation_query should return a
27
+ # need to be implemented, in addition to :'key'_relation_query, which should return a
29
28
  # Parse::Query object that is properly tied to the foreign table class related to this object column.
30
29
  # Example, if an Artist has many Song objects, then the query to be returned by this method
31
30
  # should be a Parse::Query for the class 'Song'.
32
31
  # Because relation changes are separate from object changes, you can call save on a
33
- # relation collection to save the current add and remove operations. Because the delegate needs
34
- # to be informed of the changes being committed, it will be notified
35
- # through :'key'_commit_relation_updates message. The delegate is also in charge of
36
- # clearing out the change information for the collection if saved successfully.
32
+ # relation collection to send only its staged add and remove operations, through the
33
+ # owner's atomic relation operations.
37
34
  # @see PointerCollectionProxy
38
35
  class RelationCollectionProxy < PointerCollectionProxy
36
+ # @!visibility private
37
+ # Default for {#save}'s `session:`: keep the owner's session as it is.
38
+ SESSION_UNSET = Object.new.freeze
39
+
39
40
  define_attribute_methods :additions, :removals
40
41
  # @!attribute [r] removals
41
42
  # The objects that have been newly removed to this collection
@@ -51,9 +52,29 @@ module Parse
51
52
  @removals = []
52
53
  end
53
54
 
55
+ # The items of the relation. The first access queries the server for the
56
+ # related objects, then applies the additions and removals that have not
57
+ # been saved yet. An owner without an objectId has nothing on the server,
58
+ # so no query is sent for it.
59
+ # @return [Array<Parse::Object>]
60
+ def collection
61
+ unless @loaded
62
+ fetched = owner_saved? ? forward(:"#{@key}_fetch!") : nil
63
+ list = fetched.to_a.reject { |item| @removals.include?(item) }
64
+ @additions.each { |item| list.push(item) unless list.include?(item) }
65
+ @collection = list
66
+ @loaded = true
67
+ end
68
+ @collection
69
+ end
70
+
54
71
  # You can get items within the collection relation filtered by a specific set
55
72
  # of query constraints.
56
73
  def all(constraints = {}, &block)
74
+ # An unsaved owner has no relation on the server yet.
75
+ unless owner_saved?
76
+ return block_given? ? collection.each(&block) : collection
77
+ end
57
78
  q = query({ limit: :max }.merge(constraints))
58
79
  if block_given?
59
80
  # if we have a query, then use the Proc with it (more efficient)
@@ -86,32 +107,44 @@ module Parse
86
107
  query(limit: count)
87
108
  end
88
109
 
89
- # Add Parse::Objects to the relation.
110
+ # Add Parse::Objects to the relation. The change is staged and sent with
111
+ # the next save of the owner. Staging does not query the relation.
112
+ # Adding an item that is already staged has no further effect.
90
113
  # @overload add(parse_object)
91
114
  # Add a Parse::Object or Parse::Pointer to this relation.
92
115
  # @param parse_object [Parse::Object,Parse::Pointer] the object to add
93
116
  # @overload add(parse_objects)
94
117
  # Add an array of Parse::Objects or Parse::Pointers to this relation.
95
118
  # @param parse_objects [Array<Parse::Object,Parse::Pointer>] the array to append.
119
+ # @raise [ArgumentError] if an item is nil, of another Parse class, or
120
+ # cannot be turned into a pointer.
96
121
  # @return [Array<Parse::Object>] the collection
97
122
  def add(*items)
98
- items = items.flatten.parse_objects
123
+ items = typecast_items(items)
99
124
  return @collection if items.empty?
100
125
 
101
126
  notify_will_change!
102
127
  additions_will_change!
103
128
  removals_will_change!
104
- # take all the items
105
129
  items.each do |item|
106
- @additions.push item
107
- @collection.push item
108
- #cleanup
109
- @removals.delete item
130
+ @additions.push(item) unless @additions.include?(item)
131
+ @removals.delete(item)
132
+ @collection.push(item) if @loaded && !@collection.include?(item)
110
133
  end
111
134
  @collection
112
135
  end
113
136
 
114
- # Removes Parse::Objects from the relation.
137
+ alias_method :push, :add
138
+
139
+ # Same as {#add}: a relation never holds an object twice.
140
+ def add_unique(*items)
141
+ add(*items)
142
+ end
143
+
144
+ alias_method :push_unique, :add_unique
145
+
146
+ # Removes Parse::Objects from the relation. The change is staged and sent
147
+ # with the next save of the owner. Staging does not query the relation.
115
148
  # @overload remove(parse_object)
116
149
  # Remove a Parse::Object or Parse::Pointer to this relation.
117
150
  # @param parse_object [Parse::Object,Parse::Pointer] the object to remove
@@ -120,52 +153,165 @@ module Parse
120
153
  # @param parse_objects [Array<Parse::Object,Parse::Pointer>] the array of objects to remove.
121
154
  # @return [Array<Parse::Object>] the collection
122
155
  def remove(*items)
123
- items = items.flatten.parse_objects
156
+ items = typecast_items(items, strict: false)
124
157
  return @collection if items.empty?
125
158
  notify_will_change!
126
159
  additions_will_change!
127
160
  removals_will_change!
161
+ # An unsaved owner has nothing on the server to remove.
162
+ saved = owner_saved?
128
163
  items.each do |item|
129
- @removals.push item
130
- @collection.delete item
131
- # remove it from any add operations
132
- @additions.delete item
164
+ @removals.push(item) if saved && !@removals.include?(item)
165
+ @additions.delete(item)
166
+ @collection.delete(item)
133
167
  end
134
168
  @collection
135
169
  end
136
170
 
171
+ alias_method :delete, :remove
172
+
173
+ # Stage the removal of every object in the relation. This loads the
174
+ # relation to know what to remove.
175
+ # @return [Array<Parse::Object>] the (now empty) collection.
176
+ def clear
177
+ remove(*collection.to_a)
178
+ @collection
179
+ end
180
+
181
+ # Stage the changes that make the relation hold exactly `items`.
182
+ # @return [self]
183
+ def replace(items)
184
+ items = typecast_items(Array(items.is_a?(Parse::CollectionProxy) ? items.to_a : items))
185
+ current = collection.to_a
186
+ stale = current.reject { |item| items.include?(item) }
187
+ remove(*stale) if stale.any?
188
+ fresh = items.reject { |item| current.include?(item) }
189
+ add(*fresh) if fresh.any?
190
+ self
191
+ end
192
+
137
193
  # Atomically add a set of Parse::Objects to this relation.
138
- # This is done by making the API request directly with Parse server; the
139
- # local object is not updated with changes.
194
+ # This is done by making the API request directly with Parse server. On
195
+ # success the loaded collection includes the items, and a staged
196
+ # removal of the same items is dropped so a later save does not undo it.
197
+ # On an owner that has not been saved yet the items are staged as in
198
+ # {#add} and sent with the next save.
199
+ # @return [Boolean] whether the operation succeeded.
140
200
  def add!(*items)
141
201
  return false unless @delegate.respond_to?(:op_add_relation!)
142
- items = items.flatten.parse_pointers
143
- @delegate.send :op_add_relation!, @key, items
202
+ items = typecast_items(items)
203
+ return true if items.empty?
204
+ return add(*items) && true unless owner_persisted?
205
+ return false unless @delegate.send(:op_add_relation!, @key, items.parse_pointers)
206
+ items.each do |item|
207
+ @removals.delete(item)
208
+ @collection.push(item) if @loaded && !@collection.include?(item)
209
+ end
210
+ true
144
211
  end
145
212
 
146
- # Atomically add a set of Parse::Objects to this relation.
147
- # This is done by making the API request directly with Parse server; the
148
- # local object is not updated with changes.
213
+ # @see #add!
149
214
  def add_unique!(*items)
150
- return false unless @delegate.respond_to?(:op_add_relation!)
151
- items = items.flatten.parse_pointers
152
- @delegate.send :op_add_relation!, @key, items
215
+ add!(*items)
153
216
  end
154
217
 
155
- # Atomically remove a set of Parse::Objects to this relation.
156
- # This is done by making the API request directly with Parse server; the
157
- # local object is not updated with changes.
218
+ # Atomically remove a set of Parse::Objects from this relation.
219
+ # This is done by making the API request directly with Parse server. On
220
+ # success the items leave the loaded collection, and a staged addition
221
+ # of the same items is dropped.
222
+ # @return [Boolean] whether the operation succeeded.
158
223
  def remove!(*items)
159
224
  return false unless @delegate.respond_to?(:op_remove_relation!)
160
- items = items.flatten.parse_pointers
161
- @delegate.send :op_remove_relation!, @key, items
225
+ items = typecast_items(items, strict: false)
226
+ return true if items.empty?
227
+ return remove(*items) && true unless owner_persisted?
228
+ return false unless @delegate.send(:op_remove_relation!, @key, items.parse_pointers)
229
+ items.each do |item|
230
+ @additions.delete(item)
231
+ @collection.delete(item)
232
+ end
233
+ true
234
+ end
235
+
236
+ # Marks the staged additions and removals as saved. Called after the
237
+ # owner is saved successfully, so the operations are not sent again by a
238
+ # later save.
239
+ def changes_applied!
240
+ @additions = []
241
+ @removals = []
242
+ super
243
+ end
244
+
245
+ # Drops the staged additions and removals. The items are reloaded from
246
+ # the server on the next access.
247
+ def rollback!
248
+ super
249
+ @additions = []
250
+ @removals = []
251
+ reset!
252
+ @collection
162
253
  end
163
254
 
164
- # Save the changes to the relation
165
- def save
166
- unless @removals.empty? && @additions.empty?
167
- forward :"#{@key}_commit_relation_updates"
255
+ # @return [Boolean] true when additions or removals are staged and not
256
+ # yet sent.
257
+ def staged_changes?
258
+ @additions.any? || @removals.any?
259
+ end
260
+
261
+ # Drops the staged additions and removals and clears the dirty tracking,
262
+ # without sending anything. When operations were staged, the items are
263
+ # reloaded from the server on the next access, since the loaded list
264
+ # included them.
265
+ def clear_changes!
266
+ staged = staged_changes?
267
+ @additions = []
268
+ @removals = []
269
+ super
270
+ reset! if staged
271
+ @collection
272
+ end
273
+
274
+ # Sends the staged additions and removals of this relation without saving
275
+ # the rest of the owner. The owner must already be saved: an unsaved owner
276
+ # sends its relation additions when it is created.
277
+ #
278
+ # Removals and additions go out as two requests, removals first (each
279
+ # through the owner's atomic relation operation, so {Parse::Role} cache
280
+ # invalidation still runs). The two are not atomic: if the removals
281
+ # succeed and the additions fail, the removals stay applied, they are no
282
+ # longer staged, and the additions stay staged for a retry. An owner
283
+ # save sends relation changes the same way.
284
+ #
285
+ # The requests use the owner's session token from its last
286
+ # `save(session:)`, or the client's default credentials. Pass `session:`
287
+ # to send them as a specific user (a session token String or a
288
+ # {Parse::User}), or `session: nil` to send them with no session token.
289
+ # @param session [String, Parse::User, nil] the session to send the
290
+ # operations with, when given.
291
+ # @return [Boolean] whether the staged operations were applied. False
292
+ # when the owner is not saved yet or a request failed; operations that
293
+ # did not succeed stay staged.
294
+ def save(session: SESSION_UNSET)
295
+ return true if @additions.empty? && @removals.empty?
296
+ return false unless owner_persisted?
297
+ return false unless @delegate.respond_to?(:op_add_relation!) && @delegate.respond_to?(:op_remove_relation!)
298
+ applied = with_owner_session(session) do
299
+ if @removals.any?
300
+ next false unless @delegate.send(:op_remove_relation!, @key, @removals.parse_pointers)
301
+ @removals = []
302
+ end
303
+ if @additions.any?
304
+ next false unless @delegate.send(:op_add_relation!, @key, @additions.parse_pointers)
305
+ @additions = []
306
+ end
307
+ true
308
+ end
309
+ return false unless applied
310
+ clear_changes_information
311
+ if @delegate.respond_to?(:clear_attribute_changes, true)
312
+ @delegate.send(:clear_attribute_changes, [@key.to_s])
168
313
  end
314
+ true
169
315
  end
170
316
 
171
317
  # @see #add
@@ -173,5 +319,50 @@ module Parse
173
319
  list.each { |d| add(d) }
174
320
  @collection
175
321
  end
322
+
323
+ private
324
+
325
+ attr_writer :additions, :removals
326
+
327
+ # @return [Boolean] whether the owner has an objectId to query by.
328
+ def owner_saved?
329
+ !(@delegate.respond_to?(:id) && @delegate.id.blank?)
330
+ end
331
+
332
+ # @return [Boolean] whether the owner exists on the server, so a write to
333
+ # its relation can be sent. Follows {Parse::Object#persisted?}: an
334
+ # id-only handle to a stored record counts, but an objectId assigned
335
+ # client-side during a create (`parse_reference precompute:`) does not
336
+ # until the create returns, and neither does a destroyed owner.
337
+ def owner_persisted?
338
+ return false unless owner_saved?
339
+ !@delegate.respond_to?(:persisted?) || @delegate.persisted?
340
+ end
341
+
342
+ # Run the block with the owner's session token set to `session`, then
343
+ # restore the owner's previous token. {SESSION_UNSET} runs it unchanged.
344
+ def with_owner_session(session)
345
+ return yield if session.equal?(SESSION_UNSET)
346
+ token = @delegate.send(:_validate_session_token!, session, :save)
347
+ had = @delegate.instance_variable_defined?(:@_session_token)
348
+ previous = @delegate.instance_variable_get(:@_session_token)
349
+ @delegate.instance_variable_set(:@_session_token, token)
350
+ begin
351
+ yield
352
+ ensure
353
+ if had
354
+ @delegate.instance_variable_set(:@_session_token, previous)
355
+ else
356
+ @delegate.remove_instance_variable(:@_session_token)
357
+ end
358
+ end
359
+ end
360
+
361
+ # ActiveModel reads the current value when a change starts. Read the
362
+ # items directly: going through {#collection} would query the whole
363
+ # relation just to stage an add or remove.
364
+ def _read_attribute(attr)
365
+ attr.to_s == "collection" ? @collection : super
366
+ end
176
367
  end
177
368
  end