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
@@ -19,7 +19,228 @@ module Parse
19
19
  # supported in Parse and mapping them between their remote names with their local ruby named attributes.
20
20
  module Properties
21
21
  # These are the base types supported by Parse.
22
- TYPES = [:string, :relation, :integer, :float, :boolean, :date, :array, :file, :geopoint, :polygon, :bytes, :object, :acl, :timezone, :phone, :email, :vector].freeze
22
+ TYPES = [:string, :relation, :integer, :float, :number, :boolean, :date, :array, :file, :geopoint, :polygon, :bytes, :object, :acl, :timezone, :phone, :email, :vector].freeze
23
+ # Local data types stored in a Parse Number column.
24
+ NUMERIC_TYPES = [:integer, :float, :number].freeze
25
+
26
+ # Cast a value for a `:number` property. Parse's Number column holds
27
+ # both integral and fractional values, so the cast keeps whichever the
28
+ # value is: an Integer when it has no fractional part (`5`, `5.0`,
29
+ # `"5.0"`, `BigDecimal("5")`) and a Float otherwise (`4.75`, `"4.5"`).
30
+ # Blank values and values that are not numeric become nil. Non-finite
31
+ # floats (NaN, Infinity) are kept as Floats.
32
+ #
33
+ # @param val [Object] the value to cast.
34
+ # @return [Integer, Float, nil]
35
+ def self.typecast_number(val)
36
+ case val
37
+ when nil, true, false
38
+ nil
39
+ when Integer
40
+ val
41
+ when Float
42
+ integral_or_float(val)
43
+ when String
44
+ str = val.strip
45
+ return nil unless NUMBER_STRING_FORMAT.match?(str)
46
+ str.match?(/[.eE]/) ? integral_or_float(Float(str)) : Integer(str, 10)
47
+ when Numeric
48
+ # BigDecimal, Rational and other Numeric types: compare against the
49
+ # truncated value so an integral BigDecimal stays exact.
50
+ begin
51
+ float = val.to_f
52
+ return float unless float.finite?
53
+ val == val.truncate ? val.truncate.to_i : float
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ else
58
+ val.respond_to?(:to_str) ? typecast_number(val.to_str) : nil
59
+ end
60
+ end
61
+
62
+ # Decimal number literal accepted by {typecast_number} for String input.
63
+ NUMBER_STRING_FORMAT = /\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/.freeze
64
+
65
+ # @!visibility private
66
+ def self.integral_or_float(num)
67
+ return num unless num.finite?
68
+ num == num.floor ? num.to_i : num
69
+ end
70
+
71
+ # Raised when a value assigned to a typed property cannot be converted to
72
+ # that type. Assignments refuse such values instead of storing a guess:
73
+ # before this, `true` assigned to an `:integer` became nil and was sent as
74
+ # a Delete operation that erased the column, and `""` became 0.
75
+ # Values hydrated from a server response (untracked assignment) never
76
+ # raise; an unconvertible server value is kept as-is.
77
+ class TypecastError < ArgumentError; end
78
+
79
+ # Cast a value for an `:integer` property.
80
+ #
81
+ # - nil and blank Strings become nil (a blank form field clears the column).
82
+ # - Integers pass through; Floats and other Numerics truncate with `to_i`.
83
+ # - Strings must be a decimal literal with an integral value: `"5"`,
84
+ # `"5.0"` and `"1e3"` cast, while `"1.5"` and `"abc"` are refused
85
+ # rather than silently becoming 1 or 0.
86
+ # - true/false, NaN, Infinity and values with no numeric meaning raise.
87
+ #
88
+ # @param val [Object] the value to cast.
89
+ # @return [Integer, nil]
90
+ # @raise [TypecastError] when the value cannot be represented as an Integer.
91
+ def self.typecast_integer(val)
92
+ case val
93
+ when nil then nil
94
+ when true, false
95
+ raise TypecastError, "cannot cast boolean #{val.inspect} to an Integer"
96
+ when Integer then val
97
+ when Float
98
+ raise TypecastError, "cannot cast #{val.inspect} to an Integer" unless val.finite?
99
+ val.to_i
100
+ when String
101
+ str = val.strip
102
+ return nil if str.empty?
103
+ unless NUMBER_STRING_FORMAT.match?(str)
104
+ raise TypecastError, "cannot cast #{val.inspect} to an Integer"
105
+ end
106
+ return Integer(str, 10) unless str.match?(/[.eE]/)
107
+ num = Float(str)
108
+ unless num.finite? && num == num.floor
109
+ raise TypecastError, "cannot cast #{val.inspect} to an Integer without losing its fractional part"
110
+ end
111
+ num.to_i
112
+ when Numeric
113
+ begin
114
+ val.to_i
115
+ rescue FloatDomainError, RangeError, NoMethodError
116
+ raise TypecastError, "cannot cast #{val.inspect} to an Integer"
117
+ end
118
+ else
119
+ return typecast_integer(val.to_str) if val.respond_to?(:to_str)
120
+ raise TypecastError, "cannot cast #{val.class} to an Integer" unless val.respond_to?(:to_i)
121
+ val.to_i
122
+ end
123
+ end
124
+
125
+ # Cast a value for a `:float` property.
126
+ #
127
+ # - nil and blank Strings become nil.
128
+ # - Numerics convert with `to_f`; numeric Strings with `Float()`.
129
+ # - true/false, non-numeric Strings, NaN and Infinity raise. JSON has no
130
+ # NaN or Infinity, so those were serialized as null, which Parse Server
131
+ # treats as clearing the column.
132
+ #
133
+ # @param val [Object] the value to cast.
134
+ # @return [Float, nil]
135
+ # @raise [TypecastError] when the value is not a finite number.
136
+ def self.typecast_float(val)
137
+ num = case val
138
+ when nil then return nil
139
+ when true, false
140
+ raise TypecastError, "cannot cast boolean #{val.inspect} to a Float"
141
+ when Numeric
142
+ begin
143
+ val.to_f
144
+ rescue StandardError
145
+ raise TypecastError, "cannot cast #{val.inspect} to a Float"
146
+ end
147
+ when String
148
+ str = val.strip
149
+ return nil if str.empty?
150
+ unless NUMBER_STRING_FORMAT.match?(str)
151
+ raise TypecastError, "cannot cast #{val.inspect} to a Float"
152
+ end
153
+ Float(str)
154
+ else
155
+ return typecast_float(val.to_str) if val.respond_to?(:to_str)
156
+ raise TypecastError, "cannot cast #{val.class} to a Float" unless val.respond_to?(:to_f)
157
+ val.to_f
158
+ end
159
+ unless num.is_a?(Float) && num.finite?
160
+ raise TypecastError, "cannot store non-finite #{num.inspect} (JSON would send it as null)"
161
+ end
162
+ num
163
+ end
164
+
165
+ # Strict variant of {typecast_number} used by property assignment: nil
166
+ # and blank Strings become nil, while values {typecast_number} would
167
+ # turn into nil (booleans, non-numeric Strings) and non-finite Floats
168
+ # raise instead of being sent as a Delete or a JSON null.
169
+ # @param val [Object] the value to cast.
170
+ # @return [Integer, Float, nil]
171
+ # @raise [TypecastError]
172
+ def self.typecast_number!(val)
173
+ return nil if val.nil? || (val.is_a?(String) && val.strip.empty?)
174
+ if val == true || val == false
175
+ raise TypecastError, "cannot cast boolean #{val.inspect} to a Number"
176
+ end
177
+ num = typecast_number(val)
178
+ raise TypecastError, "cannot cast #{val.inspect} to a Number" if num.nil?
179
+ if num.is_a?(Float) && !num.finite?
180
+ raise TypecastError, "cannot store non-finite #{num.inspect} (JSON would send it as null)"
181
+ end
182
+ num
183
+ end
184
+
185
+ # String forms read as false for a `:boolean` property, in addition to
186
+ # ActiveModel's set ("0", "f", "false", "off", and their upper-case forms).
187
+ EXTRA_FALSE_STRINGS = %w[no n].freeze
188
+
189
+ # Property types whose values are mutable containers. In-place edits to
190
+ # these (`obj.meta["k"] = 1`) do not pass through the property setter, so
191
+ # they are detected by comparing against a snapshot instead.
192
+ MUTABLE_TRACKED_TYPES = [:object, :array].freeze
193
+
194
+ # Copy a value deeply enough that in-place edits to the original cannot
195
+ # reach the copy. Hashes, Arrays and unfrozen Strings are copied
196
+ # recursively; every other value (numbers, dates, Parse objects) is
197
+ # shared, since replacing those goes through a setter.
198
+ # @param value [Object]
199
+ # @return [Object]
200
+ # @!visibility private
201
+ def self.deep_copy_value(value)
202
+ case value
203
+ when Hash
204
+ copy = value.dup
205
+ copy.each_key { |k| copy[k] = deep_copy_value(copy[k]) }
206
+ copy
207
+ when Array
208
+ value.map { |v| deep_copy_value(v) }
209
+ when String
210
+ value.frozen? ? value : value.dup
211
+ else
212
+ value
213
+ end
214
+ end
215
+
216
+ # Encode Ruby time values nested anywhere inside an `:array` or
217
+ # `:object` value as Parse Date dictionaries
218
+ # (`{"__type" => "Date", "iso" => ...}`), matching how a top-level
219
+ # `:date` property is stored. Without this, a Time inside an array or
220
+ # hash serialized through `as_json` as a bare ISO-8601 String, so the
221
+ # server stored text rather than a Date. Returns new containers; the
222
+ # input is not modified.
223
+ # @param value [Object]
224
+ # @return [Object]
225
+ # @!visibility private
226
+ def self.encode_nested_dates(value)
227
+ case value
228
+ when Parse::Date then { Parse::Model::TYPE_FIELD => Parse::Model::TYPE_DATE, "iso" => value.iso }
229
+ when ::Time, ::DateTime, ::Date, ActiveSupport::TimeWithZone
230
+ encode_nested_dates(value.parse_date)
231
+ when Hash
232
+ value.each_with_object({}) { |(k, v), h| h[k] = encode_nested_dates(v) }
233
+ when Parse::PointerCollectionProxy
234
+ value
235
+ when Parse::CollectionProxy
236
+ value.to_a.map { |v| encode_nested_dates(v) }
237
+ when Array
238
+ value.map { |v| encode_nested_dates(v) }
239
+ else
240
+ value
241
+ end
242
+ end
243
+
23
244
  # These are the base mappings of the remote field name types.
