parse-stack-next 5.2.1 → 5.4.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/.bundle/config +1 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +616 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +296 -3
- data/Rakefile +243 -41
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +38 -0
- data/docs/mcp_guide.md +119 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +174 -9
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +200 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +73 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +173 -1
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +399 -11
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +16 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module Embeddings
|
|
8
|
+
# Per-tenant cumulative embedding spend cap.
|
|
9
|
+
#
|
|
10
|
+
# The agent `semantic_search` tool embeds attacker-controlled text
|
|
11
|
+
# (chat queries) on every call. Without a cap, a tenant — or an
|
|
12
|
+
# adversary driving an agent — can run up unbounded embedding-provider
|
|
13
|
+
# cost. {SpendCap} tracks the cumulative number of *tokens* embedded
|
|
14
|
+
# per tenant inside a sliding time window and HARD-REFUSES (raises
|
|
15
|
+
# {Exceeded}) once a tenant would exceed its limit. This is distinct
|
|
16
|
+
# from {Parse::Agent::RateLimiter}, which bounds request *count* per
|
|
17
|
+
# window; the spend cap bounds embedding *volume* (a proxy for cost).
|
|
18
|
+
#
|
|
19
|
+
# == Disabled by default
|
|
20
|
+
#
|
|
21
|
+
# With no configured limit the cap is a no-op — {.charge!} records
|
|
22
|
+
# nothing and never raises. Operators opt in:
|
|
23
|
+
#
|
|
24
|
+
# Parse::Embeddings::SpendCap.configure(limit_tokens: 1_000_000, window: 3600)
|
|
25
|
+
# Parse::Embeddings::SpendCap.configure(:acme_tenant, limit_tokens: 50_000)
|
|
26
|
+
#
|
|
27
|
+
# A per-tenant limit (second form) overrides the default for that
|
|
28
|
+
# tenant. The reserved key {DEFAULT_KEY} sets the fallback applied to
|
|
29
|
+
# every tenant without an explicit limit.
|
|
30
|
+
#
|
|
31
|
+
# == Token estimation
|
|
32
|
+
#
|
|
33
|
+
# Callers pass an explicit token count, or use {.estimate_tokens} (a
|
|
34
|
+
# chars/4 heuristic — the same approximation the agent layer uses for
|
|
35
|
+
# its context-token budgets). The cap is intentionally an estimate: it
|
|
36
|
+
# exists to bound runaway cost, not to bill precisely.
|
|
37
|
+
#
|
|
38
|
+
# Thread-safe: all state lives behind a single mutex.
|
|
39
|
+
module SpendCap
|
|
40
|
+
# Raised when a tenant would exceed its token cap. Carries the
|
|
41
|
+
# limit, the already-used count (within the window), and a
|
|
42
|
+
# `retry_after` hint (seconds until enough of the window rolls off
|
|
43
|
+
# to admit the rejected charge — `nil` if the charge can never fit).
|
|
44
|
+
class Exceeded < StandardError
|
|
45
|
+
attr_reader :tenant_id, :limit, :used, :requested, :window, :retry_after
|
|
46
|
+
|
|
47
|
+
def initialize(tenant_id:, limit:, used:, requested:, window:, retry_after:)
|
|
48
|
+
@tenant_id = tenant_id
|
|
49
|
+
@limit = limit
|
|
50
|
+
@used = used
|
|
51
|
+
@requested = requested
|
|
52
|
+
@window = window
|
|
53
|
+
@retry_after = retry_after
|
|
54
|
+
super(
|
|
55
|
+
"Embedding spend cap exceeded for tenant #{tenant_id.inspect}: " \
|
|
56
|
+
"#{used}+#{requested} tokens would exceed #{limit}/#{window}s." \
|
|
57
|
+
"#{retry_after ? " Retry after #{retry_after.round(1)}s." : " Request exceeds the cap outright."}"
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Fallback bucket key for charges with no tenant identity, and the
|
|
63
|
+
# key under which {.configure} (with no explicit tenant) sets the
|
|
64
|
+
# default limit applied to every tenant lacking an override.
|
|
65
|
+
DEFAULT_KEY = :__default__
|
|
66
|
+
|
|
67
|
+
# Default sliding window (seconds) when none is configured.
|
|
68
|
+
DEFAULT_WINDOW = 3600
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Configure the cap. Two forms:
|
|
72
|
+
#
|
|
73
|
+
# configure(limit_tokens:, window:) # default for all tenants
|
|
74
|
+
# configure(tenant_id, limit_tokens:, window:) # override one tenant
|
|
75
|
+
#
|
|
76
|
+
# `limit_tokens: nil` disables the cap for that scope (the default
|
|
77
|
+
# scope when no tenant is given).
|
|
78
|
+
#
|
|
79
|
+
# @param tenant_id [Object, nil] tenant to override, or nil for
|
|
80
|
+
# the global default.
|
|
81
|
+
# @param limit_tokens [Integer, nil] token ceiling per window.
|
|
82
|
+
# @param window [Integer] sliding window length in seconds.
|
|
83
|
+
# @return [void]
|
|
84
|
+
def configure(tenant_id = nil, limit_tokens:, window: DEFAULT_WINDOW)
|
|
85
|
+
key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
|
|
86
|
+
unless limit_tokens.nil?
|
|
87
|
+
li = Integer(limit_tokens)
|
|
88
|
+
raise ArgumentError, "SpendCap: limit_tokens must be positive (got #{li})." if li <= 0
|
|
89
|
+
end
|
|
90
|
+
w = Integer(window)
|
|
91
|
+
raise ArgumentError, "SpendCap: window must be positive (got #{w})." if w <= 0
|
|
92
|
+
mutex.synchronize do
|
|
93
|
+
limits[key] = limit_tokens.nil? ? nil : { limit: Integer(limit_tokens), window: w }
|
|
94
|
+
end
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Charge `tokens` against `tenant_id`'s budget. HARD-REFUSES by
|
|
99
|
+
# raising {Exceeded} when the charge would push the tenant over
|
|
100
|
+
# its limit within the window; otherwise records the charge and
|
|
101
|
+
# returns the new in-window total.
|
|
102
|
+
#
|
|
103
|
+
# No-op (returns nil) when no limit applies to the tenant.
|
|
104
|
+
#
|
|
105
|
+
# @param tenant_id [Object, nil] tenant identity (nil → {DEFAULT_KEY}).
|
|
106
|
+
# @param tokens [Integer] tokens to charge (>= 0).
|
|
107
|
+
# @return [Integer, nil] new in-window total, or nil if uncapped.
|
|
108
|
+
# @raise [Exceeded]
|
|
109
|
+
def charge!(tenant_id:, tokens:)
|
|
110
|
+
t = Integer(tokens)
|
|
111
|
+
raise ArgumentError, "SpendCap: tokens must be >= 0 (got #{t})." if t.negative?
|
|
112
|
+
key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
|
|
113
|
+
|
|
114
|
+
mutex.synchronize do
|
|
115
|
+
cfg = limit_for(key)
|
|
116
|
+
return nil if cfg.nil? # uncapped
|
|
117
|
+
|
|
118
|
+
window = cfg[:window]
|
|
119
|
+
limit = cfg[:limit]
|
|
120
|
+
now = monotonic
|
|
121
|
+
entries = prune(key, now, window)
|
|
122
|
+
used = entries.sum { |e| e[1] }
|
|
123
|
+
|
|
124
|
+
if used + t > limit
|
|
125
|
+
raise Exceeded.new(
|
|
126
|
+
tenant_id: key, limit: limit, used: used, requested: t,
|
|
127
|
+
window: window, retry_after: retry_after_for(entries, t, limit, window, now),
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
entries << [now, t] if t.positive?
|
|
131
|
+
used + t
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Current in-window token usage for a tenant (0 when uncapped or
|
|
136
|
+
# idle). Does not mutate.
|
|
137
|
+
#
|
|
138
|
+
# @param tenant_id [Object, nil]
|
|
139
|
+
# @return [Integer]
|
|
140
|
+
def usage(tenant_id: nil)
|
|
141
|
+
key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
|
|
142
|
+
mutex.synchronize do
|
|
143
|
+
cfg = limit_for(key)
|
|
144
|
+
return 0 if cfg.nil?
|
|
145
|
+
prune(key, monotonic, cfg[:window]).sum { |e| e[1] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Estimate token count from a String.
|
|
150
|
+
#
|
|
151
|
+
# The familiar "~4 characters per token" ratio only holds for
|
|
152
|
+
# ASCII. CJK, emoji, and other multibyte text run closer to one
|
|
153
|
+
# token per codepoint in a real tokenizer, so a pure
|
|
154
|
+
# `chars / 4` estimate undercounts such input by up to ~4x — and
|
|
155
|
+
# since this estimate is the sole basis for the hard-refuse
|
|
156
|
+
# decision, that lets a caller feeding multibyte text reach ~4x
|
|
157
|
+
# the real embedding volume before the cap trips. Take the larger
|
|
158
|
+
# of the char-based and byte-based estimates so multibyte input
|
|
159
|
+
# bills at least as much as its UTF-8 byte length implies.
|
|
160
|
+
#
|
|
161
|
+
# @param text [String]
|
|
162
|
+
# @return [Integer]
|
|
163
|
+
def estimate_tokens(text)
|
|
164
|
+
str = text.to_s
|
|
165
|
+
chars = (str.length / 4.0).ceil
|
|
166
|
+
bytes = (str.bytesize / 4.0).ceil
|
|
167
|
+
[chars, bytes].max
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Clear recorded usage (all tenants, or one). Limits are retained.
|
|
171
|
+
#
|
|
172
|
+
# @param tenant_id [Object, nil]
|
|
173
|
+
def reset!(tenant_id = nil)
|
|
174
|
+
mutex.synchronize do
|
|
175
|
+
if tenant_id.nil?
|
|
176
|
+
@buckets = {}
|
|
177
|
+
else
|
|
178
|
+
buckets.delete(tenant_id)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Remove all configured limits AND recorded usage. Mainly for
|
|
185
|
+
# tests — returns the cap to its disabled-by-default state.
|
|
186
|
+
def reset_all!
|
|
187
|
+
mutex.synchronize do
|
|
188
|
+
@limits = {}
|
|
189
|
+
@buckets = {}
|
|
190
|
+
end
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
MUTEX_INIT = Mutex.new
|
|
197
|
+
private_constant :MUTEX_INIT
|
|
198
|
+
|
|
199
|
+
def mutex
|
|
200
|
+
@mutex ||= MUTEX_INIT.synchronize { @mutex ||= Mutex.new }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def limits
|
|
204
|
+
@limits ||= {}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def buckets
|
|
208
|
+
@buckets ||= {}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Resolve the effective limit config for a key: an explicit
|
|
212
|
+
# per-tenant entry wins; otherwise the DEFAULT_KEY entry; nil when
|
|
213
|
+
# neither is set (uncapped). A key explicitly set to nil disables
|
|
214
|
+
# the cap for that tenant even if a default exists.
|
|
215
|
+
def limit_for(key)
|
|
216
|
+
if limits.key?(key)
|
|
217
|
+
limits[key]
|
|
218
|
+
else
|
|
219
|
+
limits[DEFAULT_KEY]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Drop entries older than the window; returns the (mutated) live
|
|
224
|
+
# entry list for the key.
|
|
225
|
+
def prune(key, now, window)
|
|
226
|
+
entries = (buckets[key] ||= [])
|
|
227
|
+
cutoff = now - window
|
|
228
|
+
entries.reject! { |e| e[0] <= cutoff }
|
|
229
|
+
entries
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Seconds until enough in-window tokens roll off to admit a charge
|
|
233
|
+
# of `requested` tokens. nil when the request alone exceeds the
|
|
234
|
+
# limit (it can never fit).
|
|
235
|
+
def retry_after_for(entries, requested, limit, window, now)
|
|
236
|
+
return nil if requested > limit
|
|
237
|
+
need_to_free = (entries.sum { |e| e[1] } + requested) - limit
|
|
238
|
+
return 0.0 if need_to_free <= 0
|
|
239
|
+
freed = 0
|
|
240
|
+
entries.sort_by { |e| e[0] }.each do |ts, tok|
|
|
241
|
+
freed += tok
|
|
242
|
+
if freed >= need_to_free
|
|
243
|
+
return [(ts + window) - now, 0.0].max
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def monotonic
|
|
250
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/parse/embeddings.rb
CHANGED
|
@@ -363,7 +363,7 @@ module Parse
|
|
|
363
363
|
# events can arrive. Optional — callers may still capture the
|
|
364
364
|
# returned subscription and register callbacks later.
|
|
365
365
|
# @return [Subscription]
|
|
366
|
-
def subscribe(class_name, where: {}, fields: nil, session_token: nil,
|
|
366
|
+
def subscribe(class_name, where: {}, fields: nil, keys: nil, watch: nil, session_token: nil,
|
|
367
367
|
use_master_key: false, &block)
|
|
368
368
|
# Handle Parse::Object subclass
|
|
369
369
|
if class_name.is_a?(Class) && class_name < Parse::Object
|
|
@@ -396,6 +396,8 @@ module Parse
|
|
|
396
396
|
class_name: class_name,
|
|
397
397
|
query: where,
|
|
398
398
|
fields: fields,
|
|
399
|
+
keys: keys,
|
|
400
|
+
watch: watch,
|
|
399
401
|
session_token: session_token,
|
|
400
402
|
use_master_key: use_master_key,
|
|
401
403
|
)
|
|
@@ -62,12 +62,22 @@ module Parse
|
|
|
62
62
|
# @return [Parse::LiveQuery::Client] the LiveQuery client
|
|
63
63
|
attr_reader :client
|
|
64
64
|
|
|
65
|
-
# @return [Array<String>]
|
|
65
|
+
# @return [Array<String>] field projection for returned events
|
|
66
|
+
# (nil = all fields). Parse Server 7.0 renamed this subscription
|
|
67
|
+
# option from `fields` to `keys`; {#keys} is the canonical alias.
|
|
66
68
|
attr_reader :fields
|
|
69
|
+
# @return [Array<String>] alias for {#fields} under Parse Server's
|
|
70
|
+
# post-7.0 `keys` name.
|
|
71
|
+
alias_method :keys, :fields
|
|
67
72
|
|
|
68
73
|
# @return [String, nil] session token for ACL-aware subscriptions
|
|
69
74
|
attr_reader :session_token
|
|
70
75
|
|
|
76
|
+
# @return [Array<String>, nil] field names that trigger update events when
|
|
77
|
+
# changed (PS 7.0+ `watch` option). +nil+ means all field changes trigger
|
|
78
|
+
# update events.
|
|
79
|
+
attr_reader :watch
|
|
80
|
+
|
|
71
81
|
# Create a new subscription
|
|
72
82
|
# @param client [Parse::LiveQuery::Client] the LiveQuery client
|
|
73
83
|
# @param class_name [String] Parse class name
|
|
@@ -85,13 +95,16 @@ module Parse
|
|
|
85
95
|
# elevated; on a non-admin connection the client warns and the
|
|
86
96
|
# subscription stays ACL-scoped. For mixed scoped + admin needs,
|
|
87
97
|
# use two separate clients. Defaults to false.
|
|
88
|
-
def initialize(client:, class_name:, query: {}, fields: nil,
|
|
89
|
-
session_token: nil, use_master_key: false)
|
|
98
|
+
def initialize(client:, class_name:, query: {}, fields: nil, keys: nil,
|
|
99
|
+
session_token: nil, use_master_key: false, watch: nil)
|
|
90
100
|
@monitor = Monitor.new
|
|
91
101
|
@client = client
|
|
92
102
|
@class_name = class_name
|
|
93
103
|
@query = query
|
|
94
|
-
|
|
104
|
+
# `keys` is the post-7.0 name; accept either and prefer the explicit
|
|
105
|
+
# `keys:` when both are supplied.
|
|
106
|
+
@fields = keys.nil? ? fields : keys
|
|
107
|
+
@watch = watch
|
|
95
108
|
@session_token = session_token
|
|
96
109
|
@use_master_key = use_master_key == true
|
|
97
110
|
@request_id = generate_request_id
|
|
@@ -239,7 +252,21 @@ module Parse
|
|
|
239
252
|
},
|
|
240
253
|
}
|
|
241
254
|
|
|
242
|
-
|
|
255
|
+
if fields&.any?
|
|
256
|
+
# Parse Server 7.0 (DEPPS9 / #8852) renamed the subscription field-
|
|
257
|
+
# projection option from `fields` to `keys`. PS 7+ reads `keys` and
|
|
258
|
+
# ignores `fields`; PS < 7 reads `fields`. Emit BOTH so projection is
|
|
259
|
+
# honored on every supported server — sending an extra key the server
|
|
260
|
+
# ignores is harmless, while sending only `fields` silently disables
|
|
261
|
+
# projection (events return all columns) on PS 7+.
|
|
262
|
+
msg[:query][:keys] = fields
|
|
263
|
+
msg[:query][:fields] = fields
|
|
264
|
+
end
|
|
265
|
+
# PS 7.0 (#8028) `watch`: fire update events only when the named fields
|
|
266
|
+
# change. Distinct from field projection (`keys`/`fields`): `watch`
|
|
267
|
+
# controls which field mutations generate an update event; `keys` controls
|
|
268
|
+
# which fields are returned in the event payload.
|
|
269
|
+
msg[:query][:watch] = watch if watch&.any?
|
|
243
270
|
msg[:sessionToken] = session_token if session_token
|
|
244
271
|
# The subscribe frame deliberately NEVER carries `masterKey`.
|
|
245
272
|
# Parse Server's `_handleSubscribe` does not read it — master-key
|
data/lib/parse/model/acl.rb
CHANGED
|
@@ -91,8 +91,10 @@ module Parse
|
|
|
91
91
|
# artist.save
|
|
92
92
|
#
|
|
93
93
|
# You may also set default ACLs for your subclasses by using {Parse::Object.set_default_acl}.
|
|
94
|
-
# These will
|
|
95
|
-
#
|
|
94
|
+
# These will get applied for newly created instances. Unless overridden, subclasses
|
|
95
|
+
# inherit the shipped `:owner_else_private` policy — records are private
|
|
96
|
+
# (master-only) until an owner is resolved at save time. Use {Parse::Object.set_default_acl}
|
|
97
|
+
# or {Parse::Object.acl_policy} to grant broader access.
|
|
96
98
|
#
|
|
97
99
|
# class AdminData < Parse::Object
|
|
98
100
|
#
|
|
@@ -119,6 +119,23 @@ module Parse
|
|
|
119
119
|
|
|
120
120
|
# These items are added as attributes with the special data type of :pointer
|
|
121
121
|
def belongs_to(key, opts = {})
|
|
122
|
+
# An explicitly-passed `as:` that names a scalar data type
|
|
123
|
+
# (`:string`, `:integer`, `:boolean`, …) is almost always a mistake —
|
|
124
|
+
# you cannot point at a scalar — and most often means a `property`
|
|
125
|
+
# was written with `as:` out of habit. Reject it at declaration time.
|
|
126
|
+
# This is the only association footgun decidable without all models
|
|
127
|
+
# loaded: an unresolved *class* name may simply be a forward
|
|
128
|
+
# reference (the target is required later), so that check is deferred
|
|
129
|
+
# to {Parse.validate_associations!}.
|
|
130
|
+
explicit_as = opts.key?(:as) ? opts[:as] : nil
|
|
131
|
+
if explicit_as && Parse::Properties::TYPES.include?(explicit_as.to_s.to_sym)
|
|
132
|
+
scalar = explicit_as.to_s.to_sym
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"#{self}##{key}: `as: #{explicit_as.inspect}` names the reserved data type " \
|
|
135
|
+
":#{scalar}, not a Parse class. For a scalar column write " \
|
|
136
|
+
"`property #{key.inspect}, #{scalar.inspect}`; if you really mean a Parse class " \
|
|
137
|
+
"named #{scalar.to_s.camelize.inspect}, pass `class_name: #{scalar.to_s.camelize.inspect}` instead."
|
|
138
|
+
end
|
|
122
139
|
opts = { as: key, field: key.to_s.camelize(:lower), required: false }.merge(opts)
|
|
123
140
|
# `opts[:class_name]` is the explicit target Parse class name; it takes
|
|
124
141
|
# precedence over the legacy `as: :symbol` shorthand (where the
|
|
@@ -134,6 +151,22 @@ module Parse
|
|
|
134
151
|
set_attribute_method = :"#{key}_set_attribute!"
|
|
135
152
|
|
|
136
153
|
if self.fields[key].present? && Parse::Properties::BASE_FIELD_MAP[key].nil?
|
|
154
|
+
existing_type = self.fields[key]
|
|
155
|
+
# A structural redeclaration that CHANGES the type to a pointer
|
|
156
|
+
# (e.g. a field first declared `property :owner, :string` and then
|
|
157
|
+
# `property :owner, as: :user` / `belongs_to :owner`) is almost
|
|
158
|
+
# always a bug — and, because `property … as:` now delegates here,
|
|
159
|
+
# it is the same silent-String failure mode this whole feature
|
|
160
|
+
# exists to fix. Honor the same `strict_property_redefinition`
|
|
161
|
+
# contract that `property` enforces so the conflict is not
|
|
162
|
+
# downgraded to a warning. A same-type reopen (existing :pointer)
|
|
163
|
+
# still just warns, matching the prior behavior.
|
|
164
|
+
if existing_type != :pointer && Parse.strict_property_redefinition
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"#{self}##{key} is already defined as :#{existing_type}; refusing to " \
|
|
167
|
+
"redeclare it as a :pointer association (target #{klassName}). Set " \
|
|
168
|
+
"Parse.strict_property_redefinition = false to fall back to warn-and-ignore."
|
|
169
|
+
end
|
|
137
170
|
warn "Belongs relation #{self}##{key} already defined with type #{klassName}"
|
|
138
171
|
return false
|
|
139
172
|
end
|
|
@@ -151,6 +184,20 @@ module Parse
|
|
|
151
184
|
# Mapping between local attribute name and the remote column name
|
|
152
185
|
self.field_map.merge!(key => parse_field)
|
|
153
186
|
|
|
187
|
+
# Agent metadata: a belongs_to pointer can carry a semantic description
|
|
188
|
+
# (and per-value enum descriptions) just like a `property` can. This
|
|
189
|
+
# also lets `property :x, as: :user, _description: "..."` round-trip its
|
|
190
|
+
# metadata through the delegation in Parse::Properties#property.
|
|
191
|
+
if opts[:_description].present?
|
|
192
|
+
self.property_descriptions[key] = opts[:_description].to_s.freeze
|
|
193
|
+
end
|
|
194
|
+
if opts[:_enum].is_a?(Hash) && opts[:_enum].any?
|
|
195
|
+
normalized = opts[:_enum].each_with_object({}) do |(value, desc), h|
|
|
196
|
+
h[value.to_s] = desc.to_s.freeze
|
|
197
|
+
end
|
|
198
|
+
self.property_enum_descriptions[key] = normalized.freeze
|
|
199
|
+
end
|
|
200
|
+
|
|
154
201
|
# used for dirty tracking
|
|
155
202
|
define_attribute_methods key
|
|
156
203
|
|
|
@@ -155,12 +155,60 @@ module Parse
|
|
|
155
155
|
property :name
|
|
156
156
|
|
|
157
157
|
# @!attribute query
|
|
158
|
-
# The query constraints that define which installations belong to this
|
|
159
|
-
#
|
|
160
|
-
#
|
|
158
|
+
# The query constraints that define which installations belong to this
|
|
159
|
+
# audience, as a Hash matching the Installation query format.
|
|
160
|
+
#
|
|
161
|
+
# On the wire this is persisted as a JSON **string**, matching Parse
|
|
162
|
+
# Server's built-in `_Audience.query` column (typed `String`, not Object).
|
|
163
|
+
# Assigning a Hash and reading a Hash back is handled transparently. The
|
|
164
|
+
# previous `property :query, :object` emitted a JSON object, so every save
|
|
165
|
+
# of a hash query was rejected by the server with a schema mismatch
|
|
166
|
+
# ("expected String but got Object").
|
|
167
|
+
# @return [ActiveSupport::HashWithIndifferentAccess, nil] the query hash.
|
|
161
168
|
# @example
|
|
162
169
|
# audience.query = { "deviceType" => "ios", "appVersion" => { "$gte" => "2.0" } }
|
|
163
|
-
property :
|
|
170
|
+
property :query_json, :string, field: :query
|
|
171
|
+
|
|
172
|
+
# JSON-encode a Hash/Array assigned to the query field before the `:string`
|
|
173
|
+
# property coercion runs. Every assignment path — `query=`,
|
|
174
|
+
# `query_constraint=`, mass-assignment via `new(query:)`, and server
|
|
175
|
+
# hydration — funnels through `format_value`, so intercepting here (rather
|
|
176
|
+
# than the public setter, which mass-assignment bypasses) is the single
|
|
177
|
+
# reliable place to keep the wire value valid JSON (`{"k":"v"}`) instead of
|
|
178
|
+
# Ruby's `Hash#to_s` (`{"k"=>"v"}`). A String passed in (e.g. a row loaded
|
|
179
|
+
# from the server) falls through to the normal string coercion untouched.
|
|
180
|
+
def format_value(key, val, data_type = nil)
|
|
181
|
+
if key == :query_json && (val.is_a?(Hash) || val.is_a?(Array))
|
|
182
|
+
return val.to_json
|
|
183
|
+
end
|
|
184
|
+
super
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The query constraints as a Hash (decoded from the stored JSON string).
|
|
188
|
+
# @return [ActiveSupport::HashWithIndifferentAccess, nil]
|
|
189
|
+
def query
|
|
190
|
+
raw = query_json
|
|
191
|
+
case raw
|
|
192
|
+
when nil, "" then nil
|
|
193
|
+
when Hash then raw.with_indifferent_access
|
|
194
|
+
when String
|
|
195
|
+
decoded = begin
|
|
196
|
+
JSON.parse(raw)
|
|
197
|
+
rescue JSON::ParserError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
decoded.is_a?(Hash) ? decoded.with_indifferent_access : decoded
|
|
201
|
+
else
|
|
202
|
+
raw
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Assign the audience query. Accepts a Hash (preferred) or a pre-encoded
|
|
207
|
+
# JSON String; either form is persisted as a JSON string.
|
|
208
|
+
# @param value [Hash, String, nil]
|
|
209
|
+
def query=(value)
|
|
210
|
+
self.query_json = value
|
|
211
|
+
end
|
|
164
212
|
|
|
165
213
|
# Alias for query to match Parse Server naming conventions.
|
|
166
214
|
# @return [Hash] The query constraint hash.
|