parse-stack-next 5.2.1 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.bundle/config +1 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +616 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +296 -3
- data/Rakefile +243 -41
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +38 -0
- data/docs/mcp_guide.md +119 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +174 -9
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +200 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +73 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +173 -1
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +399 -11
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +16 -1
|
@@ -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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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,
|