24
245
  BASE = { objectId: :string, createdAt: :date, updatedAt: :date, ACL: :acl }.freeze
25
246
  # The list of properties that are part of all objects
@@ -164,7 +385,13 @@ module Parse
164
385
  # than as an Atlas index error much later.
165
386
  VECTOR_SIMILARITIES = %w[euclidean cosine dotProduct].freeze
166
387
 
167
- # `{ dimensions: Integer, provider: Symbol, model: String, similarity: Symbol }`.
388
+ # Index-side quantization Atlas vectorSearch can apply to a float
389
+ # vector field. Declared per `:vector` property; affects only the
390
+ # generated index definition, never stored data.
391
+ VECTOR_QUANTIZATIONS = %w[scalar binary].freeze
392
+
393
+ # `{ dimensions: Integer, provider: Symbol, model: String, similarity: Symbol,
394
+ # quantization: Symbol }`.
168
395
  # `dimensions:` is required; the rest are optional and only carry
169
396
  # meaning for the embedding provider plumbing layered above this
170
397
  # type. Consumed by `Parse::Embeddings` and
@@ -258,7 +485,7 @@ module Parse
258
485
  data_type = :timezone if data_type == :time_zone
259
486
  data_type = :geopoint if data_type == :geo_point
260
487
  data_type = :polygon if data_type == :geo_polygon
