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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
@@ -28,6 +28,59 @@ module Parse
28
28
 
29
29
  # 125 Error code indicating that the email address was invalid.
30
30
  class InvalidEmailAddress < Error; end
31
+
32
+ # Error code 205 (Parse::Response::ERROR_EMAIL_NOT_FOUND) raised by
33
+ # {Parse::User.login!} and {Parse::User#verify_password} when Parse Server
34
+ # returns code 205 because +preventLoginWithUnverifiedEmail+ is enabled and
35
+ # the account's email address has not been verified.
36
+ #
37
+ # It is a SUBCLASS of {AuthenticationError} on purpose: before this typed
38
+ # error existed, the unverified-email rejection raised a plain
39
+ # +AuthenticationError+, so existing callers wrapping {Parse::User.login!}
40
+ # in +rescue AuthenticationError+ must keep catching it (subclassing keeps
41
+ # that contract — making it a sibling would be a silent breaking change).
42
+ # Callers who want to special-case the unverified-email path just rescue
43
+ # this narrower subclass FIRST.
44
+ #
45
+ # @example
46
+ # begin
47
+ # Parse::User.login!(username, password)
48
+ # rescue Parse::Error::EmailNotVerifiedError
49
+ # # Prompt user to check their inbox and verify their email
50
+ # rescue Parse::Error::AuthenticationError
51
+ # # Wrong credentials or other login failure (still catches the above
52
+ # # too, if no narrower rescue precedes it)
53
+ # end
54
+ class EmailNotVerifiedError < AuthenticationError; end
55
+
56
+ # Raised by {Parse::Client} when the SDK's client-side login rate-limit
57
+ # guard fires — i.e. the same username has failed
58
+ # {Parse::API::Users::LOGIN_MAX_FAILURES} or more times and the exponential
59
+ # back-off window has not yet elapsed.
60
+ #
61
+ # The class is a subclass of {AuthenticationError} so that a single
62
+ # <tt>rescue Parse::Error::AuthenticationError</tt> handler covers both
63
+ # wrong-credential failures and lockout situations. Callers that need to
64
+ # distinguish the lockout case just rescue this narrower subclass first.
65
+ # Because the previous implementation raised a plain +RuntimeError+, there
66
+ # is no prior +AuthenticationError+ rescue contract to preserve — this is
67
+ # a new typed entry in the login-failure taxonomy.
68
+ #
69
+ # Note that +Parse::Error < StandardError+, so a bare +rescue+ or
70
+ # +rescue StandardError+ still catches this error.
71
+ #
72
+ # @example
73
+ # begin
74
+ # Parse::User.login!(username, password)
75
+ # rescue Parse::Error::AccountLockoutError => e
76
+ # # Too many failed attempts — tell the user how long to wait
77
+ # retry_in = e.message[/\d+/]
78
+ # render_lockout_page(retry_in: retry_in)
79
+ # rescue Parse::Error::AuthenticationError
80
+ # # Wrong credentials (or other login failure — also catches lockout
81
+ # # if no narrower rescue precedes it)
82
+ # end
83
+ class AccountLockoutError < AuthenticationError; end
31
84
  end
32
85
 
33
86
  # The main class representing the _User table in Parse. A user can either be signed up or anonymous.
@@ -504,15 +557,55 @@ module Parse
504
557
  if hash.key?(:authData) || hash.key?("authData") ||
505
558
  hash.key?(:auth_data) || hash.key?("auth_data")
506
559
  hash = hash.dup
560
+ raw_auth = hash[:authData] || hash["authData"] ||
561
+ hash[:auth_data] || hash["auth_data"]
507
562
  hash.delete(:authData)
508
563
  hash.delete("authData")
509
564
  hash.delete(:auth_data)
510
565
  hash.delete("auth_data")
566
+ # Preserve ONLY a non-sensitive MFA status derived from the stripped
567
+ # authData, so #mfa_enabled? / #mfa_status (and the #disable_mfa!
568
+ # guard) work after an ordinary fetch without retaining the TOTP
569
+ # secret, recovery codes, mobile number, or any OAuth provider token.
570
+ # Non-MFA authData still strips to nil exactly as before.
571
+ safe = sanitized_mfa_authdata(raw_auth)
572
+ hash["authData"] = safe if safe
511
573
  end
