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
|
@@ -621,6 +621,13 @@ module Parse
|
|
|
621
621
|
ambient = Parse.current_session_token
|
|
622
622
|
client_opts[:session_token] = ambient if ambient.is_a?(String) && !ambient.empty?
|
|
623
623
|
end
|
|
624
|
+
# Inside `Parse.with_session(nil)` the caller is anonymous. A worker
|
|
625
|
+
# thread may not see that fiber state, so carry it explicitly rather
|
|
626
|
+
# than letting the worker fall back to the master key.
|
|
627
|
+
if !client_opts.key?(:session_token) && !client_opts.key?(:use_master_key) &&
|
|
628
|
+
Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
|
|
629
|
+
client_opts[:use_master_key] = false
|
|
630
|
+
end
|
|
624
631
|
|
|
625
632
|
if type == :batch
|
|
626
633
|
# use a .in query with the given id as a list
|
|
@@ -21,7 +21,7 @@ module Parse
|
|
|
21
21
|
result = { type: v.to_s.camelize }
|
|
22
22
|
# if it is a basic column property, find the right datatype
|
|
23
23
|
case v
|
|
24
|
-
when :integer, :float
|
|
24
|
+
when :integer, :float, :number
|
|
25
25
|
result[:type] = Parse::Model::TYPE_NUMBER
|
|
26
26
|
when :geopoint, :geo_point
|
|
27
27
|
result[:type] = Parse::Model::TYPE_GEOPOINT
|
|
@@ -31,8 +31,10 @@ module Parse
|
|
|
31
31
|
result = { type: Parse::Model::TYPE_POINTER, targetClass: references[k] }
|
|
32
32
|
when :acl
|
|
33
33
|
result[:type] = Parse::Model::ACL
|
|
34
|
-
when :timezone, :time_zone
|
|
35
|
-
result[:type] = "String" # no
|
|
34
|
+
when :timezone, :time_zone, :phone, :email
|
|
35
|
+
result[:type] = "String" # no native Parse column type
|
|
36
|
+
when :vector
|
|
37
|
+
result[:type] = "Array" # embeddings are stored as a number array
|
|
36
38
|
else
|
|
37
39
|
result[:type] = v.to_s.camelize
|
|
38
40
|
end
|
|
@@ -104,6 +104,12 @@ module Parse
|
|
|
104
104
|
type: type_str,
|
|
105
105
|
}.freeze
|
|
106
106
|
|
|
107
|
+
if generated_vector_search_indexes.any? { |e| e[:name] == name_str }
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"#{self}.mongo_search_index #{name_str.inspect} is already declared by " \
|
|
110
|
+
"vector_search_index. Each name may have one declaration per class."
|
|
111
|
+
end
|
|
112
|
+
|
|
107
113
|
existing = mongo_search_index_declarations.find { |d| d[:name] == name_str }
|
|
108
114
|
if existing
|
|
109
115
|
# Idempotent redeclaration with identical content — common in
|
|
@@ -123,6 +129,63 @@ module Parse
|
|
|
123
129
|
declaration
|
|
124
130
|
end
|
|
125
131
|
|
|
132
|
+
# Declare a vectorSearch index whose definition is GENERATED from the
|
|
133
|
+
# model's declarations by {Parse::VectorSearch::IndexDefinition.build}
|
|
134
|
+
# (vector path, dimensions, similarity, quantization, agent_searchable
|
|
135
|
+
# filter fields, agent_tenant_scope field), instead of written by hand.
|
|
136
|
+
#
|
|
137
|
+
# The definition is computed when the migrator plans, not at class
|
|
138
|
+
# load, so `agent_searchable` / `agent_tenant_scope` may be declared in
|
|
139
|
+
# any order. Nothing is applied until {#apply_search_indexes!} runs.
|
|
140
|
+
#
|
|
141
|
+
# @param name [String] the search index name ({INDEX_NAME_PATTERN}).
|
|
142
|
+
# @param field [Symbol, nil] the `:vector` property; may be omitted
|
|
143
|
+
# when the class has exactly one searchable vector.
|
|
144
|
+
# @return [Hash] the registered `{ name:, field: }` entry (frozen).
|
|
145
|
+
# @raise [ArgumentError] on a bad name, or a name already declared
|
|
146
|
+
# with a different field or via {#mongo_search_index}.
|
|
147
|
+
def vector_search_index(name, field: nil)
|
|
148
|
+
name_str = name.to_s
|
|
149
|
+
unless name_str.match?(INDEX_NAME_PATTERN)
|
|
150
|
+
raise ArgumentError,
|
|
151
|
+
"#{self}.vector_search_index name #{name.inspect} must match #{INDEX_NAME_PATTERN.inspect}"
|
|
152
|
+
end
|
|
153
|
+
if mongo_search_index_declarations.any? { |d| d[:name] == name_str }
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"#{self}.vector_search_index #{name_str.inspect} is already declared by " \
|
|
156
|
+
"mongo_search_index. Each name may have one declaration per class."
|
|
157
|
+
end
|
|
158
|
+
entry = { name: name_str, field: field&.to_sym }.freeze
|
|
159
|
+
existing = generated_vector_search_indexes.find { |e| e[:name] == name_str }
|
|
160
|
+
if existing
|
|
161
|
+
return existing if existing == entry
|
|
162
|
+
raise ArgumentError,
|
|
163
|
+
"#{self}.vector_search_index #{name_str.inspect} re-declared for a different field."
|
|
164
|
+
end
|
|
165
|
+
generated_vector_search_indexes << entry
|
|
166
|
+
entry
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [Array<Hash>] `{ name:, field: }` entries registered by
|
|
170
|
+
# {#vector_search_index}.
|
|
171
|
+
def generated_vector_search_indexes
|
|
172
|
+
@generated_vector_search_indexes ||= []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Materialize {#generated_vector_search_indexes} into migrator
|
|
176
|
+
# declarations (`{ name:, definition:, type: "vectorSearch" }`),
|
|
177
|
+
# generating each definition from the current model declarations.
|
|
178
|
+
# @return [Array<Hash>]
|
|
179
|
+
def generated_vector_search_index_declarations
|
|
180
|
+
generated_vector_search_indexes.map do |entry|
|
|
181
|
+
{
|
|
182
|
+
name: entry[:name],
|
|
183
|
+
definition: Parse::VectorSearch::IndexDefinition.build(self, field: entry[:field]),
|
|
184
|
+
type: "vectorSearch",
|
|
185
|
+
}.freeze
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
126
189
|
# Dry-run reconciliation between declared search indexes and what
|
|
127
190
|
# exists on Atlas. Delegates to {Parse::Schema::SearchIndexMigrator}.
|
|
128
191
|
# @return [Hash] see {Parse::Schema::SearchIndexMigrator#plan}
|
|
@@ -225,7 +225,7 @@ module Parse
|
|
|
225
225
|
|
|
226
226
|
raw_hits = Parse::VectorSearch.search(
|
|
227
227
|
parse_class,
|
|
228
|
-
field: resolved_field,
|
|
228
|
+
field: vector_storage_path(resolved_field),
|
|
229
229
|
query_vector: query_vector,
|
|
230
230
|
k: k,
|
|
231
231
|
num_candidates: num_candidates,
|
|
@@ -318,7 +318,7 @@ module Parse
|
|
|
318
318
|
filter: lex[:filter], fuzzy: lex[:fuzzy],
|
|
319
319
|
},
|
|
320
320
|
vector: {
|
|
321
|
-
query_vector: qv, field: field_sym, index: vector_index,
|
|
321
|
+
query_vector: qv, field: vector_storage_path(field_sym), index: vector_index,
|
|
322
322
|
num_candidates: vec[:num_candidates], filter: vec[:filter],
|
|
323
323
|
vector_filter: vec[:vector_filter],
|
|
324
324
|
candidate_limit: vec[:candidate_limit],
|
|
@@ -332,6 +332,22 @@ module Parse
|
|
|
332
332
|
build_hybrid_hits(fused)
|
|
333
333
|
end
|
|
334
334
|
|
|
335
|
+
# The column a `:vector` property is stored under, which is what an
|
|
336
|
+
# Atlas index path and a `$vectorSearch` path must name: the property's
|
|
337
|
+
# `field_map` entry (an explicit `field:` alias, or the lowerCamelCase
|
|
338
|
+
# form of the Ruby name). `property :body_embedding, :vector` is saved
|
|
339
|
+
# as `bodyEmbedding`. Before 5.8 the SDK searched, discovered, and
|
|
340
|
+
# drift-checked indexes by the Ruby name, so a multi-word vector
|
|
341
|
+
# property matched no vectors.
|
|
342
|
+
#
|
|
343
|
+
# @param field [Symbol, String] the `:vector` property's Ruby name.
|
|
344
|
+
# @return [String]
|
|
345
|
+
def vector_storage_path(field)
|
|
346
|
+
sym = field.to_sym
|
|
347
|
+
fmap = respond_to?(:field_map) ? field_map : {}
|
|
348
|
+
(fmap[sym] || sym.to_s.columnize).to_s
|
|
349
|
+
end
|
|
350
|
+
|
|
335
351
|
private
|
|
336
352
|
|
|
337
353
|
def resolve_vector_field!(field)
|
|
@@ -450,7 +466,7 @@ module Parse
|
|
|
450
466
|
"#{self}.find_similar: no index: given and Parse::AtlasSearch " \
|
|
451
467
|
"could not be loaded; pass an explicit index: kwarg."
|
|
452
468
|
end
|
|
453
|
-
idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: field)
|
|
469
|
+
idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: vector_storage_path(field))
|
|
454
470
|
if idx.nil?
|
|
455
471
|
raise IndexNotResolved,
|
|
456
472
|
"#{self}.find_similar: no vectorSearch index found covering " \
|
|
@@ -473,7 +489,7 @@ module Parse
|
|
|
473
489
|
return if Parse::VectorSearch.index_drift_policy == :ignore
|
|
474
490
|
begin
|
|
475
491
|
require_relative "../../atlas_search"
|
|
476
|
-
idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: field)
|
|
492
|
+
idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: vector_storage_path(field))
|
|
477
493
|
rescue StandardError, LoadError
|
|
478
494
|
return
|
|
479
495
|
end
|
|
@@ -497,7 +513,9 @@ module Parse
|
|
|
497
513
|
# property's declared `dimensions:`.
|
|
498
514
|
# 2. `similarity` vs the property's declared `similarity:` (only
|
|
499
515
|
# when both sides declare one).
|
|
500
|
-
# 3.
|
|
516
|
+
# 3. `quantization` vs the property's declared `quantization:`
|
|
517
|
+
# (absent on either side means none).
|
|
518
|
+
# 4. When the class registers an `agent_tenant_scope`, the scope
|
|
501
519
|
# field must appear among the index's `type: "filter"` paths —
|
|
502
520
|
# otherwise the tenant pre-filter that
|
|
503
521
|
# {Parse::Retrieval.retrieve} folds into `$vectorSearch.filter`
|
|
@@ -530,7 +548,7 @@ module Parse
|
|
|
530
548
|
def vector_index_drift_findings(field, idx)
|
|
531
549
|
defn = idx["latestDefinition"] || idx[:latestDefinition] || {}
|
|
532
550
|
entries = defn["fields"] || defn[:fields] || []
|
|
533
|
-
field_str = field
|
|
551
|
+
field_str = vector_storage_path(field)
|
|
534
552
|
vector_entry = entries.find do |f|
|
|
535
553
|
(f["type"] || f[:type]).to_s == "vector" && (f["path"] || f[:path]).to_s == field_str
|
|
536
554
|
end
|
|
@@ -552,6 +570,17 @@ module Parse
|
|
|
552
570
|
"similarity: #{declared_sim.inspect}"
|
|
553
571
|
end
|
|
554
572
|
|
|
573
|
+
# Quantization is compared both ways: an index quantized without a
|
|
574
|
+
# declaration, or a declaration the index does not carry, is drift.
|
|
575
|
+
# An absent value means none on either side.
|
|
576
|
+
declared_q = vector_properties.dig(field.to_sym, :quantization)&.to_s || "none"
|
|
577
|
+
index_q = (vector_entry["quantization"] || vector_entry[:quantization]).to_s
|
|
578
|
+
index_q = "none" if index_q.empty?
|
|
579
|
+
if declared_q != index_q
|
|
580
|
+
findings << "index quantization=#{index_q.inspect} but property declares " \
|
|
581
|
+
"quantization: #{declared_q == "none" ? "none" : declared_q.inspect}"
|
|
582
|
+
end
|
|
583
|
+
|
|
555
584
|
scope_field = registered_tenant_scope_field
|
|
556
585
|
if scope_field
|
|
557
586
|
filter_paths = entries.select { |f| (f["type"] || f[:type]).to_s == "filter" }
|
data/lib/parse/model/file.rb
CHANGED
|
@@ -703,7 +703,13 @@ module Parse
|
|
|
703
703
|
# @param contents [Object]
|
|
704
704
|
# @param mime_type [String] Default see default_mime_type
|
|
705
705
|
def initialize(name, contents = nil, mime_type = nil)
|
|
706
|
-
|
|
706
|
+
# The default mime type applies only to content this instance will
|
|
707
|
+
# upload. A file hydrated from a server hash or copied from another
|
|
708
|
+
# Parse::File describes an existing upload whose type is not known
|
|
709
|
+
# here, so it keeps an explicit mime_type or the source's, never a
|
|
710
|
+
# guessed "image/jpeg".
|
|
711
|
+
hydrated = name.is_a?(Hash) || name.is_a?(Parse::File)
|
|
712
|
+
mime_type ||= hydrated ? nil : Parse::File.default_mime_type
|
|
707
713
|
|
|
708
714
|
if name.is_a?(String) && name.start_with?("http") #could be url string
|
|
709
715
|
file = Parse::File.safe_open_url(name)
|
|
@@ -717,6 +723,7 @@ module Parse
|
|
|
717
723
|
@name = File.basename name.to_path
|
|
718
724
|
elsif name.is_a?(Parse::File)
|
|
719
725
|
@name = name.name
|
|
726
|
+
@mime_type = name.mime_type
|
|
720
727
|
# Route through the single URL normalization point so the copy
|
|
721
728
|
# gets the same strip + stash treatment as a caller-side
|
|
722
729
|
# `url=`. Preserve the source's presigned-URL stash
|
|
@@ -1088,7 +1095,7 @@ module Parse
|
|
|
1088
1095
|
def inspect
|
|
1089
1096
|
url_state = @url.present? ? "set" : "blank"
|
|
1090
1097
|
"<Parse::File @name=#{@name.inspect} @mime_type=#{@mime_type.inspect} " \
|
|
1091
|
-
"@contents=#{
|
|
1098
|
+
"@contents=#{!@contents.nil?} @url=#{url_state}>"
|
|
1092
1099
|
end
|
|
1093
1100
|
|
|
1094
1101
|
# @return [String] the url
|
data/lib/parse/model/geopoint.rb
CHANGED
|
@@ -53,29 +53,68 @@ module Parse
|
|
|
53
53
|
|
|
54
54
|
alias_method :__type, :parse_class
|
|
55
55
|
|
|
56
|
-
# The initializer can create a GeoPoint with a hash, array
|
|
56
|
+
# The initializer can create a GeoPoint with a hash, array, keyword
|
|
57
|
+
# arguments or values.
|
|
57
58
|
# @example
|
|
58
59
|
# san_diego = Parse::GeoPoint.new(32.8233, -117.6542)
|
|
59
60
|
# san_diego = Parse::GeoPoint.new [32.8233, -117.6542]
|
|
60
|
-
# san_diego = Parse::GeoPoint.new
|
|
61
|
+
# san_diego = Parse::GeoPoint.new({ latitude: 32.8233, longitude: -117.6542 })
|
|
62
|
+
# san_diego = Parse::GeoPoint.new(lat: 32.8233, lng: -117.6542)
|
|
63
|
+
# san_diego = Parse::GeoPoint.new(latitude: 32.8233, longitude: -117.6542)
|
|
64
|
+
#
|
|
65
|
+
# Coordinates may be Numerics or numeric Strings. A value that is not a
|
|
66
|
+
# finite number (for example `"abc"`, `nil` inside a pair, NaN) raises
|
|
67
|
+
# ArgumentError rather than silently becoming 0.0, which would place the
|
|
68
|
+
# point at (0, 0) and corrupt geo queries.
|
|
61
69
|
#
|
|
62
70
|
# @param latitude [Numeric] The latitude value between LAT_MIN and LAT_MAX.
|
|
63
71
|
# @param longitude [Numeric] The longitude value between LNG_MIN and LNG_MAX.
|
|
64
|
-
|
|
72
|
+
# @param coords [Hash] keyword form: `lat:`/`lng:` or `latitude:`/`longitude:`.
|
|
73
|
+
# @raise ArgumentError if a coordinate is not a finite number.
|
|
74
|
+
def initialize(latitude = nil, longitude = nil, **coords)
|
|
65
75
|
@latitude = @longitude = 0.0
|
|
66
|
-
if
|
|
76
|
+
if coords.any?
|
|
77
|
+
unless latitude.nil? && longitude.nil?
|
|
78
|
+
raise ArgumentError, "[Parse::GeoPoint] pass either positional or keyword coordinates, not both."
|
|
79
|
+
end
|
|
80
|
+
self.attributes = coords
|
|
81
|
+
elsif latitude.is_a?(Hash) || latitude.is_a?(Array)
|
|
67
82
|
self.attributes = latitude
|
|
68
|
-
elsif latitude.is_a?(Numeric) && longitude.is_a?(Numeric)
|
|
69
|
-
@latitude = latitude
|
|
70
|
-
@longitude = longitude
|
|
71
83
|
elsif latitude.is_a?(GeoPoint)
|
|
72
84
|
@latitude = latitude.latitude
|
|
73
85
|
@longitude = latitude.longitude
|
|
86
|
+
elsif latitude.is_a?(String) && longitude.nil?
|
|
87
|
+
# "lat,lng" string form.
|
|
88
|
+
parts = latitude.split(",")
|
|
89
|
+
unless parts.length == 2
|
|
90
|
+
raise ArgumentError, "[Parse::GeoPoint] cannot build a GeoPoint from #{latitude.inspect}."
|
|
91
|
+
end
|
|
92
|
+
self.attributes = parts
|
|
93
|
+
elsif !latitude.nil? || !longitude.nil?
|
|
94
|
+
@latitude = self.class.coerce_coordinate(latitude, :latitude)
|
|
95
|
+
@longitude = self.class.coerce_coordinate(longitude, :longitude)
|
|
74
96
|
end
|
|
75
97
|
|
|
76
98
|
_validate_point
|
|
77
99
|
end
|
|
78
100
|
|
|
101
|
+
# Convert a single coordinate to a Float.
|
|
102
|
+
# @param value [Numeric, String] the coordinate.
|
|
103
|
+
# @param name [Symbol] the coordinate name, for the error message.
|
|
104
|
+
# @return [Float]
|
|
105
|
+
# @raise ArgumentError when the value is not a finite number.
|
|
106
|
+
# @!visibility private
|
|
107
|
+
def self.coerce_coordinate(value, name)
|
|
108
|
+
num = case value
|
|
109
|
+
when Numeric then value.to_f
|
|
110
|
+
when String then Float(value.strip, exception: false)
|
|
111
|
+
end
|
|
112
|
+
unless num.is_a?(Float) && num.finite?
|
|
113
|
+
raise ArgumentError, "[Parse::GeoPoint] #{name} must be a finite number (got #{value.inspect})."
|
|
114
|
+
end
|
|
115
|
+
num
|
|
116
|
+
end
|
|
117
|
+
|
|
79
118
|
# @!visibility private
|
|
80
119
|
def _validate_point
|
|
81
120
|
unless @latitude.nil? || @latitude.between?(LAT_MIN, LAT_MAX)
|
|
@@ -134,15 +173,24 @@ module Parse
|
|
|
134
173
|
end
|
|
135
174
|
|
|
136
175
|
# Setting lat and lng for an GeoPoint can be done using a hash with the attributes set
|
|
137
|
-
# or with an array of two items where the first is the lat and the second is the lng (ex. [32.22,-118.81])
|
|
176
|
+
# or with an array of two items where the first is the lat and the second is the lng (ex. [32.22,-118.81]).
|
|
177
|
+
# Hash keys may be `latitude`/`longitude` or `lat`/`lng`, as Symbols or
|
|
178
|
+
# Strings. A coordinate missing from the hash keeps its current value.
|
|
179
|
+
# @raise ArgumentError when a supplied coordinate is not a finite number,
|
|
180
|
+
# or an Array does not hold exactly two coordinates.
|
|
138
181
|
def attributes=(h)
|
|
139
182
|
if h.is_a?(Hash)
|
|
140
183
|
h = h.symbolize_keys
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
@
|
|
145
|
-
|
|
184
|
+
lat = h.key?(:latitude) ? h[:latitude] : h[:lat]
|
|
185
|
+
lng = h.key?(:longitude) ? h[:longitude] : h[:lng]
|
|
186
|
+
@latitude = self.class.coerce_coordinate(lat, :latitude) unless lat.nil?
|
|
187
|
+
@longitude = self.class.coerce_coordinate(lng, :longitude) unless lng.nil?
|
|
188
|
+
elsif h.is_a?(Array)
|
|
189
|
+
unless h.count == 2
|
|
190
|
+
raise ArgumentError, "[Parse::GeoPoint] expects [latitude, longitude] (got #{h.inspect})."
|
|
191
|
+
end
|
|
192
|
+
@latitude = self.class.coerce_coordinate(h.first, :latitude)
|
|
193
|
+
@longitude = self.class.coerce_coordinate(h.last, :longitude)
|
|
146
194
|
end
|
|
147
195
|
_validate_point
|
|
148
196
|
end
|
data/lib/parse/model/model.rb
CHANGED
|
@@ -136,10 +136,124 @@ module Parse
|
|
|
136
136
|
# the SDK (see Parse::ACLScope `@no_acl_warned`).
|
|
137
137
|
@model_cache = {}
|
|
138
138
|
@model_cache_mutex = Mutex.new
|
|
139
|
+
# Class names {find_class} looked up and did not find. A miss otherwise
|
|
140
|
+
# rescans every descendant on each call (a query on a class with no Ruby
|
|
141
|
+
# model hits this on every build). Cleared by {model_registry_changed!}.
|
|
142
|
+
@model_cache_misses = {}
|
|
143
|
+
MODEL_CACHE_MISSES_MAX = 10_000
|
|
144
|
+
# Bumped whenever the set of models, a model's `parse_class`, or a model's
|
|
145
|
+
# declared fields change, so derived per-class caches (such as the
|
|
146
|
+
# query field-alias cache) know to rebuild.
|
|
147
|
+
@model_generation = 0
|
|
139
148
|
|
|
140
149
|
class << self
|
|
141
150
|
# @!visibility private
|
|
142
|
-
attr_reader :model_cache, :model_cache_mutex
|
|
151
|
+
attr_reader :model_cache, :model_cache_mutex, :model_cache_misses
|
|
152
|
+
# @!visibility private
|
|
153
|
+
# Anonymous Parse::Object descendants seen by the last missed
|
|
154
|
+
# {find_class} scan. Guarded by {model_cache_mutex}.
|
|
155
|
+
attr_accessor :model_anonymous_descendants
|
|
156
|
+
# @!visibility private
|
|
157
|
+
# @return [Integer] the current model registry generation.
|
|
158
|
+
attr_reader :model_generation
|
|
159
|
+
|
|
160
|
+
# @!visibility private
|
|
161
|
+
# Record that a model was defined, renamed with `parse_class`, or had a
|
|
162
|
+
# field declared. Clears the {find_class} miss cache and advances
|
|
163
|
+
# {model_generation}.
|
|
164
|
+
def model_registry_changed!
|
|
165
|
+
model_cache_mutex.synchronize do
|
|
166
|
+
# Hits are cleared too: a model redefined after `remove_const`
|
|
167
|
+
# (a code reload, or a test) must replace the old class, whose
|
|
168
|
+
# field_map and references no longer describe the table.
|
|
169
|
+
@model_cache = {}
|
|
170
|
+
@model_cache_misses = {}
|
|
171
|
+
@model_anonymous_descendants = nil
|
|
172
|
+
@model_generation += 1
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @!visibility private
|
|
177
|
+
# Whether `klass` is the class its name currently resolves to. A class
|
|
178
|
+
# left behind by `remove_const` (or one whose `name` is overridden to a
|
|
179
|
+
# constant that does not exist) is not live. Never triggers autoload.
|
|
180
|
+
#
|
|
181
|
+
# @param klass [Class]
|
|
182
|
+
# @return [Boolean]
|
|
183
|
+
def live_model?(klass)
|
|
184
|
+
name = klass.name
|
|
185
|
+
return false unless name.is_a?(String) && !name.empty?
|
|
186
|
+
scope = Object
|
|
187
|
+
name.split("::").each do |part|
|
|
188
|
+
return false if scope.autoload?(part)
|
|
189
|
+
return false unless scope.const_defined?(part, false)
|
|
190
|
+
scope = scope.const_get(part, false)
|
|
191
|
+
return false unless scope.is_a?(Module)
|
|
192
|
+
end
|
|
193
|
+
scope.equal?(klass)
|
|
194
|
+
rescue StandardError
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @!visibility private
|
|
199
|
+
# The one rule that turns a field name into its column for `klass`.
|
|
200
|
+
# Used by query compilation ({Parse::Query.format_field}) and by the
|
|
201
|
+
# agent field allowlist ({Parse::Agent::MetadataRegistry.wire_field_names}),
|
|
202
|
+
# so the name a policy check approves is the column a query addresses.
|
|
203
|
+
#
|
|
204
|
+
# Precedence:
|
|
205
|
+
# 1. A Ruby property name maps to its declared column (`field_map`).
|
|
206
|
+
# 2. Otherwise a name that is exactly a declared column stays as is.
|
|
207
|
+
# 3. Otherwise nil: the caller applies its default formatting.
|
|
208
|
+
#
|
|
209
|
+
# So with `property :email, field: "contactEmail"` and
|
|
210
|
+
# `property :legacy_email, field: "email"`, the name `email` resolves to
|
|
211
|
+
# `contactEmail`. Model code writes Ruby names (`Klass.query(email: x)`),
|
|
212
|
+
# and the Ruby name must not be redirected to another property's column.
|
|
213
|
+
#
|
|
214
|
+
# @param klass [Class] a model class (anything responding to field_map).
|
|
215
|
+
# @param name [String, Symbol]
|
|
216
|
+
# @return [String, nil] the declared column, or nil when undeclared.
|
|
217
|
+
def wire_name_for(klass, name)
|
|
218
|
+
return nil unless klass.respond_to?(:field_map)
|
|
219
|
+
field_resolution(klass)[name.to_s]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @!visibility private
|
|
223
|
+
# Every name {wire_name_for} resolves for `klass`, as a frozen
|
|
224
|
+
# `{name => column}` Hash. Cached on the class and rebuilt when the
|
|
225
|
+
# model registry generation or the field_map size changes.
|
|
226
|
+
#
|
|
227
|
+
# @param klass [Class]
|
|
228
|
+
# @return [Hash{String => String}]
|
|
229
|
+
def field_resolution(klass)
|
|
230
|
+
fmap = klass.field_map
|
|
231
|
+
cached = klass.instance_variable_get(:@_parse_field_resolution)
|
|
232
|
+
generation = @model_generation
|
|
233
|
+
if cached && cached[0] == generation && cached[1] == fmap.size
|
|
234
|
+
return cached[2]
|
|
235
|
+
end
|
|
236
|
+
map = field_resolution_map(fmap)
|
|
237
|
+
klass.instance_variable_set(:@_parse_field_resolution, [generation, fmap.size, map].freeze)
|
|
238
|
+
map
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @!visibility private
|
|
242
|
+
# Build the {wire_name_for} table from a field_map. Declared columns map
|
|
243
|
+
# to themselves; Ruby property names map to their column and win over a
|
|
244
|
+
# declared column spelled the same.
|
|
245
|
+
#
|
|
246
|
+
# @param fmap [Hash{Symbol => Symbol, String}]
|
|
247
|
+
# @return [Hash{String => String}] frozen.
|
|
248
|
+
def field_resolution_map(fmap)
|
|
249
|
+
map = {}
|
|
250
|
+
fmap.each_value do |remote|
|
|
251
|
+
wire = remote.to_s
|
|
252
|
+
map[wire] = wire
|
|
253
|
+
end
|
|
254
|
+
fmap.each { |ruby_name, remote| map[ruby_name.to_s] = remote.to_s }
|
|
255
|
+
map.freeze
|
|
256
|
+
end
|
|
143
257
|
# @!attribute self.raise_on_save_failure
|
|
144
258
|
# By default, we return `true` or `false` for save and destroy operations.
|
|
145
259
|
# If you prefer to have `Parse::Object` raise an exception instead, you
|
|
@@ -215,21 +329,58 @@ module Parse
|
|
|
215
329
|
# subclasses (e.g. Parse::Object.find_class), so a bare `@model_cache`
|
|
216
330
|
# would resolve on the subclass singleton — which has no cache. The
|
|
217
331
|
# cache lives on Parse::Model itself.
|
|
332
|
+
generation = nil
|
|
218
333
|
Parse::Model.model_cache_mutex.synchronize do
|
|
219
334
|
cached = Parse::Model.model_cache[str]
|
|
220
335
|
return cached if cached
|
|
336
|
+
if Parse::Model.model_cache_misses.key?(str)
|
|
337
|
+
# A recorded miss holds until the registry changes, unless an
|
|
338
|
+
# anonymous model seen by the last scan has since been named
|
|
339
|
+
# (`Foo = Class.new(Parse::Object)` fires no hook when the
|
|
340
|
+
# constant is assigned). Without anonymous models this is free.
|
|
341
|
+
anonymous = Parse::Model.model_anonymous_descendants
|
|
342
|
+
return nil if anonymous.nil? || anonymous.none? { |k| (k.name rescue nil) }
|
|
343
|
+
Parse::Model.model_cache_misses.clear
|
|
344
|
+
end
|
|
345
|
+
generation = Parse::Model.model_generation
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Scan outside the lock: the liveness check reads constants, and a
|
|
349
|
+
# constant read must never run a model definition (whose `inherited`
|
|
350
|
+
# hook takes this lock) while the lock is held.
|
|
351
|
+
matches = []
|
|
352
|
+
anonymous = []
|
|
353
|
+
Parse::Object.descendants.each do |f|
|
|
354
|
+
anonymous << f if (f.name rescue nil).nil?
|
|
355
|
+
begin
|
|
356
|
+
cls = f.parse_class
|
|
357
|
+
rescue StandardError
|
|
358
|
+
next
|
|
359
|
+
end
|
|
360
|
+
matches << f if cls == str || cls == "_#{str}"
|
|
361
|
+
end
|
|
362
|
+
# Prefer the class the constant currently names over one left behind by
|
|
363
|
+
# `remove_const`; keep descendant order otherwise.
|
|
364
|
+
result = if matches.size > 1
|
|
365
|
+
matches.find { |m| Parse::Model.live_model?(m) } || matches.first
|
|
366
|
+
else
|
|
367
|
+
matches.first
|
|
368
|
+
end
|
|
221
369
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
370
|
+
Parse::Model.model_cache_mutex.synchronize do
|
|
371
|
+
# Cache only when no model was defined or renamed during the scan.
|
|
372
|
+
if generation == Parse::Model.model_generation
|
|
373
|
+
if result
|
|
374
|
+
Parse::Model.model_cache[str] = result
|
|
375
|
+
else
|
|
376
|
+
misses = Parse::Model.model_cache_misses
|
|
377
|
+
misses.clear if misses.size >= MODEL_CACHE_MISSES_MAX
|
|
378
|
+
misses[str] = true
|
|
379
|
+
Parse::Model.model_anonymous_descendants = anonymous.freeze
|
|
227
380
|
end
|
|
228
|
-
cls == str || cls == "_#{str}"
|
|
229
381
|
end
|
|
230
|
-
Parse::Model.model_cache[str] = result if result
|
|
231
|
-
result
|
|
232
382
|
end
|
|
383
|
+
result
|
|
233
384
|
end
|
|
234
385
|
|
|
235
386
|
# Whether two Parse class-name strings denote the same class. This is the
|