261
- data_type = :integer if data_type == :int || data_type == :number
488
+ data_type = :integer if data_type == :int
262
489
  data_type = :phone if data_type == :phone_number || data_type == :mobile || data_type == :e164
263
490
  data_type = :email if data_type == :email_address
264
491
 
@@ -316,7 +543,9 @@ module Parse
316
543
  "(remote field :#{parse_field}). Set Parse.strict_property_redefinition = false " \
317
544
  "to fall back to warn-and-ignore behavior."
318
545
  end
319
- warn "Property #{self}##{key} already defined with data type :#{data_type}. Will be ignored."
546
+ warn "Property #{self}##{key} already defined with data type :#{existing_type} " \
547
+ "(remote field :#{existing_parse_field}); redeclaration as :#{data_type} " \
548
+ "(remote field :#{parse_field}) will be ignored."
320
549
  return false
321
550
  end
322
551
  # We keep the list of fields that are on the remote Parse store
@@ -336,6 +565,8 @@ module Parse
336
565
  self.fields.merge!(key => data_type, parse_field => data_type)
337
566
  # This creates a mapping between the local field and the remote field name.
338
567
  self.field_map.merge!(key => parse_field)
568
+ # Derived per-class caches (query field aliases) rebuild on next use.
569
+ Parse::Model.model_registry_changed!
339
570
 