512
574
  end
513
575
  super(hash, dirty_track: dirty_track, filter_protected: filter_protected, protected_set: protected_set)
514
576
  end
515
577
 
578
+ # @!visibility private
579
+ # Reduce a server-returned +authData+ hash to a leak-safe MFA status.
580
+ # Parse Server returns +authData.mfa+ as +{ "secret" => ..., "recovery" =>
581
+ # [...] }+ (the raw TOTP secret and one-time recovery codes) even on a
582
+ # user's own session-token read, so the value itself must never be retained.
583
+ # This keeps only +{ "mfa" => { "status" => "enabled" } }+ when MFA is
584
+ # configured, and returns +nil+ otherwise (preserving the prior
585
+ # strip-to-nil behavior for OAuth-only / non-MFA authData).
586
+ # @return [Hash, nil]
587
+ def sanitized_mfa_authdata(raw)
588
+ return nil unless raw.is_a?(Hash)
589
+ mfa = raw["mfa"] || raw[:mfa]
590
+ return nil unless mfa.is_a?(Hash)
591
+
592
+ status = mfa["status"] || mfa[:status]
593
+ # An EXPLICIT non-"enabled" status is authoritative: treat the user
594
+ # as disabled even if a stale `secret`/`recovery` lingers in the
595
+ # blob. Without this, a residual credential would override an
596
+ # explicit `status: "disabled"` and make `mfa_enabled?` report true
597
+ # for a user who has turned MFA off.
598
+ return nil if status.is_a?(String) && status != "enabled"
599
+
600
+ recovery = mfa["recovery"] || mfa[:recovery]
601
+ enabled = status == "enabled" ||
602
+ (mfa["secret"] || mfa[:secret]).present? ||
603
+ (recovery.is_a?(Array) ? recovery.any? : recovery.present?) ||
604
+ (mfa["mobile"] || mfa[:mobile]).present?
605
+
606
+ enabled ? { "mfa" => { "status" => "enabled" } } : nil
607
+ end
608
+
516
609
  # @return [Boolean] true if this user is anonymous (i.e. created
517
610
  # via the +authData.anonymous+ provider rather than via signup
518
611
  # with a username/password or a real OAuth provider).
@@ -664,6 +757,17 @@ module Parse
664
757
  Parse::User.request_password_reset(email)
665
758
  end
666
759
 
760
+ # Request that Parse Server (re)send this user's email-address verification
761
+ # email. The server must have an email adapter and `verifyUserEmails` enabled.
762
+ # @return [Boolean] true if the request was accepted, false otherwise.
763
+ # @raise [Parse::Error::ServiceUnavailableError] if Parse Server returns a
764
+ # 500/503 (e.g. no emailAdapter / `verifyUserEmails` disabled).
765
+ # @see Parse::User.request_email_verification
766
+ def request_email_verification
767
+ return false if email.nil?
768
+ Parse::User.request_email_verification(email)
769
+ end
770
+
667
771
  # You may set a password for this user when you are creating them. Parse never returns a
668
772
  # @param passwd The user's password to be used for signing up.
669
773
  # @raise [Parse::Error::UsernameMissingError] If username is missing.
@@ -835,6 +939,26 @@ module Parse
835
939
  @session
836
940
  end
837
941
 
