parse-stack-next 5.2.0 → 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 +240 -0
- data/Gemfile.lock +1 -1
- data/README.md +195 -1
- data/Rakefile +193 -40
- data/docs/client_sdk_guide.md +33 -0
- data/docs/mcp_guide.md +135 -4
- 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/actions.rb +7 -9
- 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/model/object.rb +41 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +156 -1
- data/lib/parse/webhooks/payload.rb +205 -34
- data/lib/parse/webhooks.rb +15 -3
- metadata +2 -1
|
@@ -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
|
|
@@ -1196,16 +1196,14 @@ module Parse
|
|
|
1196
1196
|
success
|
|
1197
1197
|
end
|
|
1198
1198
|
|
|
1199
|
-
#
|
|
1199
|
+
# Back-compat alias for {Parse::Object#run_before_save_callbacks}. The
|
|
1200
|
+
# canonical name spells out exactly what runs (the before_save callbacks,
|
|
1201
|
+
# before phase only) and is symmetric with run_after_save_callbacks /
|
|
1202
|
+
# run_before_create_callbacks / run_after_create_callbacks. Retained so
|
|
1203
|
+
# existing callers of `prepare_save!` keep working.
|
|
1204
|
+
# @return [Boolean] false if a before_save callback halted the chain, else true.
|
|
1200
1205
|
def prepare_save!
|
|
1201
|
-
|
|
1202
|
-
# We track if the block executes to know if callbacks were halted
|
|
1203
|
-
callback_success = false
|
|
1204
|
-
run_callbacks(:save) do
|
|
1205
|
-
callback_success = true
|
|
1206
|
-
true
|
|
1207
|
-
end
|
|
1208
|
-
callback_success
|
|
1206
|
+
run_before_save_callbacks
|
|
1209
1207
|
end
|
|
1210
1208
|
|
|
1211
1209
|
# @return [Hash] a hash of the list of changes made to this instance.
|
|
@@ -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.
|
data/lib/parse/model/object.rb
CHANGED
|
@@ -1645,6 +1645,25 @@ module Parse
|
|
|
1645
1645
|
run_callbacks_from_list(self.class._destroy_callbacks, :after)
|
|
1646
1646
|
end
|
|
1647
1647
|
|
|
1648
|
+
# Run before_save callbacks for this object (BEFORE phase only). Used by the
|
|
1649
|
+
# beforeSave webhook. Honors :if/:unless conditions and the callback
|
|
1650
|
+
# terminator: returns false if a callback halts the chain. The after_*
|
|
1651
|
+
# callbacks are NOT run here -- they belong to the afterSave webhook.
|
|
1652
|
+
# @return [Boolean] false if a before_save callback halted the chain, else true.
|
|
1653
|
+
def run_before_save_callbacks
|
|
1654
|
+
run_before_phase_callbacks(:save)
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
# Run before_create callbacks for this object (BEFORE phase only). Parse
|
|
1658
|
+
# Server exposes no separate beforeCreate trigger, so the beforeSave webhook
|
|
1659
|
+
# runs these for new objects right after before_save -- matching ActiveModel
|
|
1660
|
+
# order, where before_save wraps before_create. Honors :if/:unless and the
|
|
1661
|
+
# terminator.
|
|
1662
|
+
# @return [Boolean] false if a before_create callback halted the chain, else true.
|
|
1663
|
+
def run_before_create_callbacks
|
|
1664
|
+
run_before_phase_callbacks(:create)
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1648
1667
|
# Returns a hash of all the changes that have been made to the object. By default
|
|
1649
1668
|
# changes to the Parse::Properties::BASE_KEYS are ignored unless you pass true as
|
|
1650
1669
|
# an argument.
|
|
@@ -2054,6 +2073,28 @@ module Parse
|
|
|
2054
2073
|
end
|
|
2055
2074
|
true
|
|
2056
2075
|
end
|
|
2076
|
+
|
|
2077
|
+
# Runs ONLY the before-phase callbacks of an ActiveModel callback chain
|
|
2078
|
+
# (`:save` or `:create`), fully honoring `:if`/`:unless` conditions and the
|
|
2079
|
+
# model's terminator, WITHOUT running the after-phase callbacks. ActiveModel
|
|
2080
|
+
# `run_callbacks(kind) { block }` runs before -> block -> after; we throw out
|
|
2081
|
+
# of the block so the after callbacks never run, while the before callbacks
|
|
2082
|
+
# get complete condition/terminator handling. If a before callback halts the
|
|
2083
|
+
# chain (returns false), the block never executes, so `completed` stays false
|
|
2084
|
+
# and we report the halt. Used by the webhook before-phase so it does not
|
|
2085
|
+
# double-fire after_* (those belong to the afterSave webhook).
|
|
2086
|
+
# @return [Boolean] false if the chain was halted by a before callback.
|
|
2087
|
+
def run_before_phase_callbacks(kind)
|
|
2088
|
+
tag = :"__parse_before_phase_#{kind}"
|
|
2089
|
+
completed = false
|
|
2090
|
+
catch(tag) do
|
|
2091
|
+
run_callbacks(kind) do
|
|
2092
|
+
completed = true
|
|
2093
|
+
throw tag
|
|
2094
|
+
end
|
|
2095
|
+
end
|
|
2096
|
+
completed
|
|
2097
|
+
end
|
|
2057
2098
|
end
|
|
2058
2099
|
end
|
|
2059
2100
|
|
data/lib/parse/stack/version.rb
CHANGED
data/lib/parse/stack.rb
CHANGED
|
@@ -548,6 +548,21 @@ module Parse
|
|
|
548
548
|
# PARSE_STRICT_POINTER_SHAPES=true
|
|
549
549
|
@strict_pointer_shapes = ENV["PARSE_STRICT_POINTER_SHAPES"] == "true"
|
|
550
550
|
|
|
551
|
+
# Configuration for automatic pluralized class-name aliases. When enabled
|
|
552
|
+
# (the default), referencing the plural form of a {Parse::Object} subclass
|
|
553
|
+
# constant resolves to that class, so `Posts.where(...)` works for a class
|
|
554
|
+
# `Post`. The alias is created lazily on first reference via `const_missing`
|
|
555
|
+
# and points at the same class object, so every class method
|
|
556
|
+
# (`where`, `query`, `count`, `find`, `all`, scopes) works for free and
|
|
557
|
+
# `Posts.parse_class` still returns `"Post"`. Classes whose name already
|
|
558
|
+
# ends in `s` are skipped. Set to false to opt out globally.
|
|
559
|
+
# @example Opt out globally
|
|
560
|
+
# Parse.pluralized_aliases = false
|
|
561
|
+
# @example ENV opt-out
|
|
562
|
+
# PARSE_PLURALIZED_ALIASES=false
|
|
563
|
+
# @see Parse::Core::Querying#pluralized_alias!
|
|
564
|
+
@pluralized_aliases = ENV["PARSE_PLURALIZED_ALIASES"] != "false"
|
|
565
|
+
|
|
551
566
|
# Tuning bundle for the synchronize-create lock. Per-call kwargs override.
|
|
552
567
|
# Keys: :ttl (seconds, default 3, max 30), :wait (seconds, default 2.0,
|
|
553
568
|
# max 30), :on_degraded (:warn, :warn_throttled, :raise, :proceed).
|
|
@@ -630,7 +645,8 @@ module Parse
|
|
|
630
645
|
:rewrite_lookups, :strict_property_redefinition,
|
|
631
646
|
:synchronize_create_default, :synchronize_create_options, :synchronize_create_secret,
|
|
632
647
|
:synchronize_create_store, :synchronize_classes,
|
|
633
|
-
:strict_pointer_shapes, :suppress_server_version_warning
|
|
648
|
+
:strict_pointer_shapes, :suppress_server_version_warning,
|
|
649
|
+
:pluralized_aliases
|
|
634
650
|
|
|
635
651
|
# Check whether the Parse Server version deprecation warning is
|
|
636
652
|
# silenced. Returns true if either the in-process accessor or the
|
|
@@ -714,6 +730,140 @@ module Parse
|
|
|
714
730
|
@strict_pointer_shapes == true
|
|
715
731
|
end
|
|
716
732
|
|
|
733
|
+
# Whether automatic pluralized class-name aliases are enabled. Defaults
|
|
734
|
+
# to true; opt out with `Parse.pluralized_aliases = false` or
|
|
735
|
+
# `PARSE_PLURALIZED_ALIASES=false`. See {Parse.pluralized_aliases}.
|
|
736
|
+
# @return [Boolean]
|
|
737
|
+
def pluralized_aliases?
|
|
738
|
+
@pluralized_aliases != false
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# @!visibility private
|
|
742
|
+
# Resolve a (possibly plural) missing constant to its singular
|
|
743
|
+
# {Parse::Object} subclass and install the alias on the referencing
|
|
744
|
+
# module. Returns the class when an alias was created, otherwise nil so
|
|
745
|
+
# the caller (`const_missing`) can fall through to `super` and preserve
|
|
746
|
+
# normal `NameError` / autoloading behavior.
|
|
747
|
+
#
|
|
748
|
+
# Guards (fail-through to nil unless ALL hold):
|
|
749
|
+
# - the feature is enabled,
|
|
750
|
+
# - {Parse::Object} is loaded,
|
|
751
|
+
# - the name singularizes to a *different* string (i.e. looks plural),
|
|
752
|
+
# - the singular form does NOT already end in `s` (per design: classes
|
|
753
|
+
# whose name ends in `s` are not auto-aliased),
|
|
754
|
+
# - the singular constant is defined (searching ancestors so a
|
|
755
|
+
# top-level model is visible from a nested reference) and is a
|
|
756
|
+
# `Parse::Object` subclass,
|
|
757
|
+
# - the plural is not already defined on the referencing module.
|
|
758
|
+
#
|
|
759
|
+
# @param mod [Module] the module/class on which `const_missing` fired.
|
|
760
|
+
# @param name [Symbol] the missing constant name.
|
|
761
|
+
# @return [Class, nil]
|
|
762
|
+
def __pluralized_alias_for(mod, name)
|
|
763
|
+
return nil unless pluralized_aliases?
|
|
764
|
+
return nil unless defined?(Parse::Object)
|
|
765
|
+
str = name.to_s
|
|
766
|
+
singular = str.singularize
|
|
767
|
+
return nil if singular == str
|
|
768
|
+
return nil if singular.end_with?("s")
|
|
769
|
+
sym = singular.to_sym
|
|
770
|
+
return nil unless mod.const_defined?(sym, true)
|
|
771
|
+
klass = mod.const_get(sym)
|
|
772
|
+
return nil unless klass.is_a?(Class) && klass < Parse::Object
|
|
773
|
+
return nil if mod.const_defined?(name, false)
|
|
774
|
+
mod.const_set(name, klass)
|
|
775
|
+
klass
|
|
776
|
+
rescue NameError, LoadError
|
|
777
|
+
# const_get/const_defined? can raise on malformed names or autoload
|
|
778
|
+
# failures; never let alias resolution mask the original lookup.
|
|
779
|
+
nil
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Verify that every association target across the loaded {Parse::Object}
|
|
783
|
+
# subclasses resolves to a known Parse class. Covers `belongs_to` and
|
|
784
|
+
# `property … as:` pointer targets (via each class's `references`),
|
|
785
|
+
# `has_many … through: :relation` targets (via `relations`), and the
|
|
786
|
+
# query- and array-backed `has_many` targets (via `has_many_associations`)
|
|
787
|
+
# — the bucket where an `as:` typo otherwise stays latent until the
|
|
788
|
+
# association is first traversed at call time.
|
|
789
|
+
#
|
|
790
|
+
# This is the deferred companion to the definition-time scalar guard in
|
|
791
|
+
# {Parse::Associations::BelongsTo::ClassMethods#belongs_to}: at declaration
|
|
792
|
+
# time a forward reference (a target class that is required later) is legal
|
|
793
|
+
# and indistinguishable from a typo, so the cross-class resolution check is
|
|
794
|
+
# run here — after all models are loaded. Intended to run once at boot, in
|
|
795
|
+
# CI, or from a rake task ("during the upgrade").
|
|
796
|
+
#
|
|
797
|
+
# A target resolves when it is a Parse system class (`_User`, `_Role`,
|
|
798
|
+
# `_Installation`, `_Session`, …) or a registered {Parse::Object} subclass
|
|
799
|
+
# (via {Parse::Model.find_class}). Note this checks against *loaded Ruby
|
|
800
|
+
# models*: if you intentionally point at a server-side class that has no
|
|
801
|
+
# Ruby model, define a stub model for it or exclude it via `classes:`.
|
|
802
|
+
#
|
|
803
|
+
# @param classes [Array<Class>, nil] optional subset of Parse::Object
|
|
804
|
+
# subclasses to check; defaults to every loaded subclass.
|
|
805
|
+
# @raise [ArgumentError] if any target is unresolved, listing each
|
|
806
|
+
# offending `Class#field -> 'Target'`.
|
|
807
|
+
# @return [true] when every association target resolves.
|
|
808
|
+
def validate_associations!(classes: nil)
|
|
809
|
+
models = classes || Parse::Object.descendants
|
|
810
|
+
problems = []
|
|
811
|
+
models.each do |klass|
|
|
812
|
+
next unless klass.respond_to?(:parse_class)
|
|
813
|
+
if klass.respond_to?(:references)
|
|
814
|
+
klass.references.each do |field, target|
|
|
815
|
+
next if _association_target_resolvable?(target)
|
|
816
|
+
# `references` is keyed by the remote (camelCase) column; report the
|
|
817
|
+
# declared Ruby accessor so the operator can find the offending line.
|
|
818
|
+
accessor = (klass.respond_to?(:field_map) && klass.field_map.key(field)) || field
|
|
819
|
+
problems << "#{klass}##{accessor} -> #{target.inspect} (no such Parse class)"
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
if klass.respond_to?(:relations)
|
|
823
|
+
klass.relations.each do |field, target|
|
|
824
|
+
next if _association_target_resolvable?(target)
|
|
825
|
+
problems << "#{klass}##{field} (relation) -> #{target.inspect} (no such Parse class)"
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
if klass.respond_to?(:has_many_associations)
|
|
829
|
+
klass.has_many_associations.each do |accessor, meta|
|
|
830
|
+
# `:relation`-storage has_many is mirrored into `relations` and is
|
|
831
|
+
# already reported above; only the `:query` and `:array` storage
|
|
832
|
+
# targets (which live nowhere else) need checking here. This is the
|
|
833
|
+
# branch where a `has_many … as:` typo hides, since a query-backed
|
|
834
|
+
# has_many resolves its target lazily at call time.
|
|
835
|
+
next if meta[:storage] == :relation
|
|
836
|
+
target = meta[:target_class]
|
|
837
|
+
next if target.nil? || _association_target_resolvable?(target)
|
|
838
|
+
problems << "#{klass}##{accessor} (has_many #{meta[:storage]}) -> " \
|
|
839
|
+
"#{target.inspect} (no such Parse class)"
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
unless problems.empty?
|
|
844
|
+
raise ArgumentError,
|
|
845
|
+
"Unresolved Parse association targets:\n " + problems.join("\n ") +
|
|
846
|
+
"\nRequire/define the target class, or fix the `as:`/`class_name:` name."
|
|
847
|
+
end
|
|
848
|
+
true
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# @!visibility private
|
|
852
|
+
# Whether an association target class name resolves to a known Parse
|
|
853
|
+
# class. Parse system classes resolve against {Parse::Model::SYSTEM_CLASS_MAP}
|
|
854
|
+
# — both the canonical `_`-prefixed value (`_User`) and the bare-name key
|
|
855
|
+
# (`User`) — even when their Ruby class is not loaded; everything else must
|
|
856
|
+
# resolve via {Parse::Model.find_class}. A leading underscore is NOT a
|
|
857
|
+
# blanket pass: a typo'd system name such as `_Usr` is neither in the map
|
|
858
|
+
# nor a registered model, so it is still surfaced as unresolved.
|
|
859
|
+
def _association_target_resolvable?(target)
|
|
860
|
+
name = target.to_s
|
|
861
|
+
return false if name.empty?
|
|
862
|
+
return true if Parse::Model::SYSTEM_CLASS_MAP.key?(name) ||
|
|
863
|
+
Parse::Model::SYSTEM_CLASS_MAP.value?(name)
|
|
864
|
+
!Parse::Model.find_class(name).nil?
|
|
865
|
+
end
|
|
866
|
+
|
|
717
867
|
# Check if MCP server feature is enabled
|
|
718
868
|
# Requires PARSE_MCP_ENABLED=true in environment AND Parse.mcp_server_enabled = true
|
|
719
869
|
# @return [Boolean]
|
|
@@ -851,4 +1001,9 @@ end
|
|
|
851
1001
|
# the setter on load.
|
|
852
1002
|
Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
|
|
853
1003
|
|
|
1004
|
+
# Install the lazy pluralized class-name alias hook (Posts -> Post). Loaded
|
|
1005
|
+
# last so Parse::Object and the Parse.__pluralized_alias_for helper are
|
|
1006
|
+
# already defined. Gated at runtime on Parse.pluralized_aliases?.
|
|
1007
|
+
require_relative "model/core/pluralized_aliases"
|
|
1008
|
+
|
|
854
1009
|
require_relative "stack/railtie" if defined?(::Rails)
|