340
571
  # Store the property description for agent metadata if provided
341
572
  if opts[:_description].present?
@@ -356,8 +587,8 @@ module Parse
356
587
 
357
588
  # if the field is marked as required, then add validations
358
589
  if opts[:required]
359
- # if integer or float, validate that it's a number
360
- if data_type == :integer || data_type == :float
590
+ # if integer, float or number, validate that it's a number
591
+ if NUMERIC_TYPES.include?(data_type)
361
592
  validates_numericality_of key
362
593
  end
363
594
  # validate that it is not empty
@@ -431,11 +662,22 @@ module Parse
431
662
  "#{VECTOR_SIMILARITIES.inspect} (got #{similarity.inspect})."
432
663
  end
433
664
 
665
+ # Optional index-side quantization (Atlas automatic quantization).
666
+ # Off by default. Only the generated vectorSearch index carries
667
+ # it; stored vectors and the write path stay full-precision.
668
+ quantization = opts[:quantization]
669
+ if quantization && !VECTOR_QUANTIZATIONS.include?(quantization.to_s)
670
+ raise ArgumentError,
671
+ "Property #{self}##{key} :vector `quantization:` must be one of " \
672
+ "#{VECTOR_QUANTIZATIONS.inspect} (got #{quantization.inspect})."
673
+ end
674
+
434
675
  vector_properties[key] = {
435
676
  dimensions: dims,
436
677
  provider: opts[:provider],
437
678
  model: opts[:model],
438
679
  similarity: similarity,
680
+ quantization: quantization&.to_sym,
439
681
  searchable: searchable,
440
682
  }.freeze
441
683
 
@@ -595,18 +837,24 @@ module Parse
595
837
 
596
838
  # if the value is a String (like an iso8601 date) and the data type of
597
839
  # this object is :date, then let's be nice and create a parse date for it.
840
+ # A String here came from server data (tracked assignment already
841
+ # converts), so converting it is not a change and must not mark
842
+ # the record dirty.
598
843
  if value.is_a?(String) && data_type == :date
599
844
  value = format_value(key, value, data_type)
600
845
  instance_variable_set ivar, value
601
- send will_change_method
602
846
  end
847
+ # Remember the value as last seen so in-place edits can be
848
+ # detected later (see #_detect_in_place_changes!).
849
+ _snapshot_mutable_value!(key, value) if MUTABLE_TRACKED_TYPES.include?(data_type)
850
+
603
851
  # finally return the value
604
852
  if symbolize_value
605
853
  if data_type == :string
606
854
  return value.respond_to?(:to_sym) ? value.to_sym : value
607
855
  elsif data_type == :array && value.is_a?(Array)
608
856
  # value.map(&:to_sym)
609
- return value.compact.map { |m| m.respond_to?(:to_sym) ? m.to_sym : m }
857
+ return value.map { |m| m.respond_to?(:to_sym) ? m.to_sym : m }
610
858
  end
611
859
  end
612
860
 
@@ -624,22 +872,18 @@ module Parse
624
872
  unless opts[:scopes] == false
625
873
  scope key, ->(opts = {}) { query(opts.merge(key => true)) }
626
874
  end
627
- elsif data_type == :integer || data_type == :float
875
+ elsif NUMERIC_TYPES.include?(data_type)
628
876
  if self.method_defined?("#{key}_increment!")
629
877
  warn "Creating increment helper :#{key}_increment!. Will overwrite existing method #{self}##{key}_increment!."
630
878
  end
631
879
 
880
+ # op_increment! sends the atomic operation and updates the local
881
+ # value itself, so this helper must not add the amount again.
632
882
  define_method("#{key}_increment!") do |amount = 1|
633
883
  unless amount.is_a?(Numeric)
