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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +290 -17
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
|
@@ -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
|
-
#
|
|
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
|
|
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 :#{
|
|
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
|
|
360
|
-
if data_type
|
|
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.
|
|
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
|
|
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
|
|
884
|
+
raise ArgumentError, "Amount needs to be numeric"
|
|
635
885
|
end
|
|
636
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
863
|
-
# for operations that increment by a certain amount, they come as a hash
|
|
864
|
-
|
|
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
|
-
|
|
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 =
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
942
|
-
#
|
|
943
|
-
#
|
|
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?
|