parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "active_support/concern"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module Core
|
|
8
|
+
# Declarative write protection for model fields, enforced inside
|
|
9
|
+
# before_save webhook handling. Unlike Parse Server's class-level
|
|
10
|
+
# `protectedFields` (which only hides values on read), these guards
|
|
11
|
+
# revert disallowed client writes before the change reaches the
|
|
12
|
+
# persistent store.
|
|
13
|
+
#
|
|
14
|
+
# Four modes are supported:
|
|
15
|
+
#
|
|
16
|
+
# * `:master_only` - the field is never writable by clients. Any
|
|
17
|
+
# client-supplied value is reverted; master-key requests bypass
|
|
18
|
+
# the guard.
|
|
19
|
+
# * `:immutable` - the field is writable when the object is
|
|
20
|
+
# created but is reverted on any subsequent client update.
|
|
21
|
+
# Master-key requests bypass the guard.
|
|
22
|
+
# * `:always_immutable` - same as `:immutable` for creates, but the
|
|
23
|
+
# field is also reverted on master-key updates. Useful for fields
|
|
24
|
+
# that must NEVER change after creation regardless of who is
|
|
25
|
+
# writing (e.g. a one-way state transition marker, or a slug used
|
|
26
|
+
# in canonical URLs that breaks on rename).
|
|
27
|
+
# * `:set_once` - the field is writable while the persisted
|
|
28
|
+
# value is blank, then locked forever. Master-key writes DO NOT
|
|
29
|
+
# bypass the lock once a value is set. Useful for derived fields
|
|
30
|
+
# that are populated by an after_create callback (e.g.
|
|
31
|
+
# `parse_reference`) where the canonical value depends on the
|
|
32
|
+
# server-assigned objectId and must never change after first
|
|
33
|
+
# assignment.
|
|
34
|
+
#
|
|
35
|
+
# Reverts are a silent successful no-op from the client's perspective:
|
|
36
|
+
# the save proceeds normally, the guarded field simply isn't written.
|
|
37
|
+
# A DEBUG-level log line is emitted for diagnosis, but nothing is raised
|
|
38
|
+
# and nothing is logged at WARN/INFO, so clients that routinely resubmit
|
|
39
|
+
# a full record don't generate log noise.
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# class Project < Parse::Object
|
|
43
|
+
# property :slug, :string
|
|
44
|
+
# property :created_by, :pointer
|
|
45
|
+
#
|
|
46
|
+
# guard :created_by, :master_only
|
|
47
|
+
# guard :slug, :external_id, :immutable
|
|
48
|
+
# end
|
|
49
|
+
module FieldGuards
|
|
50
|
+
extend ActiveSupport::Concern
|
|
51
|
+
|
|
52
|
+
GUARD_MODES = [:master_only, :immutable, :always_immutable, :set_once].freeze
|
|
53
|
+
|
|
54
|
+
included do
|
|
55
|
+
class_attribute :field_guards, instance_writer: false
|
|
56
|
+
self.field_guards = {}.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
module ClassMethods
|
|
60
|
+
# Declare one or more guarded fields. Two call shapes are accepted:
|
|
61
|
+
#
|
|
62
|
+
# guard :slug, :immutable # positional mode (must be the last arg)
|
|
63
|
+
# guard :owner, :tags, :master_only # multiple fields, positional mode
|
|
64
|
+
# guard :slug, mode: :immutable # keyword mode (less ambiguous)
|
|
65
|
+
#
|
|
66
|
+
# @param fields [Array<Symbol>] one or more field names
|
|
67
|
+
# @param mode [Symbol, nil] positional mode; required unless `mode:` keyword is given
|
|
68
|
+
# @param mode_kw [Symbol, nil] keyword mode (alternative to the trailing positional arg)
|
|
69
|
+
def guard(*fields, mode: nil)
|
|
70
|
+
# Support `guard :field, :master_only` by treating a trailing
|
|
71
|
+
# symbol that matches a known mode as the positional mode arg.
|
|
72
|
+
# Anything else (unknown symbol, no trailing symbol, etc.) falls
|
|
73
|
+
# through to the validation below with a clear error message.
|
|
74
|
+
if mode.nil? && fields.last.is_a?(Symbol) && GUARD_MODES.include?(fields.last)
|
|
75
|
+
mode = fields.pop
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "guard requires at least one field name" if fields.empty?
|
|
79
|
+
unless GUARD_MODES.include?(mode)
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"guard mode missing or invalid: #{mode.inspect}. " \
|
|
82
|
+
"Allowed: #{GUARD_MODES.inspect}. Call as " \
|
|
83
|
+
"`guard :field, :master_only` or `guard :field, mode: :master_only`."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
new_guards = field_guards.dup
|
|
87
|
+
fields.each { |f| new_guards[f.to_sym] = mode }
|
|
88
|
+
self.field_guards = new_guards.freeze
|
|
89
|
+
|
|
90
|
+
# Ensure Parse Server is configured to call our webhook for this
|
|
91
|
+
# class. Without a before_save route, the webhook is never invoked
|
|
92
|
+
# and the guard is silently a no-op (a credible misconfiguration
|
|
93
|
+
# footgun). Register a stub only if no handler exists yet; if the
|
|
94
|
+
# user later declares `webhook :before_save`, it replaces this stub.
|
|
95
|
+
ensure_field_guards_webhook!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @!visibility private
|
|
99
|
+
def ensure_field_guards_webhook!
|
|
100
|
+
return unless respond_to?(:parse_class)
|
|
101
|
+
class_name = parse_class
|
|
102
|
+
return if class_name.blank?
|
|
103
|
+
# Load-order safety: in `lib/parse/stack.rb` the model classes are
|
|
104
|
+
# required before `Parse::Webhooks`, so a `guard` declaration in a
|
|
105
|
+
# class body (e.g. `Parse::User`) fires before the Webhooks
|
|
106
|
+
# constant exists. Skip the route registration in that case —
|
|
107
|
+
# application code that uses `guard` from its own model files (a
|
|
108
|
+
# later load step) will hit this path with Webhooks already
|
|
109
|
+
# loaded, and Parse::Webhooks.route_field_guards! re-registers the
|
|
110
|
+
# built-in routes after Webhooks loads.
|
|
111
|
+
return unless defined?(Parse::Webhooks)
|
|
112
|
+
existing = Parse::Webhooks.routes[:before_save][class_name]
|
|
113
|
+
return if existing.present?
|
|
114
|
+
Parse::Webhooks.route(:before_save, self) { parse_object }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Revert any disallowed client writes per the class-level guards.
|
|
119
|
+
# Called by {Parse::Webhooks.call_route} for before_save triggers,
|
|
120
|
+
# before {Parse::Object#prepare_save!} runs.
|
|
121
|
+
#
|
|
122
|
+
# @param master [Boolean] true if the webhook request used the master key
|
|
123
|
+
# @param is_new [Boolean] true if this is a create (no original record)
|
|
124
|
+
# @return [Array<Symbol>] field names that were reverted
|
|
125
|
+
def apply_field_guards!(master:, is_new:)
|
|
126
|
+
guards = self.class.field_guards
|
|
127
|
+
return [] if guards.blank?
|
|
128
|
+
|
|
129
|
+
reverted = guards.each_with_object([]) do |(field, mode), acc|
|
|
130
|
+
next unless changed.include?(field.to_s)
|
|
131
|
+
case mode
|
|
132
|
+
when :master_only
|
|
133
|
+
# Master bypasses; client writes always reverted
|
|
134
|
+
next if master
|
|
135
|
+
revert_field!(field, is_new: is_new)
|
|
136
|
+
acc << field
|
|
137
|
+
when :immutable
|
|
138
|
+
# Master bypasses; clients can set on create, never on update
|
|
139
|
+
next if master
|
|
140
|
+
next if is_new
|
|
141
|
+
revert_field!(field, is_new: false)
|
|
142
|
+
acc << field
|
|
143
|
+
when :always_immutable
|
|
144
|
+
# No master bypass on updates: the field is frozen for everyone
|
|
145
|
+
# (including server/admin code using the master key) once the
|
|
146
|
+
# object exists. Creates are still allowed for everyone.
|
|
147
|
+
next if is_new
|
|
148
|
+
revert_field!(field, is_new: false)
|
|
149
|
+
acc << field
|
|
150
|
+
when :set_once
|
|
151
|
+
# Allow writes while the persisted (original) value is blank;
|
|
152
|
+
# lock the field once it holds a value. No master bypass --
|
|
153
|
+
# once set, NOTHING can change it. Implementation note: this
|
|
154
|
+
# checks the dirty-tracked "was" value rather than the current
|
|
155
|
+
# value, so an update payload that includes a new value is
|
|
156
|
+
# only rejected if the field was previously populated.
|
|
157
|
+
previous = changed_attributes[field.to_s]
|
|
158
|
+
next if previous.nil? || previous.to_s.strip.empty?
|
|
159
|
+
revert_field!(field, is_new: false)
|
|
160
|
+
acc << field
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if reverted.any?
|
|
165
|
+
klass = self.class.respond_to?(:parse_class) ? self.class.parse_class : self.class.name
|
|
166
|
+
oid = (respond_to?(:id) && id) || "<new>"
|
|
167
|
+
Parse.logger&.debug(
|
|
168
|
+
"[Parse::FieldGuards] Reverted client writes on #{klass}:#{oid} -> #{reverted.join(", ")}"
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
reverted
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# On update: restore the previous persisted value and clear dirty.
|
|
178
|
+
# On create: zero the field (still dirty) so the response payload
|
|
179
|
+
# tells Parse Server to drop the client-supplied value instead of
|
|
180
|
+
# silently letting it through.
|
|
181
|
+
#
|
|
182
|
+
# Handles three field shapes:
|
|
183
|
+
# * Scalar properties (including belongs_to pointers): ActiveModel
|
|
184
|
+
# `restore_attributes` sets back to the prior value and clears dirty.
|
|
185
|
+
# * has_many :relation fields: the proxy itself tracks pending
|
|
186
|
+
# add/remove operations; we roll those back on the proxy and clear
|
|
187
|
+
# the parent's dirty flag so {Parse::Object#relation_change_operations}
|
|
188
|
+
# emits nothing for this field.
|
|
189
|
+
# * has_many array fields (PointerCollectionProxy backed by an Array
|
|
190
|
+
# property): treated as a scalar property; `restore_attributes`
|
|
191
|
+
# reassigns the prior proxy. Mutations to a proxy that doesn't
|
|
192
|
+
# trigger a setter (e.g. some in-place edits) may not fully revert;
|
|
193
|
+
# prefer assigning a new array if you need strict revert semantics.
|
|
194
|
+
def revert_field!(field, is_new:)
|
|
195
|
+
field_sym = field.to_sym
|
|
196
|
+
field_str = field.to_s
|
|
197
|
+
|
|
198
|
+
if respond_to?(:relations) && relations[field_sym]
|
|
199
|
+
proxy = public_send(field_sym)
|
|
200
|
+
# Reset the pending add/remove ledger that backs
|
|
201
|
+
# relation_change_operations. The proxy itself has no public reset
|
|
202
|
+
# API for these (its rollback!/restore_attributes path expects
|
|
203
|
+
# setters that don't exist for additions/removals), so we clear
|
|
204
|
+
# them directly and then drop the proxy's dirty markers.
|
|
205
|
+
proxy.instance_variable_set(:@additions, []) if proxy.instance_variable_defined?(:@additions)
|
|
206
|
+
proxy.instance_variable_set(:@removals, []) if proxy.instance_variable_defined?(:@removals)
|
|
207
|
+
proxy.clear_changes! if proxy.respond_to?(:clear_changes!)
|
|
208
|
+
clear_attribute_changes([field_str])
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
if is_new
|
|
213
|
+
public_send("#{field_str}=", nil)
|
|
214
|
+
else
|
|
215
|
+
restore_attributes([field_str])
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Core
|
|
6
|
+
# Model-declarative MongoDB index DSL. Mixed into Parse::Object so
|
|
7
|
+
# subclasses can declare the indexes they expect to exist on their
|
|
8
|
+
# collection. Declarations are inert at load time — they only land
|
|
9
|
+
# on MongoDB when {Parse::Schema::IndexMigrator} reads them and
|
|
10
|
+
# `apply_indexes!` is invoked through the writer connection.
|
|
11
|
+
#
|
|
12
|
+
# SECURITY POSTURE — purely declarative. No network I/O, no class
|
|
13
|
+
# introspection that could leak data, no LLM-visible surface. The
|
|
14
|
+
# validation rules below run at declaration time so a typo
|
|
15
|
+
# surfaces as a load-time error, not a runtime surprise during
|
|
16
|
+
# `rake parse:mongo:indexes:apply` in prod.
|
|
17
|
+
#
|
|
18
|
+
# @example Declaring indexes
|
|
19
|
+
# class Car < Parse::Object
|
|
20
|
+
# property :make, :string
|
|
21
|
+
# property :model, :string
|
|
22
|
+
# property :year, :integer
|
|
23
|
+
# property :tags, :array
|
|
24
|
+
# property :location, :geopoint
|
|
25
|
+
# belongs_to :owner, as: :user
|
|
26
|
+
#
|
|
27
|
+
# mongo_index :make, :model, :year # compound
|
|
28
|
+
# mongo_index :vin, unique: true
|
|
29
|
+
# mongo_index :owner # → _p_owner (pointer auto-rewrite)
|
|
30
|
+
# mongo_geo_index :location # 2dsphere
|
|
31
|
+
# mongo_index :tags # array column
|
|
32
|
+
# # mongo_index :tags, :categories # REJECTED: parallel arrays
|
|
33
|
+
# end
|
|
34
|
+
module Indexing
|
|
35
|
+
# MongoDB limits each collection to 64 indexes total (including
|
|
36
|
+
# the implicit `_id_` index). The migrator's plan phase reports
|
|
37
|
+
# remaining capacity using this constant.
|
|
38
|
+
MAX_INDEXES_PER_COLLECTION = 64
|
|
39
|
+
|
|
40
|
+
# Parse-managed array columns we can know about without
|
|
41
|
+
# introspecting actual data. Used by {#assert_at_most_one_array_field!}
|
|
42
|
+
# to catch parallel-array compounds at declaration time even when
|
|
43
|
+
# the parallel field is the `_rperm`/`_wperm` ACL array.
|
|
44
|
+
PARSE_MANAGED_ARRAY_FIELDS = %w[_rperm _wperm].to_set.freeze
|
|
45
|
+
|
|
46
|
+
# Wire-format column names that hold Parse-internal secret material
|
|
47
|
+
# (password hashes, session tokens, verification tokens, auth provider
|
|
48
|
+
# blobs). The DSL refuses to declare an index on any of these because
|
|
49
|
+
# a queryable index on bcrypt hashes or session tokens turns
|
|
50
|
+
# `$indexStats` / collection-scan access into a credential-enumeration
|
|
51
|
+
# oracle. Parse Server already manages the legitimate indexes for
|
|
52
|
+
# these columns (see {Parse::Schema::IndexMigrator::PARSE_MANAGED_INDEX_PATTERNS});
|
|
53
|
+
# this guard exists so a typo or malicious PR can't add a new one.
|
|
54
|
+
SENSITIVE_FIELDS = %w[
|
|
55
|
+
_hashed_password _session_token _email_verify_token
|
|
56
|
+
_perishable_token _password_history authData _auth_data
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
# Storage for declared indexes. Each entry is a frozen Hash with
|
|
60
|
+
# the keys `:keys`, `:options`, `:declared_for` (the source-of-truth
|
|
61
|
+
# symbol list from the `mongo_index` call, for diagnostics).
|
|
62
|
+
# @return [Array<Hash>]
|
|
63
|
+
def mongo_index_declarations
|
|
64
|
+
@mongo_index_declarations ||= []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Declare a regular (B-tree) index on one or more fields. Symbols
|
|
68
|
+
# in `fields` are looked up against the class's `references` table
|
|
69
|
+
# — pointers auto-rewrite to `_p_<field>` so callers think in
|
|
70
|
+
# property names. Use `mongo_geo_index` for 2dsphere indexes.
|
|
71
|
+
#
|
|
72
|
+
# @param fields [Array<Symbol>] property names; compound indexes
|
|
73
|
+
# are formed by passing more than one. Order matters for query
|
|
74
|
+
# prefix matching (MongoDB compound-index rule).
|
|
75
|
+
# @param unique [Boolean]
|
|
76
|
+
# @param sparse [Boolean]
|
|
77
|
+
# @param partial [Hash, nil] partial-index filter expression. Owner
|
|
78
|
+
# is responsible for lifecycle — Parse Server will not manage it.
|
|
79
|
+
# @param expire_after [Integer, nil] TTL in seconds (only valid on
|
|
80
|
+
# single-field indexes per MongoDB's TTL rules).
|
|
81
|
+
# @param name [String, nil] explicit index name; defaults to
|
|
82
|
+
# `field_dir_field_dir` via MongoDB's auto-naming.
|
|
83
|
+
# @return [Hash] the registered declaration (frozen)
|
|
84
|
+
# @raise [ArgumentError] when validation rules fail (no fields,
|
|
85
|
+
# unknown field, parallel arrays, relation field, etc.)
|
|
86
|
+
def mongo_index(*fields, unique: false, sparse: false, partial: nil,
|
|
87
|
+
expire_after: nil, name: nil)
|
|
88
|
+
register_index(fields, key_value: 1, unique: unique, sparse: sparse,
|
|
89
|
+
partial: partial, expire_after: expire_after, name: name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Sugar for a 2dsphere geospatial index. Geopoint columns are
|
|
93
|
+
# stored in Mongo as GeoJSON `{ type: "Point", coordinates: [lng, lat] }`
|
|
94
|
+
# which `2dsphere` indexes natively.
|
|
95
|
+
def mongo_geo_index(field, sparse: false, name: nil)
|
|
96
|
+
register_index([field], key_value: "2dsphere", unique: false,
|
|
97
|
+
sparse: sparse, partial: nil, expire_after: nil, name: name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Declare an index on a Parse Relation's join collection. Relations
|
|
101
|
+
# are stored in `_Join:<field>:<ParentClass>` collections — these
|
|
102
|
+
# have no Ruby model, so an `add_index :field` against the parent
|
|
103
|
+
# class would index the wrong collection. This method routes the
|
|
104
|
+
# declaration to the correct join-collection name, with the
|
|
105
|
+
# conventional column shape: `owningId` is the parent-side foreign
|
|
106
|
+
# key, `relatedId` is the related-side.
|
|
107
|
+
#
|
|
108
|
+
# Default: single declaration on `{owningId: 1}` — the forward
|
|
109
|
+
# lookup ("what's related to this owner"), which is the dominant
|
|
110
|
+
# pattern for most Parse Relation queries.
|
|
111
|
+
#
|
|
112
|
+
# `bidirectional: true` adds a second declaration on
|
|
113
|
+
# `{relatedId: 1}` — the reverse lookup ("which owners contain
|
|
114
|
+
# this related object"). For high-traffic auth patterns like
|
|
115
|
+
# `Parse::Role.users`, the reverse direction is often the
|
|
116
|
+
# heavier-used index.
|
|
117
|
+
#
|
|
118
|
+
# Uniqueness is NOT supported on `mongo_relation_index` — a unique
|
|
119
|
+
# single-direction index on a `has_many :through => :relation`
|
|
120
|
+
# field is semantically broken (it would say each owner can hold
|
|
121
|
+
# at most one related, contradicting `has_many`). If you want to
|
|
122
|
+
# enforce no-duplicate-pair membership, declare a compound unique
|
|
123
|
+
# index directly via `Parse::MongoDB.create_index` or a later
|
|
124
|
+
# extension to this DSL.
|
|
125
|
+
#
|
|
126
|
+
# @example Canonical case — role membership
|
|
127
|
+
# class Parse::Role < Parse::Object
|
|
128
|
+
# has_many :users, through: :relation
|
|
129
|
+
# mongo_relation_index :users, bidirectional: true
|
|
130
|
+
# # creates: _Join:users:_Role { owningId: 1 }
|
|
131
|
+
# # _Join:users:_Role { relatedId: 1 }
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
# @param field [Symbol] the relation field name (must be declared
|
|
135
|
+
# via `has_many :field, through: :relation`)
|
|
136
|
+
# @param bidirectional [Boolean] when true, register two
|
|
137
|
+
# declarations — one each for owningId and relatedId
|
|
138
|
+
# @raise [ArgumentError] when `field` is not a declared relation
|
|
139
|
+
# or `unique:` is passed (not supported on relation indexes)
|
|
140
|
+
# @return [Array<Hash>] the registered declarations
|
|
141
|
+
def mongo_relation_index(field, bidirectional: false, unique: false)
|
|
142
|
+
if unique
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"#{self}.mongo_relation_index does not support unique: — uniqueness on " \
|
|
145
|
+
"a single-direction relation column breaks has_many semantics. For no-" \
|
|
146
|
+
"duplicate-pair membership, declare a compound unique index directly " \
|
|
147
|
+
"via Parse::MongoDB.create_index."
|
|
148
|
+
end
|
|
149
|
+
field = field.to_sym
|
|
150
|
+
unless respond_to?(:relations) && relations.key?(field)
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"#{self}.mongo_relation_index requires #{field.inspect} to be declared " \
|
|
153
|
+
"via `has_many :#{field}, through: :relation`. Got non-relation field."
|
|
154
|
+
end
|
|
155
|
+
join_collection = "_Join:#{field}:#{parse_class}"
|
|
156
|
+
decls = [register_relation_index(join_collection, "owningId", source: field)]
|
|
157
|
+
decls << register_relation_index(join_collection, "relatedId", source: field) if bidirectional
|
|
158
|
+
decls
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Dry-run reconciliation between declared indexes and what's on
|
|
162
|
+
# the collection. Delegates to {Parse::Schema::IndexMigrator}.
|
|
163
|
+
# @return [Hash{String=>Hash}] keyed by collection name; one entry
|
|
164
|
+
# per unique target collection across the declaration list
|
|
165
|
+
# (parent collection + any `_Join:*` collections from
|
|
166
|
+
# `mongo_relation_index`).
|
|
167
|
+
def indexes_plan
|
|
168
|
+
Parse::Schema::IndexMigrator.new(self).plan
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Apply additive index changes via the writer connection. Pass
|
|
172
|
+
# `drop: true` to also drop orphan indexes; each drop carries its
|
|
173
|
+
# own audit log and confirmation envelope.
|
|
174
|
+
# @return [Hash] see {Parse::Schema::IndexMigrator#apply!}
|
|
175
|
+
def apply_indexes!(drop: false)
|
|
176
|
+
Parse::Schema::IndexMigrator.new(self).apply!(drop: drop)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def register_index(fields, key_value:, unique:, sparse:, partial:,
|
|
182
|
+
expire_after:, name:)
|
|
183
|
+
fields = fields.flatten.map(&:to_sym)
|
|
184
|
+
if fields.empty?
|
|
185
|
+
raise ArgumentError, "#{self}.mongo_index requires at least one field name"
|
|
186
|
+
end
|
|
187
|
+
if expire_after && fields.size > 1
|
|
188
|
+
raise ArgumentError,
|
|
189
|
+
"#{self}.mongo_index expire_after is only valid on single-field indexes; " \
|
|
190
|
+
"got #{fields.inspect}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Sensitive-field check runs BEFORE wire-key resolution so that
|
|
194
|
+
# non-`_`-prefixed Parse-internal columns (e.g. `authData`) are
|
|
195
|
+
# caught even when they aren't declared as properties on the
|
|
196
|
+
# subclass — otherwise `resolve_index_field_name` would reject
|
|
197
|
+
# them as "unknown field" and the operator might add the property
|
|
198
|
+
# to silence the error, defeating the guard.
|
|
199
|
+
assert_no_sensitive_raw_fields!(fields)
|
|
200
|
+
|
|
201
|
+
wire_keys = fields.each_with_object({}) do |sym, h|
|
|
202
|
+
h[resolve_index_field_name(sym)] = key_value
|
|
203
|
+
end
|
|
204
|
+
assert_not_id_field!(wire_keys)
|
|
205
|
+
assert_not_sensitive_field!(wire_keys)
|
|
206
|
+
assert_at_most_one_array_field!(fields, wire_keys)
|
|
207
|
+
|
|
208
|
+
declaration = {
|
|
209
|
+
keys: wire_keys,
|
|
210
|
+
options: {
|
|
211
|
+
unique: unique, sparse: sparse,
|
|
212
|
+
partial_filter: partial, expire_after: expire_after, name: name,
|
|
213
|
+
}.reject { |_, v| v.nil? || v == false }.freeze,
|
|
214
|
+
declared_for: fields.dup.freeze,
|
|
215
|
+
collection: nil, # nil sentinel means "use the model's parse_class"
|
|
216
|
+
}.freeze
|
|
217
|
+
|
|
218
|
+
if mongo_index_declarations.any? { |d| d[:keys] == declaration[:keys] && d[:options] == declaration[:options] && d[:collection] == declaration[:collection] }
|
|
219
|
+
# Idempotent redeclaration — same class re-opened or sub-class
|
|
220
|
+
# inherited; don't accumulate duplicates.
|
|
221
|
+
return declaration
|
|
222
|
+
end
|
|
223
|
+
mongo_index_declarations << declaration
|
|
224
|
+
declaration
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Register one direction of a relation index. The declaration
|
|
228
|
+
# carries an explicit `:collection` override so the migrator routes
|
|
229
|
+
# the apply call to the `_Join:*` collection name instead of the
|
|
230
|
+
# model's `parse_class`.
|
|
231
|
+
def register_relation_index(collection, column, source:)
|
|
232
|
+
decl = {
|
|
233
|
+
keys: { column => 1 }.freeze,
|
|
234
|
+
options: {}.freeze,
|
|
235
|
+
declared_for: [source].freeze,
|
|
236
|
+
collection: collection,
|
|
237
|
+
}.freeze
|
|
238
|
+
if mongo_index_declarations.any? { |d| d[:keys] == decl[:keys] && d[:collection] == collection }
|
|
239
|
+
return decl
|
|
240
|
+
end
|
|
241
|
+
mongo_index_declarations << decl
|
|
242
|
+
decl
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Translate a property symbol to the wire-format column name a
|
|
246
|
+
# MongoDB index must reference. Pointer fields (declared via
|
|
247
|
+
# `belongs_to`) live in Mongo at `_p_<field>` and the SDK already
|
|
248
|
+
# tracks them in the class's `references` map. Relations
|
|
249
|
+
# (declared via `has_many :foo, through: :relation`) live in a
|
|
250
|
+
# separate `_Join:<field>:<ClassName>` collection and CAN NOT be
|
|
251
|
+
# indexed on the parent — reject those at declaration.
|
|
252
|
+
def resolve_index_field_name(sym)
|
|
253
|
+
sym = sym.to_sym
|
|
254
|
+
if respond_to?(:relations) && relations.key?(sym)
|
|
255
|
+
raise ArgumentError,
|
|
256
|
+
"#{self}.mongo_index cannot index #{sym.inspect}: it is a Parse Relation, " \
|
|
257
|
+
"stored in a separate _Join:#{sym}:#{self} collection. Index on the join " \
|
|
258
|
+
"collection directly via Parse::MongoDB.create_index if needed."
|
|
259
|
+
end
|
|
260
|
+
if respond_to?(:references) && references.key?(sym)
|
|
261
|
+
# Pointer field — Parse stores as _p_<field>
|
|
262
|
+
return "_p_#{references_field_for(sym)}"
|
|
263
|
+
end
|
|
264
|
+
# Regular property or already-wire-format string (`_rperm` etc.)
|
|
265
|
+
wire = if respond_to?(:field_map) && field_map[sym]
|
|
266
|
+
field_map[sym].to_s
|
|
267
|
+
else
|
|
268
|
+
sym.to_s
|
|
269
|
+
end
|
|
270
|
+
# Sanity check: the field should be declared, OR start with an
|
|
271
|
+
# underscore (internal Parse column the operator is targeting
|
|
272
|
+
# intentionally), OR be a valid property name.
|
|
273
|
+
unless internal_or_declared?(sym, wire)
|
|
274
|
+
raise ArgumentError,
|
|
275
|
+
"#{self}.mongo_index references unknown field #{sym.inspect}. " \
|
|
276
|
+
"Declare the property first (`property #{sym.inspect}, :string`) " \
|
|
277
|
+
"or pass an internal column name like :_rperm explicitly."
|
|
278
|
+
end
|
|
279
|
+
wire
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The `references` map stores `parse_field => target_class_name`.
|
|
283
|
+
# For pointer auto-rewrite we want the wire-format pointer column
|
|
284
|
+
# (`_p_<parseField>`), so we look up the parse field name matching
|
|
285
|
+
# the symbol passed to `mongo_index`.
|
|
286
|
+
def references_field_for(sym)
|
|
287
|
+
# `references` is keyed by the wire-format field. For
|
|
288
|
+
# `belongs_to :owner, as: :user` the entry is `owner => "_User"`,
|
|
289
|
+
# so the symbol matches the key directly.
|
|
290
|
+
if respond_to?(:field_map) && field_map[sym]
|
|
291
|
+
field_map[sym].to_s
|
|
292
|
+
else
|
|
293
|
+
sym.to_s
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def internal_or_declared?(sym, wire)
|
|
298
|
+
return true if PARSE_MANAGED_ARRAY_FIELDS.include?(wire)
|
|
299
|
+
return true if wire.start_with?("_")
|
|
300
|
+
return true if respond_to?(:fields) && fields.key?(sym)
|
|
301
|
+
return true if respond_to?(:attributes) && attributes.key?(sym)
|
|
302
|
+
false
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# MongoDB's primary key index (`_id_`, on the `_id` column) is
|
|
306
|
+
# auto-created and auto-maintained for every collection. Declaring
|
|
307
|
+
# an additional index on `_id` is at best redundant (same key as
|
|
308
|
+
# the primary) and at worst conflicts with the unique constraint.
|
|
309
|
+
# The migrator already protects `_id_` from drop via
|
|
310
|
+
# `PARSE_MANAGED_INDEX_PATTERNS`; this guard prevents the
|
|
311
|
+
# corresponding mistake on the create side at class load.
|
|
312
|
+
def assert_not_id_field!(wire_keys)
|
|
313
|
+
if wire_keys.keys.include?("_id")
|
|
314
|
+
raise ArgumentError,
|
|
315
|
+
"#{self}: cannot declare an index on `_id` — MongoDB's primary key " \
|
|
316
|
+
"index (`_id_`) is auto-managed and protected from modification."
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Refuse to declare an index against any Parse-internal secret column
|
|
321
|
+
# (see {SENSITIVE_FIELDS}). The migrator's drop-protection list
|
|
322
|
+
# ({Parse::Schema::IndexMigrator::PARSE_MANAGED_INDEX_PATTERNS}) only
|
|
323
|
+
# blocks *removal* of existing Parse-managed indexes; it does not
|
|
324
|
+
# prevent CREATION of a new index targeting bcrypt hashes / session
|
|
325
|
+
# tokens / verification tokens. Refuse those at declaration time so a
|
|
326
|
+
# typo (`mongo_index :_hashed_password`) or malicious change does not
|
|
327
|
+
# silently install a credential-enumeration oracle.
|
|
328
|
+
def assert_not_sensitive_field!(wire_keys)
|
|
329
|
+
sensitive = wire_keys.keys & SENSITIVE_FIELDS
|
|
330
|
+
return if sensitive.empty?
|
|
331
|
+
raise_sensitive_field_error!(sensitive)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Pre-resolve guard for sensitive raw field symbols. Catches cases
|
|
335
|
+
# like `mongo_index :authData` where the field is a Parse-internal
|
|
336
|
+
# column but not `_`-prefixed (so `internal_or_declared?` would
|
|
337
|
+
# otherwise reject it as "unknown" and the operator might add a
|
|
338
|
+
# benign-looking `property :authData, :hash` to silence that error,
|
|
339
|
+
# which would then pass through to `resolve_index_field_name`
|
|
340
|
+
# without ever hitting the wire-key denylist).
|
|
341
|
+
def assert_no_sensitive_raw_fields!(fields)
|
|
342
|
+
names = fields.map(&:to_s)
|
|
343
|
+
sensitive = names & SENSITIVE_FIELDS
|
|
344
|
+
return if sensitive.empty?
|
|
345
|
+
raise_sensitive_field_error!(sensitive)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def raise_sensitive_field_error!(sensitive)
|
|
349
|
+
raise ArgumentError,
|
|
350
|
+
"#{self}.mongo_index cannot target sensitive Parse-internal columns: " \
|
|
351
|
+
"#{sensitive.inspect}. These hold password hashes, session tokens, or " \
|
|
352
|
+
"verification tokens; a queryable index would turn $indexStats / " \
|
|
353
|
+
"collection-scan access into a credential-enumeration oracle. Parse " \
|
|
354
|
+
"Server manages the legitimate indexes on these columns itself."
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# MongoDB allows at most one array-typed field per compound index
|
|
358
|
+
# ("cannot index parallel arrays" — server error). Catch it at
|
|
359
|
+
# declaration time so a `mongo_index :tags, :categories`-style
|
|
360
|
+
# mistake fails when the class is loaded, not when the migrator
|
|
361
|
+
# tries to apply it.
|
|
362
|
+
def assert_at_most_one_array_field!(field_syms, wire_keys)
|
|
363
|
+
return if field_syms.size <= 1
|
|
364
|
+
pairs = field_syms.zip(wire_keys.keys)
|
|
365
|
+
arrays = pairs.select { |sym, wire| array_typed?(sym, wire) }
|
|
366
|
+
if arrays.size > 1
|
|
367
|
+
names = arrays.map { |sym, _| sym }
|
|
368
|
+
raise ArgumentError,
|
|
369
|
+
"#{self}.mongo_index cannot combine multiple array-typed fields " \
|
|
370
|
+
"(#{names.inspect}) in a compound index — MongoDB rejects " \
|
|
371
|
+
"parallel arrays. Index each array separately."
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def array_typed?(sym, wire)
|
|
376
|
+
return true if PARSE_MANAGED_ARRAY_FIELDS.include?(wire)
|
|
377
|
+
return true if respond_to?(:fields) && fields[sym] == :array
|
|
378
|
+
false
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|