634
- raise ArgumentError, "Amount needs to be an integer"
884
+ raise ArgumentError, "Amount needs to be numeric"
635
885
  end
636
- result = self.op_increment!(key, amount)
637
- if result
638
- new_value = send(key).to_i + amount
639
- # set the updated value, with no dirty tracking
640
- self.send set_attribute_method, new_value, false
641
- end
642
- result
886
+ self.op_increment!(key, amount)
643
887
  end
644
888
 
645
889
  if self.method_defined?("#{key}_decrement!")
@@ -648,7 +892,7 @@ module Parse
648
892
 
649
893
  define_method("#{key}_decrement!") do |amount = -1|
650
894
  unless amount.is_a?(Numeric)
651
- raise ArgumentError, "Amount needs to be an integer"
895
+ raise ArgumentError, "Amount needs to be numeric"
652
896
  end
653
897
  amount = -amount if amount > 0
654
898
  send("#{key}_increment!", amount)
@@ -670,8 +914,25 @@ module Parse
670
914
  define_method(set_attribute_method) do |val, track = true|
671
915
  # Each value has a data type, based on that we can treat the incoming
672
916
  # value as input, and format it to the correct storage format. This method is
673
- # defined in this file (instance method)
674
- val = format_value(key, val, data_type)
917
+ # defined in this file (instance method).
918
+ #
919
+ # A tracked assignment of a value the type cannot represent raises
920
+ # TypecastError rather than storing a guess. Untracked assignment
921
+ # (hydration from a server response) keeps the raw value instead,
922
+ # so a schema mismatch never makes a fetch fail or drop data; an
923
+ # untracked value is not dirty and is never sent back.
924
+ begin
925
+ val = format_value(key, val, data_type)
926
+ rescue Parse::Properties::TypecastError
927
+ raise if track == true
928
+ end
929
+ # Record any in-place edit made to the current value before its
930
+ # baseline is dropped: `obj.meta["a"] = 2; obj.meta = obj.meta`
931
+ # would otherwise lose the edit, since the reassigned value equals
932
+ # the (already edited) current one.
933
+ _detect_in_place_changes! if track == true && @_mutable_snapshots&.key?(key)
934
+ # A replaced value starts a new in-place tracking baseline.
935
+ @_mutable_snapshots&.delete(key)
675
936
  # if dirty trackin is enabled, call the ActiveModel required method of _will_change!
676
937
  # this will grab the current value and keep a copy of it - but we only do this if
677
938
  # the new value being set is different from the current value stored.
@@ -801,6 +1062,155 @@ module Parse
801
1062
  apply_attributes!(hash, dirty_track: true)
802
1063
  end
803
1064
 
