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.
@@ -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
- # Runs all the registered `before_save` related callbacks.
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
- # With terminator configured, run_callbacks will return false if any callback returns false
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
@@ -734,10 +734,43 @@ module Parse
734
734
  ATTRIBUTES
735
735
  end
736
736
 
737
- # @return [Boolean] Two files are equal if they have the same url
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
- @url == u.url
773
+ content_signature == u.content_signature
741
774
  end
742
775
 
743
776
  # Allows mass assignment from a Parse JSON hash.
@@ -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
 
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.2.0"
9
+ VERSION = "5.3.0"
10
10
  end
11
11
  end
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)