parse-stack-next 5.2.1 → 5.3.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/CHANGELOG.md +155 -0
- data/Gemfile.lock +1 -1
- data/README.md +136 -0
- data/Rakefile +193 -40
- data/docs/client_sdk_guide.md +33 -0
- data/docs/mcp_guide.md +60 -0
- data/lib/parse/client.rb +119 -7
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/user.rb +20 -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 +70 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +156 -1
- data/lib/parse/webhooks/payload.rb +147 -4
- metadata +2 -1
data/docs/mcp_guide.md
CHANGED
|
@@ -3106,6 +3106,66 @@ Four different refusal reasons each produce a distinct `:error_code` and message
|
|
|
3106
3106
|
|
|
3107
3107
|
**Resolution order at dispatch:** operator filter ▷ mutation gate ▷ mode ceiling ▷ in-tool class gate. Operator-filter precedence is deliberate — when a tool is excluded by both the operator's `tools: { except: [...] }` AND the mutation gate (or the mode ceiling), the operator-filter message wins so the operator looks at the right knob first. The mode-ceiling message names the tool, not the class — even when the request would have hit an `agent_hidden` class, the ceiling fires first for a refused tool, so the LLM does not learn anything about the class. For tools that pass the ceiling (e.g. `query_class`) the in-tool `assert_class_accessible!` runs next and the `agent_hidden` message echoes the class name supplied by the caller.
|
|
3108
3108
|
|
|
3109
|
+
### Client mode from a webhook — run a handler as the calling user (v5.3.0)
|
|
3110
|
+
|
|
3111
|
+
Parse Server includes the caller's live session token (`user.sessionToken`) in
|
|
3112
|
+
every trigger webhook fired by a logged-in user (it is absent for a master-key
|
|
3113
|
+
request). `Parse::Webhooks::Payload` captures that token before scrubbing it out
|
|
3114
|
+
of `payload.user` / `payload.object` (so it never lands in `payload.as_json` or
|
|
3115
|
+
the request log) and exposes two opt-in, user-scoped handles — the webhook
|
|
3116
|
+
counterpart of constructing a client-mode agent by hand:
|
|
3117
|
+
|
|
3118
|
+
```ruby
|
|
3119
|
+
Parse::Webhooks.route(:after_save, "Post") do
|
|
3120
|
+
# self is the Parse::Webhooks::Payload.
|
|
3121
|
+
next true unless session_token? # master-key save → no caller token
|
|
3122
|
+
|
|
3123
|
+
# A client-mode Parse::Agent bound to the caller — ACL/CLP enforced, no
|
|
3124
|
+
# master-key fallback. Same posture as Parse::Agent.new(session_token:, client: <no master_key>).
|
|
3125
|
+
visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
|
|
3126
|
+
|
|
3127
|
+
# …or a raw user-scoped Parse::Client (token is BOUND, so plain REST calls
|
|
3128
|
+
# are authorized as the user with no per-call session_token: needed):
|
|
3129
|
+
mine = user_client.request(:get, "classes/Post").result
|
|
3130
|
+
true
|
|
3131
|
+
end
|
|
3132
|
+
```
|
|
3133
|
+
|
|
3134
|
+
| Payload handle | Returns | `nil` when |
|
|
3135
|
+
|----------------|---------|-----------|
|
|
3136
|
+
| `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
|
|
3137
|
+
| `payload.user_agent(**opts)` | non-master `Parse::Agent` in **client mode**, token bound | no token |
|
|
3138
|
+
| `payload.user_client` | non-master `Parse::Client` with the token **bound** | no token |
|
|
3139
|
+
|
|
3140
|
+
`user_client` binds the token via the new `Parse::Client.new(session_token:)`
|
|
3141
|
+
option, applied as the lowest-priority auth fallback on every request — an
|
|
3142
|
+
explicit per-call `session_token:`, a `Parse.with_session` block, or an explicit
|
|
3143
|
+
`use_master_key: true` all still take precedence. Everything the Client Mode
|
|
3144
|
+
ceiling above says about a hand-built client-mode agent applies verbatim to
|
|
3145
|
+
`payload.user_agent`: read tools only unless `allow_mutations: true`, and
|
|
3146
|
+
`acl_user:` / `acl_role:` are not available on a no-master client.
|
|
3147
|
+
|
|
3148
|
+
The same user-scoped client is available client-side from a login
|
|
3149
|
+
(`Parse::User#session_client`, or `Parse.client.become(token)` from any token),
|
|
3150
|
+
and `Parse::User#with_session` / `Parse::Client#with_session` run a block as the
|
|
3151
|
+
user so ordinary model operations are implicitly scoped:
|
|
3152
|
+
|
|
3153
|
+
```ruby
|
|
3154
|
+
client = Parse::User.login(username, password).session_client # non-master, token bound
|
|
3155
|
+
Parse::Query.new("Post", client: client).results # query as the user
|
|
3156
|
+
Parse::User.login(username, password).with_session { Post.query.count }
|
|
3157
|
+
```
|
|
3158
|
+
|
|
3159
|
+
`with_session` (and `Parse.with_session`) authorize **REST-routed** operations
|
|
3160
|
+
(`find` / `get` / `count` / `save`) as the user. Mongo-direct queries
|
|
3161
|
+
(`results_direct`, `aggregate`, Atlas search) do NOT pick up the ambient
|
|
3162
|
+
session — they resolve auth from the query's own `session_token:` / `acl_user:`
|
|
3163
|
+
and otherwise run in **master** mode (a full master read, not anonymous), so
|
|
3164
|
+
scope them explicitly with a per-query `session_token:` or a scoped
|
|
3165
|
+
`Parse::Agent`. This is deliberate: mongo-direct scoping is always explicit in
|
|
3166
|
+
this SDK, so the ambient fiber state can never silently flip a mongo-direct
|
|
3167
|
+
query into user scope (or be mistaken for it).
|
|
3168
|
+
|
|
3109
3169
|
---
|
|
3110
3170
|
|
|
3111
3171
|
## `agent_hidden` — Per-Class Agent-Surface Denial
|
data/lib/parse/client.rb
CHANGED
|
@@ -331,7 +331,86 @@ module Parse
|
|
|
331
331
|
attr_accessor :cache
|
|
332
332
|
attr_writer :retry_limit
|
|
333
333
|
attr_reader :application_id, :api_key, :master_key, :server_url
|
|
334
|
+
# @return [String, nil] the session token bound to this client, if any
|
|
335
|
+
# (see the `:session_token` constructor option). Applied as the
|
|
336
|
+
# lowest-priority auth fallback on every request.
|
|
337
|
+
attr_reader :session_token
|
|
334
338
|
alias_method :app_id, :application_id
|
|
339
|
+
|
|
340
|
+
# Redacted inspection. The default Ruby `#inspect` would dump every ivar,
|
|
341
|
+
# exposing the master key and any bound session token in cleartext wherever
|
|
342
|
+
# a client is logged or surfaced in an error reporter. Show only the
|
|
343
|
+
# connection identity and a boolean for each credential's presence.
|
|
344
|
+
def inspect
|
|
345
|
+
"#<#{self.class.name} server_url=#{@server_url.inspect} " \
|
|
346
|
+
"app_id=#{@application_id.inspect} master_key=#{@master_key ? "[FILTERED]" : "nil"} " \
|
|
347
|
+
"session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# A NEW non-master {Parse::Client} that mirrors THIS client's connection
|
|
351
|
+
# settings (`server_url` / `application_id` / `api_key`) but carries no
|
|
352
|
+
# master key and binds +session_token+, so it acts on the server as that
|
|
353
|
+
# user (ACL / CLP / `protectedFields` enforced, no master-key fallback).
|
|
354
|
+
# This is the general primitive behind {Parse::Webhooks::Payload#user_client}
|
|
355
|
+
# and {Parse::User#session_client}: derive a user-scoped client from a
|
|
356
|
+
# configured (e.g. master) client without re-specifying the connection.
|
|
357
|
+
#
|
|
358
|
+
# user_client = Parse.client.become(user.session_token)
|
|
359
|
+
# Parse::Query.new("Post", client: user_client).results # as the user
|
|
360
|
+
#
|
|
361
|
+
# @param session_token [String, #session_token] the token to bind. A blank
|
|
362
|
+
# token yields a tokenless non-master client (anonymous REST).
|
|
363
|
+
# @return [Parse::Client]
|
|
364
|
+
def become(session_token)
|
|
365
|
+
Parse::Client.new(
|
|
366
|
+
server_url: @server_url,
|
|
367
|
+
app_id: @application_id,
|
|
368
|
+
api_key: @api_key,
|
|
369
|
+
master_key: nil,
|
|
370
|
+
session_token: session_token,
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A NEW anonymous client that mirrors THIS client's connection but carries
|
|
375
|
+
# neither a master key nor a session token — every request it makes is
|
|
376
|
+
# unauthenticated (app-id + REST key only). Use it to drop the bound user
|
|
377
|
+
# identity for a one-off public read without mutating a shared client.
|
|
378
|
+
# Equivalent to {#become} with no token.
|
|
379
|
+
# @return [Parse::Client]
|
|
380
|
+
def anonymous
|
|
381
|
+
become(nil)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Run a block with this client's bound {#session_token} active as the
|
|
385
|
+
# ambient session, so every query / object operation inside it that resolves
|
|
386
|
+
# the default client (e.g. `Post.count`, `Post.all`, `obj.save`) is
|
|
387
|
+
# authorized by Parse Server as that user — ACL and CLP enforced, master key
|
|
388
|
+
# suppressed — without threading `session_token:` through each call.
|
|
389
|
+
#
|
|
390
|
+
# This is the client-receiver flavor of {Parse.with_session} (and mirrors
|
|
391
|
+
# {Parse::User#with_session}); it scopes by binding the token as the AMBIENT
|
|
392
|
+
# session — it does not re-route operations through this client object, so
|
|
393
|
+
# the connection used inside the block is still the resolved default client.
|
|
394
|
+
# If you need operations to run against a different client, pass that client
|
|
395
|
+
# explicitly (e.g. `Parse::Query.new("Post", client: #{become}(...))`).
|
|
396
|
+
#
|
|
397
|
+
# total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
|
|
398
|
+
#
|
|
399
|
+
# Scopes REST-routed operations (`find` / `get` / `count` / `save`). It does
|
|
400
|
+
# NOT scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
|
|
401
|
+
# search): those resolve auth from the query's own `session_token:` /
|
|
402
|
+
# `acl_user:` and, absent that, run in MASTER mode — so a mongo-direct read
|
|
403
|
+
# inside this block is a full master read, not anonymous. Scope mongo-direct
|
|
404
|
+
# explicitly with a per-query `session_token:` or a scoped {Parse::Agent}.
|
|
405
|
+
#
|
|
406
|
+
# @raise [ArgumentError] if this client has no bound session token (scoping
|
|
407
|
+
# would be a no-op and almost certainly a mistake).
|
|
408
|
+
# @return the value of the block.
|
|
409
|
+
def with_session(&block)
|
|
410
|
+
raise ArgumentError, "Parse::Client#with_session requires a block" unless block_given?
|
|
411
|
+
raise ArgumentError, "Parse::Client#with_session requires a client with a bound session_token" if @session_token.nil?
|
|
412
|
+
Parse.with_session(@session_token, &block)
|
|
413
|
+
end
|
|
335
414
|
# The client can support multiple sessions. The first session created, will be placed
|
|
336
415
|
# under the default session tag. The :default session will be the default client to be used
|
|
337
416
|
# by the other classes including Parse::Query and Parse::Objects
|
|
@@ -415,6 +494,14 @@ module Parse
|
|
|
415
494
|
# @option opts [String] :master_key The Parse application master key (optional).
|
|
416
495
|
# If this key is set, it will be sent on every request sent by the client
|
|
417
496
|
# and your models. Defaults to ENV['PARSE_SERVER_MASTER_KEY'].
|
|
497
|
+
# @option opts [String] :session_token An optional session token bound to
|
|
498
|
+
# this client. When set, every request that does not pass an explicit
|
|
499
|
+
# `session_token:` / `use_master_key: true` and is not inside a
|
|
500
|
+
# `Parse.with_session` block sends this token (and suppresses the master
|
|
501
|
+
# key), so the client transparently acts as that user. Precedence is
|
|
502
|
+
# explicit per-call > `Parse.with_session` ambient > this bound token.
|
|
503
|
+
# Typically paired with `master_key: nil` to build a user-scoped client
|
|
504
|
+
# (see {Parse::Webhooks::Payload#user_client}).
|
|
418
505
|
# @option opts [Boolean, Symbol] :logging Controls request/response logging.
|
|
419
506
|
# - `true` - Enable logging at :info level
|
|
420
507
|
# - `:debug` - Enable verbose logging with headers and body content
|
|
@@ -492,7 +579,26 @@ module Parse
|
|
|
492
579
|
@server_url = opts[:server_url] || ENV["PARSE_SERVER_URL"] || Parse::Protocol::SERVER_URL
|
|
493
580
|
@application_id = opts[:application_id] || opts[:app_id] || ENV["PARSE_SERVER_APPLICATION_ID"] || ENV["PARSE_APP_ID"]
|
|
494
581
|
@api_key = opts[:api_key] || opts[:rest_api_key] || ENV["PARSE_SERVER_REST_API_KEY"] || ENV["PARSE_API_KEY"]
|
|
495
|
-
|
|
582
|
+
# Distinguish an explicit `master_key: nil` (deliberately a non-master
|
|
583
|
+
# client — what user_client / session_client / user_agent rely on) from
|
|
584
|
+
# an omitted key (fall back to ENV). The previous `opts[:master_key] ||
|
|
585
|
+
# ENV[...]` form silently re-inherited the process master key for the
|
|
586
|
+
# explicit-nil case, putting a "non-master" client back into master mode
|
|
587
|
+
# in any deployment that exports PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY.
|
|
588
|
+
@master_key = if opts.key?(:master_key)
|
|
589
|
+
opts[:master_key]
|
|
590
|
+
else
|
|
591
|
+
ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
|
|
592
|
+
end
|
|
593
|
+
# Optional token bound to this client; applied per request as the
|
|
594
|
+
# lowest-priority auth fallback (see #request). Normalize blank/whitespace
|
|
595
|
+
# to nil so it never trips the "token present" branch at request time
|
|
596
|
+
# (where `present?` is false for whitespace) and silently fall back to the
|
|
597
|
+
# master key on a master-configured client.
|
|
598
|
+
bound_token = opts[:session_token]
|
|
599
|
+
bound_token = bound_token.session_token if bound_token.respond_to?(:session_token)
|
|
600
|
+
bound_token = bound_token.to_s.strip
|
|
601
|
+
@session_token = bound_token.empty? ? nil : bound_token
|
|
496
602
|
|
|
497
603
|
@require_https = opts.fetch(:require_https, ENV["PARSE_REQUIRE_HTTPS"] == "true")
|
|
498
604
|
@allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
|
|
@@ -1016,14 +1122,20 @@ module Parse
|
|
|
1016
1122
|
|
|
1017
1123
|
token = opts[:session_token]
|
|
1018
1124
|
# When no explicit token was passed AND the caller didn't ask to send
|
|
1019
|
-
# the master key, fall through to the fiber-local ambient set
|
|
1020
|
-
# `Parse.with_session
|
|
1021
|
-
#
|
|
1022
|
-
# `admin.do_thing(use_master_key: true)`
|
|
1023
|
-
# `with_session(user)` block
|
|
1125
|
+
# the master key, fall through to (in order) the fiber-local ambient set
|
|
1126
|
+
# by `Parse.with_session`, then this client's own bound `@session_token`.
|
|
1127
|
+
# Explicit `use_master_key: true` is treated as a deliberate admin call
|
|
1128
|
+
# and skips both — otherwise an `admin.do_thing(use_master_key: true)`
|
|
1129
|
+
# nested inside a `with_session(user)` block (or on a token-bound client)
|
|
1130
|
+
# would silently downgrade. The ambient wins over the bound token so a
|
|
1131
|
+
# `with_session` override inside a user-scoped client still takes effect.
|
|
1024
1132
|
if token.nil? && !(explicit_master && opts[:use_master_key] == true)
|
|
1025
1133
|
ambient = Parse.current_session_token
|
|
1026
|
-
|
|
1134
|
+
# A whitespace-only ambient must not count as present: otherwise it
|
|
1135
|
+
# blocks the bound-token fallback below and then fails the later
|
|
1136
|
+
# `token.present?` check, silently sending the master key instead.
|
|
1137
|
+
token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
|
|
1138
|
+
token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
|
|
1027
1139
|
end
|
|
1028
1140
|
if token.present?
|
|
1029
1141
|
token = token.session_token if token.respond_to?(:session_token)
|
|
@@ -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
|
|
|
@@ -835,6 +835,26 @@ module Parse
|
|
|
835
835
|
@session
|
|
836
836
|
end
|
|
837
837
|
|
|
838
|
+
# A non-master {Parse::Client} bound to this user's session token, for
|
|
839
|
+
# acting on the server *as this user* with full ACL / CLP / +protectedFields+
|
|
840
|
+
# enforcement and no master-key fallback. It mirrors the connection settings
|
|
841
|
+
# of +base+ (the configured client by default) but carries no master key and
|
|
842
|
+
# binds {#session_token}, so even raw REST calls through it are authorized as
|
|
843
|
+
# the user with no per-call ceremony. The web-counterpart of
|
|
844
|
+
# {Parse::Webhooks::Payload#user_client}; the typical client-side entry point
|
|
845
|
+
# is right after a login:
|
|
846
|
+
#
|
|
847
|
+
# client = Parse::User.login(username, password).session_client
|
|
848
|
+
# Parse::Query.new("Post", client: client).results # scoped to the user
|
|
849
|
+
#
|
|
850
|
+
# @param base [Parse::Client] the client whose connection settings to mirror.
|
|
851
|
+
# @return [Parse::Client, nil] +nil+ when the user has no session token
|
|
852
|
+
# (e.g. fetched/saved under the master key rather than logged in).
|
|
853
|
+
def session_client(base = self.client)
|
|
854
|
+
return nil if @session_token.nil? || @session_token.to_s.strip.empty?
|
|
855
|
+
base.become(@session_token)
|
|
856
|
+
end
|
|
857
|
+
|
|
838
858
|
# @!visibility private
|
|
839
859
|
# Keys that must never flow through +Parse::User.create+ from a
|
|
840
860
|
# mass-assigned hash. +authData+ on the user-signup endpoint causes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
# Global `const_missing` hook that lazily resolves the plural form of a
|
|
6
|
+
# {Parse::Object} subclass constant to that class. Referencing `Posts`
|
|
7
|
+
# when a class `Post` exists installs `Posts` as an alias for `Post` on
|
|
8
|
+
# the referencing module and returns it, so query entry points like
|
|
9
|
+
# `Posts.where(...).count` work without any per-model boilerplate.
|
|
10
|
+
#
|
|
11
|
+
# The hook is prepended onto `Module` so it applies to constant lookups
|
|
12
|
+
# in any namespace (top-level and nested). It is tightly guarded: every
|
|
13
|
+
# path that is not a plural-of-a-Parse-class falls through to `super`,
|
|
14
|
+
# preserving normal `NameError` and autoloader (Zeitwerk/classic)
|
|
15
|
+
# behavior. The whole feature is gated on {Parse.pluralized_aliases?} so
|
|
16
|
+
# opting out (`Parse.pluralized_aliases = false`) makes this a near-zero
|
|
17
|
+
# cost pass-through.
|
|
18
|
+
#
|
|
19
|
+
# @see Parse.pluralized_aliases
|
|
20
|
+
# @see Parse.__pluralized_alias_for
|
|
21
|
+
module PluralizedAliases
|
|
22
|
+
def const_missing(name)
|
|
23
|
+
klass = Parse.__pluralized_alias_for(self, name) if defined?(Parse)
|
|
24
|
+
return klass unless klass.nil?
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Module.prepend(Parse::PluralizedAliases)
|
|
@@ -197,6 +197,33 @@ module Parse
|
|
|
197
197
|
# data_type = :timezone if key == :time_zone || key == :timezone
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
+
# A property that names a pointer target — via `as:`/`class_name:` or an
|
|
201
|
+
# explicit :pointer data type — is really a belongs_to association, not a
|
|
202
|
+
# scalar column. Delegate so `property :rejected_by, as: :user` behaves
|
|
203
|
+
# identically to `belongs_to :rejected_by, as: :user` instead of silently
|
|
204
|
+
# storing a String (the `as:` option was previously dropped, leaving the
|
|
205
|
+
# field as the default :string type). Reusing belongs_to keeps the
|
|
206
|
+
# className-trust guard and autofetch/dirty-tracking in one place rather
|
|
207
|
+
# than duplicating pointer handling here. `opts` is still raw user input
|
|
208
|
+
# at this point (the defaults merge happens further down), so only an
|
|
209
|
+
# explicitly-passed :field is forwarded and belongs_to defaults the rest.
|
|
210
|
+
if opts.key?(:as) || opts.key?(:class_name) || data_type == :pointer
|
|
211
|
+
forwarded = %i[as class_name field required _description _enum]
|
|
212
|
+
bt_opts = {}
|
|
213
|
+
forwarded.each { |o| bt_opts[o] = opts[o] if opts.key?(o) }
|
|
214
|
+
# belongs_to has no scalar-property machinery (defaults, symbolize,
|
|
215
|
+
# enum validation, alias toggles, scope generation). Surface — rather
|
|
216
|
+
# than silently drop — any such option so a `default:`/`symbolize:` on
|
|
217
|
+
# a pointer property isn't quietly ignored.
|
|
218
|
+
dropped = opts.keys.map(&:to_sym) - forwarded
|
|
219
|
+
unless dropped.empty?
|
|
220
|
+
warn "[#{self}] property #{key.inspect} resolves to a pointer association; " \
|
|
221
|
+
"ignoring unsupported option(s) #{dropped.map(&:inspect).join(', ')} " \
|
|
222
|
+
"(not available on belongs_to)."
|
|
223
|
+
end
|
|
224
|
+
return belongs_to(key, bt_opts)
|
|
225
|
+
end
|
|
226
|
+
|
|
200
227
|
data_type = :timezone if data_type == :string && (key == :time_zone || key == :timezone)
|
|
201
228
|
|
|
202
229
|
# allow :bool for :boolean
|
|
@@ -120,6 +120,76 @@ module Parse
|
|
|
120
120
|
|
|
121
121
|
alias_method :where, :query
|
|
122
122
|
|
|
123
|
+
# Define a pluralized constant alias for this class so the plural form
|
|
124
|
+
# can be used as a query entry point — e.g. `Posts.where(...).count`
|
|
125
|
+
# for a class `Post`. The alias is the same class object, so every
|
|
126
|
+
# class method (`where`, `query`, `count`, `find`, `all`, scopes)
|
|
127
|
+
# works through it and `Posts.parse_class` still returns `"Post"`.
|
|
128
|
+
#
|
|
129
|
+
# This is the explicit counterpart to the automatic
|
|
130
|
+
# {Parse.pluralized_aliases} behavior. Use it when automatic aliasing
|
|
131
|
+
# is disabled, when the class name ends in `s` (which the automatic
|
|
132
|
+
# path skips), when you want a custom plural, or for namespaced models
|
|
133
|
+
# (the alias is defined on the enclosing module, not at top level).
|
|
134
|
+
#
|
|
135
|
+
# @example default plural
|
|
136
|
+
# class Post < Parse::Object
|
|
137
|
+
# pluralized_alias! # defines ::Posts => Post
|
|
138
|
+
# end
|
|
139
|
+
#
|
|
140
|
+
# @example custom plural for a name ending in `s`
|
|
141
|
+
# class Status < Parse::Object
|
|
142
|
+
# pluralized_alias! :Statuses
|
|
143
|
+
# end
|
|
144
|
+
#
|
|
145
|
+
# @example namespaced model
|
|
146
|
+
# module Blog
|
|
147
|
+
# class Post < Parse::Object
|
|
148
|
+
# pluralized_alias! # defines Blog::Posts => Blog::Post
|
|
149
|
+
# end
|
|
150
|
+
# end
|
|
151
|
+
#
|
|
152
|
+
# @param constant_name [Symbol, String, nil] the plural constant to
|
|
153
|
+
# define; defaults to the ActiveSupport pluralization of the class's
|
|
154
|
+
# demodulized name.
|
|
155
|
+
# @raise [ArgumentError] if the target constant already exists and is
|
|
156
|
+
# not this class.
|
|
157
|
+
# @return [self, nil] self when an alias exists/was created; nil if the
|
|
158
|
+
# class is anonymous or the plural matches the singular name.
|
|
159
|
+
def pluralized_alias!(constant_name = nil)
|
|
160
|
+
base = name
|
|
161
|
+
return nil if base.nil?
|
|
162
|
+
parts = base.split("::")
|
|
163
|
+
short = parts.last
|
|
164
|
+
plural = (constant_name && constant_name.to_s) || short.pluralize
|
|
165
|
+
return nil if plural == short
|
|
166
|
+
# NOTE: bare `Object` here would lexically resolve to `Parse::Object`
|
|
167
|
+
# (we are inside module Parse::Core::Querying), so the alias must be
|
|
168
|
+
# anchored at the true top level with `::Object`.
|
|
169
|
+
parent = parts.length > 1 ? parts[0..-2].join("::").constantize : ::Object
|
|
170
|
+
if parent.const_defined?(plural.to_sym, false)
|
|
171
|
+
existing = parent.const_get(plural.to_sym)
|
|
172
|
+
return self if existing.equal?(self)
|
|
173
|
+
# A code reloader (Zeitwerk in development) swaps `self` for a fresh
|
|
174
|
+
# class object but does not clean up the alias constant we set — it
|
|
175
|
+
# owns no autoload entry for it. On re-run of the class body the
|
|
176
|
+
# plural still points at the now-orphaned previous class. Re-point it
|
|
177
|
+
# to the current class instead of raising on every reload. Only a
|
|
178
|
+
# genuinely foreign constant (not a Parse model mapping to the same
|
|
179
|
+
# remote class) is treated as a conflict.
|
|
180
|
+
stale_reload = existing.is_a?(Class) && existing < Parse::Object &&
|
|
181
|
+
existing.parse_class == parse_class
|
|
182
|
+
unless stale_reload
|
|
183
|
+
raise ArgumentError,
|
|
184
|
+
"Cannot define pluralized alias #{plural} for #{base}: " \
|
|
185
|
+
"constant already defined as #{existing}."
|
|
186
|
+
end
|
|
187
|
+
parent.send(:remove_const, plural.to_sym)
|
|
188
|
+
end
|
|
189
|
+
parent.const_set(plural.to_sym, self)
|
|
190
|
+
self
|
|
191
|
+
end
|
|
192
|
+
|
|
123
193
|
# @param conditions (see Parse::Query#where)
|
|
124
194
|
# @return (see Parse::Query#where)
|
|
125
195
|
# @see Parse::Query#where
|
data/lib/parse/model/file.rb
CHANGED
|
@@ -734,10 +734,43 @@ module Parse
|
|
|
734
734
|
ATTRIBUTES
|
|
735
735
|
end
|
|
736
736
|
|
|
737
|
-
#
|
|
737
|
+
# The value used to decide whether two {Parse::File}s refer to the same
|
|
738
|
+
# underlying file -- for equality ({#==}) and, through it, for dirty
|
|
739
|
+
# tracking (the property setter compares files with `==` to decide whether
|
|
740
|
+
# a `:file` field changed).
|
|
741
|
+
#
|
|
742
|
+
# Today this is the bare canonical {#url}: signed-URL query parameters are
|
|
743
|
+
# stripped into `@presigned_url` and `force_ssl` coercion is applied, so two
|
|
744
|
+
# files at the same storage location compare equal regardless of how the URL
|
|
745
|
+
# was signed or whether it was `http`/`https`. The URL is the best identity
|
|
746
|
+
# signal currently available -- Parse Server's S3 files adapter does not
|
|
747
|
+
# surface a content digest (ETag / MD5 / sha256) through `Parse::File`.
|
|
748
|
+
#
|
|
749
|
+
# FUTURE DIRECTION: when a files adapter can expose a content hash, this is
|
|
750
|
+
# the single seam to override so equality keys off file *content* instead of
|
|
751
|
+
# URL -- e.g. a custom `Parse::File` subclass or adapter shim returning the
|
|
752
|
+
# S3 ETag / sha256 here. Overriding this one method updates {#==} (and a
|
|
753
|
+
# future `#eql?`/`#hash` pair, if added) without touching dirty tracking.
|
|
754
|
+
# No content-hash source exists yet, so the URL is authoritative for now.
|
|
755
|
+
# @return [String, nil]
|
|
756
|
+
def content_signature
|
|
757
|
+
url
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# @return [Boolean] Two files are equal when their {#content_signature}
|
|
761
|
+
# matches. Both sides go through the same reader, so the comparison is
|
|
762
|
+
# symmetric and force_ssl-consistent: the previous `@url == u.url` form
|
|
763
|
+
# compared one side's raw stored URL against the other's normalized
|
|
764
|
+
# reader, so two files at the same location read as unequal whenever
|
|
765
|
+
# {Parse::File.force_ssl} coerced one side from `http://` to `https://`
|
|
766
|
+
# (and `a == b` disagreed with `b == a`). Because the default signature is
|
|
767
|
+
# the bare canonical URL (signed-URL query parameters stripped into
|
|
768
|
+
# `@presigned_url`), a freshly re-signed URL for the same object is equal
|
|
769
|
+
# while a different underlying location is not. See {#content_signature}
|
|
770
|
+
# for the content-hash override seam.
|
|
738
771
|
def ==(u)
|
|
739
772
|
return false unless u.is_a?(self.class)
|
|
740
|
-
|
|
773
|
+
content_signature == u.content_signature
|
|
741
774
|
end
|
|
742
775
|
|
|
743
776
|
# Allows mass assignment from a Parse JSON hash.
|