1065
+ # Run a cast, re-raising a {TypecastError} with the class and property
1066
+ # name so the failing assignment is identifiable.
1067
+ # @!visibility private
1068
+ def _typecast_for(key)
1069
+ yield
1070
+ rescue Parse::Properties::TypecastError => e
1071
+ raise Parse::Properties::TypecastError, "#{self.class}##{key}: #{e.message}"
1072
+ end
1073
+ private :_typecast_for
1074
+
1075
+ # ----------------------------------------------------------------
1076
+ # In-place change detection for :object and :array properties
1077
+ # ----------------------------------------------------------------
1078
+ #
1079
+ # ActiveModel dirty tracking only sees assignments through a setter, so
1080
+ # `obj.meta["k"] = 1` (or editing a Hash inside an array) was never
1081
+ # saved. Rather than marking these properties dirty on every read (the
1082
+ # ActiveRecord approach for serialized columns, which would make every
1083
+ # read look like a change), the getter records a deep copy of the value
1084
+ # the first time it hands it out. {#changed}, {#changes},
1085
+ # {#changed_attributes} and {#attribute_changed?} compare the live value
1086
+ # against that copy and, on a difference, mark the property changed with
1087
+ # the copy as its previous value. `rollback!` therefore restores the
1088
+ # pre-edit value. The baseline is reset by any assignment, and is
1089
+ # retaken from the current value by `changes_applied` (after a save) and
1090
+ # `clear_changes_information`. Only plain `:array` proxies take part;
1091
+ # pointer and relation collections keep their own tracking.
1092
+
1093
+ # @!visibility private
1094
+ def _snapshot_mutable_value!(key, value)
1095
+ snapshots = (@_mutable_snapshots ||= {})
1096
+ return if snapshots.key?(key)
1097
+ data = _mutable_value_data(value)
1098
+ snapshots[key] = Parse::Properties.deep_copy_value(data) unless data.nil?
1099
+ end
1100
+
1101
+ # @!visibility private
1102
+ def _mutable_value_data(value)
1103
+ if value.instance_of?(Parse::CollectionProxy)
1104
+ value.instance_variable_get(:@collection)
1105
+ elsif value.is_a?(Hash)
1106
+ value
1107
+ end
1108
+ end
1109
+
1110
+ # @!visibility private
1111
+ def _reset_mutable_snapshots!
1112
+ @_mutable_snapshots = {}
1113
+ self.class.field_map.each_key { |key| _rebaseline_mutable_value!(key) }
1114
+ end
1115
+
1116
+ # Retake one property's baseline from its current value.
1117
+ # @!visibility private
1118
+ def _rebaseline_mutable_value!(key)
1119
+ key = key.to_sym
1120
+ return unless MUTABLE_TRACKED_TYPES.include?(self.class.fields[key])
1121
+ snapshots = (@_mutable_snapshots ||= {})
1122
+ snapshots.delete(key)
1123
+ ivar = :"@#{key}"
1124
+ return unless instance_variable_defined?(ivar)
1125
+ data = _mutable_value_data(instance_variable_get(ivar))
1126
+ snapshots[key] = Parse::Properties.deep_copy_value(data) unless data.nil?
1127
+ end
1128
+
1129
+ # Mark :object/:array properties edited in place as changed, with the
1130
+ # last-seen copy as the previous value.
1131
+ # @!visibility private
1132
+ def _detect_in_place_changes!
1133
+ return if @_detecting_in_place_changes
1134
+ snapshots = @_mutable_snapshots
1135
+ return if snapshots.nil? || snapshots.empty?
1136
+ @_detecting_in_place_changes = true
1137
+ begin
1138
+ snapshots.each do |key, snapshot|
1139
+ ivar = :"@#{key}"
1140
+ current = instance_variable_get(ivar)
1141
+ data = _mutable_value_data(current)
1142
+ next if data.nil? || data == snapshot
1143
+ next if mutations_from_database.changed?(key.to_s)
1144
+ original = if current.instance_of?(Parse::CollectionProxy)
1145
+ Parse::CollectionProxy.new(Parse::Properties.deep_copy_value(snapshot), delegate: self, key: key)
1146
+ else
1147
+ Parse::Properties.deep_copy_value(snapshot)
1148
+ end
1149
+ # `<key>_will_change!` records a clone of the current value as the
1150
+ # previous one, so present the snapshot as current for that call.
1151
+ instance_variable_set(ivar, original)
1152
+ begin
1153
+ send(:"#{key}_will_change!")
1154
+ ensure
1155
+ instance_variable_set(ivar, current)
1156
+ end
1157
+ end
1158
+ ensure
1159
+ @_detecting_in_place_changes = false
1160
+ end
1161
+ end
1162
+ private :_snapshot_mutable_value!, :_mutable_value_data, :_reset_mutable_snapshots!,
1163
+ :_rebaseline_mutable_value!, :_detect_in_place_changes!
1164
+
1165
+ # @return [Array<String>] changed attribute names, including :object and
1166
+ # :array properties edited in place.
1167
+ def changed
1168
+ _detect_in_place_changes!
1169
+ super
1170
+ end
1171
+
1172
+ # @return [ActiveSupport::HashWithIndifferentAccess] see ActiveModel::Dirty#changes.
1173
+ def changes
1174
+ _detect_in_place_changes!
1175
+ super
1176
+ end
1177
+
1178
+ # @return [ActiveSupport::HashWithIndifferentAccess] see ActiveModel::Dirty#changed_attributes.
1179
+ def changed_attributes
1180
+ _detect_in_place_changes!
1181
+ super
1182
+ end
1183
+
1184
+ # @see ActiveModel::Dirty#attribute_changed?
1185
+ def attribute_changed?(attr_name, **options)
1186
+ _detect_in_place_changes!
1187
+ super
1188
+ end
1189
+
1190
+ # Clears dirty tracking and retakes the in-place baselines from the
1191
+ # current values. See ActiveModel::Dirty#changes_applied.
1192
+ def changes_applied
1193
+ super
1194
+ _reset_mutable_snapshots!
1195
+ end
1196
+
1197
+ # Clears dirty tracking and retakes the in-place baselines from the
1198
+ # current values. See ActiveModel::Dirty#clear_changes_information.
1199
+ def clear_changes_information
1200
+ super
1201
+ _reset_mutable_snapshots!
1202
+ end
1203
+
1204
+ # Forgetting one attribute's change (`clear_attribute_change!`, and the
1205
+ # final step of `restore_attributes`) also retakes its baseline, so the
1206
+ # current value is not reported as an in-place edit afterwards.
1207
+ # @!visibility private
1208
+ def clear_attribute_change(attr_name)
1209
+ super
1210
+ _rebaseline_mutable_value!(attr_name)
1211
+ end
1212
+ private :clear_attribute_change
1213
+
804
1214
  # Returns a hash of attributes for properties that have changed. This will