942
+ # A non-master {Parse::Client} bound to this user's session token, for
943
+ # acting on the server *as this user* with full ACL / CLP / +protectedFields+
944
+ # enforcement and no master-key fallback. It mirrors the connection settings
945
+ # of +base+ (the configured client by default) but carries no master key and
946
+ # binds {#session_token}, so even raw REST calls through it are authorized as
947
+ # the user with no per-call ceremony. The web-counterpart of
948
+ # {Parse::Webhooks::Payload#user_client}; the typical client-side entry point
949
+ # is right after a login:
950
+ #
951
+ # client = Parse::User.login(username, password).session_client
952
+ # Parse::Query.new("Post", client: client).results # scoped to the user
953
+ #
954
+ # @param base [Parse::Client] the client whose connection settings to mirror.
955
+ # @return [Parse::Client, nil] +nil+ when the user has no session token
956
+ # (e.g. fetched/saved under the master key rather than logged in).
957
+ def session_client(base = self.client)
958
+ return nil if @session_token.nil? || @session_token.to_s.strip.empty?
959
+ base.become(@session_token)
960
+ end
961
+
838
962
  # @!visibility private
839
963
  # Keys that must never flow through +Parse::User.create+ from a
840
964
  # mass-assigned hash. +authData+ on the user-signup endpoint causes
@@ -1049,9 +1173,21 @@ module Parse
1049
1173
  # Self-fetch trust: see {.login}.
1050
1174
  with_authdata_trust { Parse::User.build(response.result) }
1051
1175
  else
1052
- raise Parse::Error::AuthenticationError,
1053
- "Parse::User.login! failed for #{username.inspect}: " \
1054
- "#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
1176
+ case response.code
1177
+ when Parse::Response::ERROR_EMAIL_NOT_FOUND
1178
+ # Parse Server throws code 205 (EMAIL_NOT_FOUND) when
1179
+ # +preventLoginWithUnverifiedEmail+ is set and the account's email
1180
+ # address has not yet been verified. Raise the typed error so callers
1181
+ # can direct the user to verify their inbox without catching every
1182
+ # AuthenticationError.
1183
+ raise Parse::Error::EmailNotVerifiedError,
1184
+ "Parse::User.login! failed for #{username.inspect}: " \
1185
+ "email address is not verified (code=205)"
1186
+ else
1187
+ raise Parse::Error::AuthenticationError,
1188
+ "Parse::User.login! failed for #{username.inspect}: " \
1189
+ "#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
1190
+ end
1055
1191
  end
1056
1192
  end
1057
1193
 
@@ -1076,6 +1212,26 @@ module Parse
1076
1212
  response.success?
1077
1213
  end
1078
1214
 
1215
+ # Request that Parse Server (re)send the email-address verification email for
1216
+ # a registered, not-yet-verified user. The server must have an email adapter
1217
+ # and `verifyUserEmails` enabled.
1218
+ # @example
1219
+ # # pass a user object
1220
+ # Parse::User.request_email_verification(user)
1221
+ # # or an email
1222
+ # Parse::User.request_email_verification("user@example.com")
1223
+ # @param email [String] The user's email address (or a {Parse::User}).
1224
+ # @return [Boolean] True/false if the request was accepted.
1225
+ # @raise [Parse::Error::ServiceUnavailableError] if Parse Server returns a
1226
+ # 500/503 (e.g. no emailAdapter / `verifyUserEmails` disabled). Callers that
1227
+ # branch on the Boolean should rescue this.
1228
+ def self.request_email_verification(email)
1229
+ email = email.email if email.is_a?(Parse::User)
1230
+ return false if email.blank?
1231
+ response = client.request_email_verification(email)
1232
+ response.success?
1233
+ end
1234
+
1079
1235
  # Same as `session!` but returns nil if a user was not found or sesion token was invalid.
1080
1236
  # @return [User] the user matching this active token, otherwise nil.
1081
1237
  # @see #session!
@@ -1240,6 +1396,47 @@ module Parse
1240
1396
  active_session_count > 1
1241
1397
  end
1242
1398
 