805
1215
  # not include any of the base attributes (ex. id, created_at, etc).
806
1216
  # This method helps generate the change payload that will be sent when saving
@@ -821,10 +1231,17 @@ module Parse
821
1231
  # if it is a Parse::PointerCollectionProxy, then make sure we get a list of pointers.
822
1232
  h[remote_field] = h[remote_field].parse_pointers if h[remote_field].is_a?(Parse::PointerCollectionProxy)
823
1233
  # For regular CollectionProxy arrays containing Parse objects, convert to pointers for storage
1234
+ # Nested times are encoded as Parse Dates first, since as_json would
1235
+ # turn them into plain ISO-8601 Strings.
824
1236
  if h[remote_field].is_a?(Parse::CollectionProxy) && !h[remote_field].is_a?(Parse::PointerCollectionProxy)
825
- h[remote_field] = h[remote_field].as_json(pointers_only: true)
1237
+ items = Parse::Properties.encode_nested_dates(h[remote_field].to_a)
1238
+ h[remote_field] = Parse::CollectionProxy.new(items).as_json(pointers_only: true)
826
1239
  end
827
1240
  h[remote_field] = h[remote_field].pointer if h[remote_field].respond_to?(:pointer)
1241
+ # Times nested inside arrays and objects are sent as Parse Dates.
1242
+ if MUTABLE_TRACKED_TYPES.include?(fields[key]) && (h[remote_field].is_a?(Array) || h[remote_field].is_a?(Hash))
1243
+ h[remote_field] = Parse::Properties.encode_nested_dates(h[remote_field])
1244
+ end
828
1245
  end
829
1246
  h
830
1247
  end
@@ -859,9 +1276,14 @@ module Parse
859
1276
  original_items = (instance_variable_get(ivar) || []).to_a
860
1277
  objects.reject! { |r| original_items.include?(r) }
861
1278
  val = original_items + objects
862
- elsif "Increment" == op && data_type == :integer || data_type == :integer
863
- # for operations that increment by a certain amount, they come as a hash
864
- val = (instance_variable_get(ivar) || 0) + (val["amount"] || 0).to_i
1279
+ elsif "Increment" == op && NUMERIC_TYPES.include?(data_type)
1280
+ # for operations that increment by a certain amount, they come as a hash.
1281
+ # The sum is cast to the property type by format_value afterwards.
1282
+ current = instance_variable_get(ivar)
1283
+ current = 0 unless current.is_a?(Numeric)
1284
+ amount = val["amount"]
1285
+ amount = 0 unless amount.is_a?(Numeric)
1286
+ val = current + amount
865
1287
  end
866
1288
  val
867
1289
  end
@@ -881,12 +1303,22 @@ module Parse
881
1303
 
882
1304
  case data_type
883
1305
  when :object
884
- val = val.with_indifferent_access if val.is_a?(Hash)
1306
+ # Copy so the property never shares containers with the caller's
1307
+ # hash: an edit through one must not silently change the other.
1308
+ val = Parse::Properties.deep_copy_value(val).with_indifferent_access if val.is_a?(Hash)
885
1309
  when :array
886
- # All "array" types use a collection proxy
1310
+ # All "array" types use a collection proxy. nil elements are kept:
1311
+ # dropping them shifted every later element to a new index.
887
1312
  val = val.to_a if val.is_a?(Parse::CollectionProxy) #all objects must be in array form
888
- val = [val] unless val.is_a?(Array) #all objects must be in array form
889
- val.compact! #remove any nil
1313
+ val = if val.nil?
1314
+ []
1315
+ elsif val.is_a?(Array)
1316
+ # Copy so the proxy does not alias the caller's array (or another
1317
+ # proxy's backing store).
1318
+ Parse::Properties.deep_copy_value(val)
1319
+ else
1320
+ [val]
1321
+ end
890
1322
  val = Parse::CollectionProxy.new val, delegate: self, key: key
891
1323
  when :geopoint
892
1324
  val = Parse::GeoPoint.new(val) unless val.blank?
@@ -905,11 +1337,7 @@ module Parse
905
1337
  val = Parse::Bytes.new(val)
906
1338
  end
907
1339
  when :integer
908
- if val.nil? || val.respond_to?(:to_i) == false
909
- val = nil
910
- else
911
- val = val.to_i
912
- end
1340
+ val = _typecast_for(key) { Parse::Properties.typecast_integer(val) }
913
1341
  when :boolean
914
1342
  # Coerce via ActiveModel's boolean caster rather than Ruby
915
1343
  # truthiness. Plain `val ? true : false` treats every non-nil,
@@ -919,11 +1347,27 @@ module Parse
919
1347
  # wrong way (e.g. an `archived` or admin gate). ActiveModel maps the
920
1348
  # string forms ("false"/"0"/"f"/"off"/"") to false/nil. Parse wire
921
1349
  # JSON already sends real booleans, which pass through unchanged.
922
- val = val.nil? ? nil : BOOLEAN_CASTER.cast(val)
1350
+ # "no" and "n" are also false (ActiveModel would cast them to true).
1351
+ val = if val.nil?
1352
+ nil
1353
+ elsif val.is_a?(String) && EXTRA_FALSE_STRINGS.include?(val.strip.downcase)
1354
+ false
1355
+ else
1356
+ BOOLEAN_CASTER.cast(val)
1357
+ end
923
1358
  when :string
924
- val = val.to_s unless val.blank?
1359
+ # Every non-nil value becomes a String (false becomes "false", as
1360
+ # true already became "true"). Arrays and Hashes are refused: their
1361
+ # String form is Ruby inspect output, not data.
1362
+ if val.is_a?(Array) || val.is_a?(Hash) || val.is_a?(Parse::CollectionProxy)
1363
+ _typecast_for(key) { raise TypecastError, "cannot store a #{val.class} in a String property" }
1364
+ elsif !val.nil? && !val.is_a?(String)
1365
+ val = val.to_s
1366
+ end
925
1367
  when :float
926
- val = val.to_f unless val.blank?
1368
+ val = _typecast_for(key) { Parse::Properties.typecast_float(val) }
1369
+ when :number
1370
+ val = _typecast_for(key) { Parse::Properties.typecast_number!(val) }
927
1371
  when :acl
928
1372
  # ACL types go through a special conversion
929
1373
  val = ACL.typecast(val, self)
@@ -938,9 +1382,14 @@ module Parse
938
1382
  elsif val.is_a?(String)
939
1383
  # if it's a string, try parsing the date
940
1384
  val = (stripped = val.strip).present? ? Parse::Date.parse(stripped) : nil
941
- #elsif val.present?
942
- # pus "[Parse::Stack] Invalid date value '#{val}' assigned to #{self.class}##{key}, it should be a Parse::Date or DateTime."
943
- # raise ValueError, "Invalid date value '#{val}' assigned to #{self.class}##{key}, it should be a Parse::Date or DateTime."
1385
+ elsif !val.nil? && !val.is_a?(Parse::Date)
1386
+ # A Numeric (or any other value) was previously stored raw and sent
1387
+ # as a plain number. Refuse it: an epoch value is ambiguous
1388
+ # (seconds or milliseconds), so convert with Time.at explicitly.
1389
+ _typecast_for(key) do
1390
+ raise TypecastError, "cannot cast #{val.class} to a Date; pass a Time, DateTime, " \
1391
+ "Parse::Date or ISO-8601 String"
1392
+ end
944
1393
  end
945
1394
  when :timezone
946
1395
  val = Parse::TimeZone.new(val) if val.present?