1399
+ # Verify this user's password without minting a session token.
1400
+ #
1401
+ # Delegates to the +GET /parse/verifyPassword+ endpoint (Parse Server
1402
+ # 7.1.0+) using this user's +username+ and the supplied +password+. The
1403
+ # check is purely credential validation — no session is created on
1404
+ # success, and the user's existing sessions are unaffected.
1405
+ #
1406
+ # Use this as a step-up authentication gate: before allowing a sensitive
1407
+ # action (e.g. changing an email address or deleting an account), call
1408
+ # +verify_password+ to confirm the caller still knows the password.
1409
+ #
1410
+ # @param password [String] the password to verify.
1411
+ # @return [Boolean] +true+ if the credentials are valid.
1412
+ # @raise [Parse::Error::EmailNotVerifiedError] when the account exists but
1413
+ # +preventLoginWithUnverifiedEmail+ is enabled and the email has not been
1414
+ # verified (Parse Server error code 205). The caller may want to prompt
1415
+ # the user to check their inbox rather than treating this as a wrong-
1416
+ # password failure.
1417
+ # @raise [Parse::Error::AuthenticationError] when the username does not
1418
+ # exist or the password is wrong (code 101, +OBJECT_NOT_FOUND+).
1419
+ # @return [Boolean]
1420
+ # @example
1421
+ # # Step-up check before a destructive action
1422
+ # if user.verify_password(params[:current_password])
1423
+ # user.destroy
1424
+ # end
1425
+ def verify_password(password)
1426
+ response = client.verify_password(username.to_s, password.to_s)
1427
+ return true if response.success?
1428
+
1429
+ case response.code
1430
+ when Parse::Response::ERROR_EMAIL_NOT_FOUND
1431
+ raise Parse::Error::EmailNotVerifiedError,
1432
+ "verify_password failed: email address is not verified (code=205)"
1433
+ else
1434
+ raise Parse::Error::AuthenticationError,
1435
+ "verify_password failed: " \
1436
+ "#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
1437
+ end
1438
+ end
1439
+
1243
1440
  # Return the transitive upward closure of role names this user
1244
1441
  # inherits permissions from.
1245
1442
  #
@@ -128,6 +128,39 @@ module Parse
128
128
  base.extend(ClassMethods)
129
129
  end
130
130
 
131
+ # Recompute this record's managed embedding(s) in-place, NOW,
132
+ # without a save. Runs the same digest-tracked recompute the
133
+ # `before_save` callback runs: a provider call happens only when the
134
+ # source text/URL changed since the last embed (digest miss). Useful
135
+ # to populate the vector before inspecting it, or to force a refresh
136
+ # in a console.
137
+ #
138
+ # @param field [Symbol, nil] limit to one embed target; nil
139
+ # recomputes every declared directive.
140
+ # @return [self]
141
+ # @raise [ArgumentError] when `field:` names no embed target, or the
142
+ # class declares no `embed` directives.
143
+ def compute_embedding!(field: nil)
144
+ directives = self.class.embed_directives
145
+ if directives.empty?
146
+ raise ArgumentError, "#{self.class}#compute_embedding!: no `embed` directives declared."
147
+ end
148
+ selected =
149
+ if field
150
+ d = directives[field.to_sym]
151
+ unless d
152
+ raise ArgumentError,
153
+ "#{self.class}#compute_embedding!: :#{field} is not an embed target " \
154
+ "(have #{directives.keys.inspect})."
155
+ end
156
+ [d]
157
+ else
158
+ directives.values
159
+ end
160
+ selected.each { |directive| Parse::Core::EmbedManaged.recompute_embedding!(self, directive) }
161
+ self
162
+ end
163
+
131
164
  module ClassMethods
132
165
  # Per-class registry of {EmbedDirective}s keyed by target vector
133
166
  # property symbol. Read by tests and tooling; written only by
@@ -300,6 +333,86 @@ module Parse
300
333
  into
301
334
  end
302
335
 
336
+ # Backfill embeddings for records whose managed vector field is
337
+ # still null — the bulk counterpart to the per-save embed path.
338
+ # Walks the class with objectId-cursor pagination (robust to the
339
+ # result set shrinking as records are embedded; terminates even
340
+ # when a record has no source text and stays null), saving each
341
+ # pending record so its `before_save` embed callback runs.
342
+ #
343
+ # Intended as an admin / maintenance operation: it reads and
344
+ # writes through the default client, so run it with a master-key
345
+ # client (or pass `save_opts:` carrying a `session_token:` that can
346
+ # write every row).
347
+ #
348
+ # @param field [Symbol, nil] limit the backfill to one embed
349
+ # target; nil processes every declared directive.
350
+ # @param batch_size [Integer] rows fetched per round (default 100).
351
+ # @param limit [Integer, nil] stop after embedding at most this
352
+ # many records across all directives; nil = no cap.
353
+ # @param where [Hash, nil] extra query constraints AND-ed with the
354
+ # null-target filter (e.g. `{ published: true }`).
355
+ # @param save_opts [Hash] options forwarded to each `record.save`
356
+ # (e.g. `session_token:`).
357
+ # @return [Integer] number of records saved (embedded).
358
+ # @raise [ArgumentError] when `field:` names no embed target, or
359
+ # the class declares no `embed` directives.
360
+ def embed_pending!(field: nil, batch_size: 100, limit: nil, where: nil, save_opts: {})
361
+ bs = Integer(batch_size)
362
+ raise ArgumentError, "#{self}.embed_pending!: batch_size must be positive." if bs <= 0
363
+ directives = resolve_embed_directives_for_backfill(field)
364
+
365
+ processed = 0
366
+ directives.each do |directive|
367
+ remaining = limit ? (limit - processed) : nil
368
+ break if remaining && remaining <= 0
369
+ processed += backfill_embed_directive!(directive, bs, where, remaining, save_opts)
370
+ end
371
+ processed
372
+ end
373
+
374
+ # @!visibility private
375
+ def resolve_embed_directives_for_backfill(field)
376
+ if field
377
+ d = embed_directives[field.to_sym]
378
+ unless d
379
+ raise ArgumentError,
380
+ "#{self}.embed_pending!: :#{field} is not an embed target " \
381
+ "(have #{embed_directives.keys.inspect})."
382
+ end
383
+ [d]
384
+ else
385
+ ds = embed_directives.values
386
+ raise ArgumentError, "#{self}.embed_pending!: no `embed` directives declared." if ds.empty?
387
+ ds
388
+ end
389
+ end
390
+
391
+ # @!visibility private
392
+ # objectId-cursor walk over rows where `directive.into` is null.
393
+ def backfill_embed_directive!(directive, batch_size, where, remaining, save_opts)
394
+ count = 0
395
+ cursor = nil
396
+ loop do
397
+ q = query(directive.into.null => true)
398
+ q = q.where(where) if where.is_a?(Hash) && !where.empty?
399
+ q = q.where(:objectId.gt => cursor) if cursor
400
+ q.order(:objectId.asc)
401
+ q.limit(batch_size)
402
+ batch = q.results
403
+ break if batch.nil? || batch.empty?
404
+
405
+ batch.each do |record|
406
+ cursor = record.id
407
+ record.save(**save_opts)
408
+ count += 1
409
+ return count if remaining && count >= remaining
410
+ end
411
+ break if batch.length < batch_size
412
+ end
413
+ count
414
+ end
415
+
303
416
  # @!visibility private
304
417
  # Prepend a module that intercepts the public `<into>=` setter
305
418
  # and raises {ProtectedFieldError} unless the current thread has
@@ -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
@@ -484,7 +554,7 @@ module Parse
484
554
  # @return [Parse::LiveQuery::Subscription] the subscription object
485
555
  # @see Parse::LiveQuery::Subscription
486
556
  # @see Parse::Query#subscribe
487
- def subscribe(where: {}, fields: nil, session_token: nil, client: nil,
557
+ def subscribe(where: {}, fields: nil, keys: nil, watch: nil, session_token: nil, client: nil,
488
558
  use_master_key: false, &block)
489
559
  # Fall through to the ambient set by `Parse.with_session` / `Parse.login`
490
560
  # so a caller wrapping a region with `with_session(user) { Klass.subscribe ... }`
@@ -495,6 +565,8 @@ module Parse
495
565
  end
496
566
  query(where).subscribe(
497
567
  fields: fields,
568
+ keys: keys,
569
+ watch: watch,
498
570
  session_token: session_token,
499
571
  client: client,
500
572
  use_master_key: use_master_key,