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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,2300 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "set"
6
+ require "time"
7
+ require_relative "pipeline_security"
8
+ require_relative "clp_scope"
9
+ require_relative "acl_scope"
10
+
11
+ module Parse
12
+ # Direct MongoDB access module for bypassing Parse Server.
13
+ # Provides read-only direct access to MongoDB for performance-critical queries.
14
+ #
15
+ # @example Enable direct MongoDB queries
16
+ # Parse::MongoDB.configure(
17
+ # uri: "mongodb://localhost:27017/parse",
18
+ # enabled: true
19
+ # )
20
+ #
21
+ # @example Using direct queries
22
+ # # Returns Parse objects, queried directly from MongoDB
23
+ # songs = Song.query(:plays.gt => 1000).results_direct
24
+ # first_song = Song.query(:plays.gt => 1000).first_direct
25
+ #
26
+ # == Field Name Conventions
27
+ #
28
+ # When writing aggregation pipelines for direct MongoDB queries, use MongoDB's native
29
+ # field naming conventions:
30
+ #
31
+ # - *Regular fields*: Use camelCase (e.g., +releaseDate+, +playCount+, +firstName+)
32
+ # - *Pointer fields*: Use +_p_+ prefix (e.g., +_p_author+, +_p_album+)
33
+ # - *Built-in dates*: Use +_created_at+ and +_updated_at+
34
+ # - *Field references*: Use +$fieldName+ syntax (e.g., +$releaseDate+, +$_p_author+)
35
+ #
36
+ # Results are automatically converted to Ruby-friendly format:
37
+ # - Field names converted to snake_case (+totalPlays+ → +total_plays+)
38
+ # - Custom aggregation results wrapped in +AggregationResult+ for method access
39
+ # - Parse documents returned as proper +Parse::Object+ instances
40
+ #
41
+ # @example Aggregation pipeline with MongoDB field names
42
+ # pipeline = [
43
+ # { "$match" => { "releaseDate" => { "$lt" => Time.now } } },
44
+ # { "$group" => { "_id" => "$_p_artist", "totalPlays" => { "$sum" => "$playCount" } } }
45
+ # ]
46
+ # results = Song.query.aggregate(pipeline, mongo_direct: true).results
47
+ #
48
+ # # Results use snake_case and support method access
49
+ # results.first.total_plays # => 5000
50
+ # results.first["totalPlays"] # => 5000 (original key also works)
51
+ #
52
+ # == Date Comparisons
53
+ #
54
+ # MongoDB stores dates in UTC. When comparing dates in aggregation pipelines:
55
+ # - Use Ruby +Time+ objects for comparisons (automatically converted to BSON dates)
56
+ # - Ruby +Date+ objects (without time) are stored as midnight UTC
57
+ # - For accurate date-only comparisons, use +Time.utc(year, month, day)+
58
+ #
59
+ # @example Date comparison in aggregation
60
+ # # Compare with a specific UTC time
61
+ # cutoff = Time.utc(2024, 1, 1, 0, 0, 0)
62
+ # pipeline = [{ "$match" => { "releaseDate" => { "$gte" => cutoff } } }]
63
+ #
64
+ # @example Using the date conversion helper
65
+ # # Safely convert any date/time to MongoDB-compatible UTC Time
66
+ # cutoff = Parse::MongoDB.to_mongodb_date(Date.new(2024, 1, 1)) # => Time UTC
67
+ # cutoff = Parse::MongoDB.to_mongodb_date("2024-01-01") # => Time UTC
68
+ # cutoff = Parse::MongoDB.to_mongodb_date(Time.now) # => Time UTC
69
+ #
70
+ # @note Requires the 'mongo' gem to be installed. Add to your Gemfile:
71
+ # gem 'mongo', '~> 2.18'
72
+ module MongoDB
73
+ # Error raised when mongo gem is not available
74
+ class GemNotAvailable < StandardError; end
75
+
76
+ # Error raised when direct MongoDB is not enabled
77
+ class NotEnabled < StandardError; end
78
+
79
+ # Error raised when MongoDB connection fails
80
+ class ConnectionError < StandardError; end
81
+
82
+ # Error raised when a denied operator is detected in a raw filter or
83
+ # pipeline forwarded through {Parse::MongoDB.find} or
84
+ # {Parse::MongoDB.aggregate}. Currently blocks $where, $function, and
85
+ # $accumulator, which all execute server-side JavaScript.
86
+ class DeniedOperator < StandardError; end
87
+
88
+ # Error raised when an index mutation primitive is invoked but the
89
+ # writer connection has not been configured via {.configure_writer}.
90
+ class WriterNotConfigured < StandardError; end
91
+
92
+ # Error raised when an index mutation primitive is invoked but one of
93
+ # the triple-gate conditions is not satisfied (writer URI configured
94
+ # AND `Parse::MongoDB.index_mutations_enabled = true` AND
95
+ # `ENV["PARSE_MONGO_INDEX_MUTATIONS"] == "1"`). The message names the
96
+ # missing gate so operators get an actionable error.
97
+ class MutationsDisabled < StandardError; end
98
+
99
+ # Error raised when an index mutation targets a Parse-internal
100
+ # collection (`_User`, `_Role`, `_Session`, etc.) without explicit
101
+ # `allow_system_classes: true` opt-in, or when the collection name
102
+ # fails the Parse-class regex.
103
+ class ForbiddenCollection < StandardError; end
104
+
105
+ # Error raised when {.configure_writer} validates the connected role
106
+ # and finds privileges that exceed `createIndex`/`dropIndex` + reads.
107
+ # The writer connection is meant strictly for index management; any
108
+ # role granting `insert`, `update`, `remove`, `dropCollection`, etc.
109
+ # is rejected fail-closed.
110
+ class WriterRoleTooPermissive < StandardError; end
111
+
112
+ # Error raised when MongoDB cancels a query because it exceeded the
113
+ # requested maxTimeMS budget (MongoDB error code 50 / MaxTimeMSExpired).
114
+ # This is the DB-side counterpart to {Parse::Agent::ToolTimeoutError} and
115
+ # is raised by {Parse::MongoDB.aggregate} / {Parse::MongoDB.find} when the
116
+ # driver reports code 50.
117
+ #
118
+ # @example Handling a DB-level timeout
119
+ # begin
120
+ # Parse::MongoDB.aggregate("Song", pipeline, max_time_ms: 5000)
121
+ # rescue Parse::MongoDB::ExecutionTimeout => e
122
+ # puts "#{e.collection_name} timed out after #{e.max_time_ms}ms"
123
+ # end
124
+ class ExecutionTimeout < StandardError
125
+ # @return [Integer] the maxTimeMS budget that was exceeded
126
+ attr_reader :max_time_ms
127
+ # @return [String] the collection that was being queried
128
+ attr_reader :collection_name
129
+
130
+ # @param collection_name [String] the MongoDB collection
131
+ # @param max_time_ms [Integer] the budget in milliseconds that was exceeded
132
+ def initialize(collection_name:, max_time_ms:)
133
+ @max_time_ms = max_time_ms
134
+ @collection_name = collection_name
135
+ super("Query on '#{collection_name}' exceeded max_time_ms=#{max_time_ms}ms — narrow filter or add index")
136
+ end
137
+ end
138
+
139
+ # Threshold above which `Parse::MongoDB.find` emits a deprecation warning
140
+ # when called without an explicit `:limit` option. A future major release
141
+ # will enforce this as a hard default limit. Callers should pass an
142
+ # explicit `:limit` (including `:limit => 0` for unbounded) to silence the
143
+ # warning.
144
+ DEFAULT_FIND_LIMIT = 1000
145
+
146
+ # Environment variable names consulted (in priority order) when
147
+ # {.configure} is called without an explicit `uri:` argument.
148
+ # `ANALYTICS_DATABASE_URI` is listed first so deployments can point
149
+ # direct-read traffic at a dedicated analytics replica without
150
+ # disturbing the primary `DATABASE_URI` that Parse Server uses for
151
+ # writes. `DATABASE_URI` is the fallback for deployments where the
152
+ # direct path reads from the same node as Parse Server.
153
+ ENV_URI_KEYS = %w[ANALYTICS_DATABASE_URI DATABASE_URI].freeze
154
+
155
+ # Environment variable consulted as part of the triple gate for
156
+ # index mutations. The check is performed on every call (not just at
157
+ # configure time) so a SIGHUP / process-supervisor that flips the
158
+ # variable can revoke without restart.
159
+ MUTATION_ENV_KEY = "PARSE_MONGO_INDEX_MUTATIONS"
160
+
161
+ # Parse-internal collections that must not receive index mutations
162
+ # without explicit `allow_system_classes: true`. A unique index on
163
+ # `_Session.session_token`, for example, would break auth on the
164
+ # first duplicate token write.
165
+ PARSE_INTERNAL_CLASSES = %w[
166
+ _User _Role _Session _Installation _Audience _Idempotency
167
+ _PushStatus _JobStatus _Hooks _GlobalConfig _SCHEMA
168
+ ].freeze
169
+
170
+ # Mongo privilege actions the writer role MAY hold. Anything outside
171
+ # this set causes {.configure_writer} to refuse with
172
+ # {WriterRoleTooPermissive}. Reads are allowed; mutations are
173
+ # scoped to index management only.
174
+ #
175
+ # The Atlas Search actions (`createSearchIndexes`, `dropSearchIndex`,
176
+ # `updateSearchIndex`, `listSearchIndexes`) are included so a writer
177
+ # role provisioned for search-index management passes the privilege
178
+ # probe. Operators who do not grant those actions in their Mongo role
179
+ # simply cannot invoke the search-index primitives — the SDK allowlist
180
+ # does not auto-grant; it only refuses to reject roles that legitimately
181
+ # hold these specific actions.
182
+ WRITER_ALLOWED_ACTIONS = %w[
183
+ createIndex dropIndex
184
+ createSearchIndexes dropSearchIndex updateSearchIndex listSearchIndexes
185
+ listIndexes listCollections collStats
186
+ find listDatabases connPoolStats serverStatus
187
+ ].freeze
188
+
189
+ class << self
190
+ # @!attribute [rw] enabled
191
+ # Feature flag to enable/disable direct MongoDB queries.
192
+ # @return [Boolean]
193
+ attr_accessor :enabled
194
+
195
+ # @!attribute [rw] uri
196
+ # MongoDB connection URI.
197
+ # @return [String]
198
+ attr_accessor :uri
199
+
200
+ # @!attribute [rw] database
201
+ # MongoDB database name (extracted from URI or set manually).
202
+ # @return [String]
203
+ attr_accessor :database
204
+
205
+ # @!attribute [r] client
206
+ # The MongoDB client instance (memoized).
207
+ # @return [Mongo::Client]
208
+ attr_reader :client
209
+
210
+ # Check if the mongo gem is available
211
+ # @return [Boolean] true if mongo gem is loaded
212
+ def gem_available?
213
+ return @gem_available if defined?(@gem_available)
214
+ @gem_available = begin
215
+ require "mongo"
216
+ true
217
+ rescue LoadError
218
+ false
219
+ end
220
+ end
221
+
222
+ # Ensure mongo gem is loaded, raise error if not
223
+ # @raise [GemNotAvailable] if mongo gem is not installed
224
+ def require_gem!
225
+ return if gem_available?
226
+ raise GemNotAvailable,
227
+ "The 'mongo' gem is required for direct MongoDB queries. " \
228
+ "Add 'gem \"mongo\"' to your Gemfile and run 'bundle install'."
229
+ end
230
+
231
+ # Configure direct MongoDB access.
232
+ #
233
+ # When `uri:` is omitted, the value is resolved from the first
234
+ # environment variable in {ENV_URI_KEYS} that is set (so
235
+ # `ANALYTICS_DATABASE_URI` wins over `DATABASE_URI`). Raises
236
+ # `ArgumentError` if neither argument nor any env var supplied a URI.
237
+ #
238
+ # @param uri [String, nil] MongoDB connection URI. When nil, falls
239
+ # back to env-var resolution.
240
+ # @param enabled [Boolean] whether to enable direct queries (default: true)
241
+ # @param database [String, nil] database name (optional, extracted
242
+ # from URI if not provided)
243
+ # @param verify_role [Boolean] when true (the default), run a
244
+ # `connectionStatus` role check after configuring and emit a
245
+ # warning if the authenticated user appears to have write
246
+ # privileges. The direct path is read-only; a writeable role
247
+ # means a bug in the gem (or in caller code touching
248
+ # `Parse::MongoDB.client` directly) could write through it.
249
+ # Set to false to skip the check (no connection attempt during
250
+ # configure).
251
+ # @raise [ArgumentError] if no URI can be resolved
252
+ # @example Explicit URI
253
+ # Parse::MongoDB.configure(
254
+ # uri: "mongodb://user:pass@localhost:27017/parse?authSource=admin",
255
+ # enabled: true
256
+ # )
257
+ # @example Env-var resolution (ANALYTICS_DATABASE_URI preferred,
258
+ # falls back to DATABASE_URI)
259
+ # Parse::MongoDB.configure(enabled: true)
260
+ def configure(uri: nil, enabled: true, database: nil, verify_role: true)
261
+ require_gem!
262
+ resolved = uri || resolve_uri_from_env
263
+ if resolved.nil? || resolved.to_s.empty?
264
+ raise ArgumentError,
265
+ "Parse::MongoDB.configure requires a `uri:` argument or one of " \
266
+ "#{ENV_URI_KEYS.join(", ")} set in the environment."
267
+ end
268
+ @uri = resolved
269
+ @enabled = enabled
270
+ @database = database || extract_database_from_uri(resolved)
271
+ @client = nil # Reset client on reconfigure
272
+ warn_if_writeable_role! if verify_role && enabled
273
+ end
274
+
275
+ # @return [String, nil] the first env-var URI found, in
276
+ # {ENV_URI_KEYS} priority order, or nil if none is set.
277
+ def resolve_uri_from_env
278
+ ENV_URI_KEYS.each do |key|
279
+ value = ENV[key]
280
+ return value if value && !value.empty?
281
+ end
282
+ nil
283
+ end
284
+
285
+ # Check if direct MongoDB queries are available and enabled
286
+ # @return [Boolean]
287
+ def available?
288
+ gem_available? && enabled? && uri.present?
289
+ end
290
+
291
+ # Check if direct queries are enabled
292
+ # @return [Boolean]
293
+ def enabled?
294
+ @enabled == true
295
+ end
296
+
297
+ # MongoDB privilege "actions" that indicate write capability. Used by
298
+ # {.read_only?} to classify the authenticated user's role.
299
+ WRITE_ACTIONS = %w[
300
+ insert update remove
301
+ createCollection dropCollection
302
+ createIndex dropIndex
303
+ applyOps dropDatabase
304
+ renameCollectionSameDB enableSharding
305
+ ].freeze
306
+
307
+ # Probe whether the authenticated user on the configured URI has any
308
+ # write privileges. Issues the `connectionStatus` command with
309
+ # `showPrivileges: true` — a read-only call that returns the user's
310
+ # role-derived privilege list.
311
+ #
312
+ # Return values:
313
+ # - `true` — user's privileges include no entries from {WRITE_ACTIONS}
314
+ # on the configured database. The role is observable read-only.
315
+ # - `false` — at least one write action was found.
316
+ # - `nil` — couldn't determine (no privilege list returned, command
317
+ # not supported, network failure). Treat as "unknown" — don't
318
+ # trust either answer.
319
+ #
320
+ # Caveats:
321
+ # - This is a ROLE check, not a transport check. A `readPreference=
322
+ # secondary` URI with a write-capable user is still write-capable;
323
+ # the driver routes writes to primary regardless of read preference.
324
+ # - Some MongoDB configurations restrict the user's visibility into
325
+ # their own privileges; an empty privilege list returns `nil`,
326
+ # not `true`.
327
+ # - Atlas Data Federation, BI Connector, and other non-standard
328
+ # endpoints may respond differently or refuse the command — also
329
+ # `nil`.
330
+ #
331
+ # @return [Boolean, nil]
332
+ def read_only?
333
+ return nil unless available?
334
+ result = client.database.command(connectionStatus: 1, showPrivileges: true).first
335
+ privileges = result && result.dig("authInfo", "authenticatedUserPrivileges")
336
+ return nil if privileges.nil? || privileges.empty?
337
+ write_set = WRITE_ACTIONS.to_set
338
+ has_write = privileges.any? do |priv|
339
+ Array(priv["actions"]).any? { |a| write_set.include?(a.to_s) }
340
+ end
341
+ !has_write
342
+ rescue StandardError
343
+ nil
344
+ end
345
+
346
+ # Emit a warning when {.read_only?} reports a writeable role. Called
347
+ # from {.configure} when `verify_role: true`. Silent on `true`
348
+ # (correctly read-only) and on `nil` (couldn't determine — too noisy
349
+ # to surface in normal operation).
350
+ # @api private
351
+ def warn_if_writeable_role!
352
+ case read_only?
353
+ when false
354
+ warn "[Parse::MongoDB] WARNING: the URI configured for direct " \
355
+ "queries authenticates a user with write privileges. The " \
356
+ "direct path is read-only by design; using a read-only " \
357
+ "role bounds the blast radius if caller code touches " \
358
+ "`Parse::MongoDB.client` directly. See " \
359
+ "docs/mongodb_direct_guide.md for routing direct reads at " \
360
+ "an analytics replica."
361
+ end
362
+ end
363
+
364
+ # Get or create the MongoDB client
365
+ # @return [Mongo::Client]
366
+ # @raise [GemNotAvailable] if mongo gem is not installed
367
+ # @raise [NotEnabled] if direct MongoDB is not enabled
368
+ # @raise [ConnectionError] if connection fails
369
+ def client
370
+ require_gem!
371
+ raise NotEnabled, "Direct MongoDB queries are not enabled. Call Parse::MongoDB.configure first." unless available?
372
+
373
+ @client ||= begin
374
+ ::Mongo::Client.new(uri)
375
+ rescue => e
376
+ raise ConnectionError, "Failed to connect to MongoDB: #{e.message}"
377
+ end
378
+ end
379
+
380
+ # Reset the client connection (useful for testing)
381
+ def reset!
382
+ @client&.close rescue nil
383
+ @client = nil
384
+ @enabled = false
385
+ @uri = nil
386
+ @database = nil
387
+ reset_writer!
388
+ end
389
+
390
+ # Get a MongoDB collection
391
+ # @param name [String] the collection name
392
+ # @return [Mongo::Collection]
393
+ def collection(name)
394
+ client[name]
395
+ end
396
+
397
+ # Normalize a Parse-style read-preference value into the Mongo Ruby
398
+ # driver's `:mode` symbol. Accepts `nil` (returns `nil`), the five
399
+ # documented Parse strings (`PRIMARY`, `PRIMARY_PREFERRED`,
400
+ # `SECONDARY`, `SECONDARY_PREFERRED`, `NEAREST`) in any case with
401
+ # hyphens or underscores, and the equivalent symbol form. Unknown
402
+ # values produce a warning and return `nil` so the operation falls
403
+ # back to the client default rather than failing.
404
+ # @param value [String, Symbol, nil]
405
+ # @return [Symbol, nil]
406
+ def normalize_read_preference(value)
407
+ return nil if value.nil?
408
+ token = value.to_s.tr("-", "_").downcase
409
+ valid = %w[primary primary_preferred secondary secondary_preferred nearest].freeze
410
+ unless valid.include?(token)
411
+ warn "[Parse::MongoDB] Invalid read_preference #{value.inspect}; ignoring."
412
+ return nil
413
+ end
414
+ token.to_sym
415
+ end
416
+
417
+ # ---- Writer connection (index mutations) -----------------------------
418
+ #
419
+ # The writer is a SECOND `Mongo::Client` configured against a
420
+ # write-capable Mongo role. It is intentionally distinct from the
421
+ # reader (`@client` above) so the existing analytics path keeps its
422
+ # read-only posture. The writer is reachable ONLY through the named
423
+ # primitives below — `create_index`, `drop_index`, `writer_indexes`.
424
+ # The underlying `Mongo::Client` is never returned to caller code,
425
+ # to bound blast radius if any in-process actor reaches one of the
426
+ # mutation methods. All mutations go through {.assert_mutations_allowed!}.
427
+
428
+ # @!attribute [rw] index_mutations_enabled
429
+ # Ruby-side gate (one of the three required for mutations). Default
430
+ # `false`. Must be flipped to `true` explicitly in code (typically
431
+ # in a rake task initializer, never in a web-process initializer).
432
+ # @return [Boolean]
433
+ attr_accessor :index_mutations_enabled
434
+
435
+ # Configure the writer connection used for index mutations.
436
+ # Opens a second `Mongo::Client` against `uri:`. The connection is
437
+ # validated via `connectionStatus` and rejected fail-closed if its
438
+ # role grants destructive privileges (insert/update/remove/
439
+ # dropCollection/dropDatabase/etc.). The client is stored privately
440
+ # and is not exposed through any public accessor.
441
+ #
442
+ # @param uri [String] writer URI, must be distinct from the reader
443
+ # `@uri`. Typically points at the same replica set with a different
444
+ # Mongo user holding only `createIndex`/`dropIndex` privileges.
445
+ # @param enabled [Boolean] when false, `configure_writer` records
446
+ # the URI but does NOT open the connection. Use this to lay
447
+ # wiring in code without activating the writer until a separate
448
+ # call sets `Parse::MongoDB.index_mutations_enabled = true`.
449
+ # @param verify_role [Boolean] when true (default), run the
450
+ # privilege check on the configured user and raise
451
+ # {WriterRoleTooPermissive} if it exceeds {WRITER_ALLOWED_ACTIONS}.
452
+ # Disable only in test fixtures.
453
+ # @raise [ArgumentError] when `uri:` is missing or matches the
454
+ # reader URI verbatim.
455
+ # @raise [WriterRoleTooPermissive] when the role check fails.
456
+ def configure_writer(uri:, enabled: true, verify_role: true)
457
+ require_gem!
458
+ raise ArgumentError, "configure_writer requires a uri:" if uri.nil? || uri.to_s.empty?
459
+ if @uri && @uri.to_s == uri.to_s
460
+ raise ArgumentError,
461
+ "configure_writer URI must differ from the reader URI. " \
462
+ "The writer is meant for a separately-credentialed Mongo role."
463
+ end
464
+ @writer_uri = uri
465
+ @writer_enabled = enabled
466
+ @writer_client&.close rescue nil
467
+ @writer_client = nil
468
+ if enabled
469
+ # Eagerly open so a misconfigured URI fails fast at configure time.
470
+ assert_writer_role_acceptable! if verify_role
471
+ end
472
+ end
473
+
474
+ # @return [Boolean] true when {.configure_writer} has been called
475
+ # with `enabled: true` and the connection is reachable.
476
+ def writer_configured?
477
+ !@writer_uri.nil? && @writer_enabled == true
478
+ end
479
+
480
+ # @return [Boolean] true iff `ENV[MUTATION_ENV_KEY] == "1"`.
481
+ def mutations_env_enabled?
482
+ ENV[MUTATION_ENV_KEY].to_s == "1"
483
+ end
484
+
485
+ # Run all three gates. Returns nil on success; raises with a
486
+ # message naming the missing gate otherwise.
487
+ # @raise [WriterNotConfigured, MutationsDisabled]
488
+ def assert_mutations_allowed!
489
+ unless writer_configured?
490
+ raise WriterNotConfigured,
491
+ "Index mutations require Parse::MongoDB.configure_writer(uri: ...) " \
492
+ "to be called with a write-capable Mongo role URI distinct from the reader."
493
+ end
494
+ unless @index_mutations_enabled == true
495
+ raise MutationsDisabled,
496
+ "Index mutations are disabled. Set Parse::MongoDB.index_mutations_enabled = true " \
497
+ "explicitly (typically in a rake-task initializer, not in a web-process initializer)."
498
+ end
499
+ unless mutations_env_enabled?
500
+ raise MutationsDisabled,
501
+ "Index mutations require ENV[#{MUTATION_ENV_KEY.inspect}] == '1'. " \
502
+ "Set this only in environments where index mutations are intended " \
503
+ "(rake tasks, maintenance scripts), never on web/worker dynos."
504
+ end
505
+ nil
506
+ end
507
+
508
+ # Reset the writer connection and clear gate state. Called from
509
+ # {.reset!}; can be invoked directly for granular teardown.
510
+ def reset_writer!
511
+ @writer_client&.close rescue nil
512
+ @writer_client = nil
513
+ @writer_uri = nil
514
+ @writer_enabled = false
515
+ end
516
+
517
+ # Create an index on the named collection. Triple-gated; refuses
518
+ # Parse-internal collections unless `allow_system_classes: true`.
519
+ # Idempotent: if an index with identical key+options already exists,
520
+ # returns `:exists` without issuing the create.
521
+ #
522
+ # @param collection_name [String] target collection / Parse class
523
+ # @param keys [Hash{String,Symbol => Integer,String}] index key spec.
524
+ # Values are `1` (asc), `-1` (desc), `"2dsphere"`, `"text"`, `"hashed"`.
525
+ # @param name [String, nil] optional index name. When nil, Mongo
526
+ # generates `field_dir_field_dir` automatically.
527
+ # @param unique [Boolean] uniqueness constraint.
528
+ # @param sparse [Boolean] sparse index (skip docs missing the key).
529
+ # @param partial_filter [Hash, nil] partial index filter expression.
530
+ # @param expire_after [Integer, nil] TTL in seconds.
531
+ # @param allow_system_classes [Boolean] opt-in to mutate Parse-internal
532
+ # collections (`_User`, `_Role`, etc.). Default false. Audit-logged.
533
+ # @return [Symbol] `:created` on success, `:exists` when an
534
+ # identically-specified index was already present.
535
+ # @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection]
536
+ def create_index(collection_name, keys, name: nil, unique: false, sparse: false,
537
+ partial_filter: nil, expire_after: nil, allow_system_classes: false)
538
+ assert_mutations_allowed!
539
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
540
+ spec_keys = normalize_index_keys(keys)
541
+ existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
542
+ if index_matches?(existing, spec_keys, name: name, unique: unique, sparse: sparse,
543
+ partial_filter: partial_filter, expire_after: expire_after)
544
+ audit_writer_event(:create_index_skipped, collection_name, keys: spec_keys, name: name)
545
+ return :exists
546
+ end
547
+ opts = build_index_options(name: name, unique: unique, sparse: sparse,
548
+ partial_filter: partial_filter, expire_after: expire_after)
549
+ audit_writer_event(:create_index, collection_name, keys: spec_keys, name: name, opts: opts)
550
+ writer_collection(collection_name).indexes.create_one(spec_keys, **opts)
551
+ :created
552
+ end
553
+
554
+ # Drop a named index. Requires the operator-supplied `confirm:`
555
+ # string to match `"drop:#{collection}:#{name}"` so a stale shell
556
+ # session against the wrong environment can't accidentally drop
557
+ # something via a rerun.
558
+ #
559
+ # @param collection_name [String] target collection
560
+ # @param name [String] index name to drop
561
+ # @param confirm [String] must equal `"drop:#{collection_name}:#{name}"`
562
+ # @param allow_system_classes [Boolean] opt-in for Parse-internal
563
+ # @return [Symbol] `:dropped` on success, `:absent` when the index
564
+ # did not exist (idempotent).
565
+ def drop_index(collection_name, name, confirm:, allow_system_classes: false)
566
+ assert_mutations_allowed!
567
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
568
+ expected = "drop:#{collection_name}:#{name}"
569
+ unless confirm.to_s == expected
570
+ raise ArgumentError,
571
+ "drop_index confirmation mismatch. Pass confirm: #{expected.inspect} " \
572
+ "to drop #{name.inspect} from #{collection_name.inspect}."
573
+ end
574
+ existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
575
+ unless existing.any? { |i| (i["name"] || i[:name]) == name }
576
+ audit_writer_event(:drop_index_absent, collection_name, name: name)
577
+ return :absent
578
+ end
579
+ audit_writer_event(:drop_index, collection_name, name: name)
580
+ writer_collection(collection_name).indexes.drop_one(name)
581
+ :dropped
582
+ end
583
+
584
+ # List indexes on a collection via the WRITER connection. Distinct
585
+ # from {.indexes} which uses the reader. Used by {.create_index}
586
+ # for the idempotency check so the existence read is performed on
587
+ # the same connection that will issue the create.
588
+ # @param collection_name [String]
589
+ # @param allow_system_classes [Boolean]
590
+ # @return [Array<Hash>]
591
+ def writer_indexes(collection_name, allow_system_classes: false)
592
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
593
+ # NOTE: listing does not require the mutation gate — operators
594
+ # can inspect what's there even when mutations are disabled,
595
+ # which is useful for `parse:mongo:indexes:plan` dry-runs that
596
+ # don't intend to mutate.
597
+ unless writer_configured?
598
+ raise WriterNotConfigured,
599
+ "writer_indexes requires configure_writer to have been called."
600
+ end
601
+ begin
602
+ writer_collection(collection_name).indexes.to_a
603
+ rescue StandardError => e
604
+ # Mongo raises NamespaceNotFound (code 26) when the collection
605
+ # has not been created yet — listing indexes on a non-existent
606
+ # collection is "no indexes" from the SDK's perspective. Match
607
+ # by code AND by message substring because the driver's exact
608
+ # class path varies across versions.
609
+ return [] if mongo_namespace_not_found?(e)
610
+ raise
611
+ end
612
+ end
613
+
614
+ # List Atlas Search indexes via the WRITER connection. Distinct
615
+ # from {.list_search_indexes} which uses the reader's aggregate
616
+ # path. Used by the search-index mutation primitives below for the
617
+ # existence check so the read is performed on the same connection
618
+ # that will issue the mutation. Returns `[]` for collections that
619
+ # do not yet exist.
620
+ #
621
+ # @param collection_name [String]
622
+ # @param allow_system_classes [Boolean]
623
+ # @return [Array<Hash>] raw search-index documents
624
+ # @raise [WriterNotConfigured, ForbiddenCollection]
625
+ def writer_search_indexes(collection_name, allow_system_classes: false)
626
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
627
+ unless writer_configured?
628
+ raise WriterNotConfigured,
629
+ "writer_search_indexes requires configure_writer to have been called."
630
+ end
631
+ begin
632
+ writer_collection(collection_name)
633
+ .aggregate([{ "$listSearchIndexes" => {} }]).to_a
634
+ rescue StandardError => e
635
+ return [] if mongo_namespace_not_found?(e)
636
+ raise
637
+ end
638
+ end
639
+
640
+ # Create an Atlas Search index. Triple-gated like {.create_index};
641
+ # refuses Parse-internal collections unless `allow_system_classes:
642
+ # true`. Idempotent on name: if a search index with the same name
643
+ # already exists, returns `:exists` without issuing the create.
644
+ # The mapping definition of the existing index is NOT diffed — use
645
+ # {.update_search_index} to change a definition.
646
+ #
647
+ # The build runs ASYNCHRONOUSLY on the Atlas Search node. This
648
+ # method returns as soon as the command is accepted; the index is
649
+ # not queryable until its status transitions to `READY`. Poll
650
+ # {Parse::AtlasSearch::IndexManager.index_ready?} to confirm.
651
+ #
652
+ # @param collection_name [String] target collection / Parse class
653
+ # @param name [String] the search index name. Must match
654
+ # `/\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/`.
655
+ # @param definition [Hash] the search index definition (e.g.
656
+ # `{ mappings: { dynamic: true } }`). String/symbol keys both
657
+ # accepted; converted to string keys before submission.
658
+ # @param allow_system_classes [Boolean] opt-in to mutate Parse-
659
+ # internal collections. Default false. Audit-logged.
660
+ # @return [Symbol] `:created` on submission, `:exists` when a
661
+ # search index with that name already exists.
662
+ # @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection, ArgumentError]
663
+ def create_search_index(collection_name, name, definition, allow_system_classes: false)
664
+ assert_mutations_allowed!
665
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
666
+ validate_search_index_name!(name)
667
+ validate_search_index_definition!(definition)
668
+ existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
669
+ if existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
670
+ audit_writer_event(:create_search_index_skipped, collection_name, name: name)
671
+ return :exists
672
+ end
673
+ audit_writer_event(:create_search_index, collection_name, name: name)
674
+ writer_client.database.command(
675
+ createSearchIndexes: collection_name.to_s,
676
+ indexes: [{ name: name.to_s, definition: stringify_keys_deep(definition) }],
677
+ )
678
+ :created
679
+ end
680
+
681
+ # Drop a named Atlas Search index. Requires the operator-supplied
682
+ # `confirm:` string to match `"drop_search:#{collection}:#{name}"`.
683
+ # The token deliberately differs from {.drop_index}'s `"drop:"`
684
+ # prefix so a token meant for a regular index cannot be replayed
685
+ # against a search index with the same name (and vice versa).
686
+ #
687
+ # The drop is asynchronous on the Atlas Search node but typically
688
+ # completes quickly; the local cache in
689
+ # {Parse::AtlasSearch::IndexManager} should be invalidated by the
690
+ # caller (the IndexManager wrapper does this).
691
+ #
692
+ # @param collection_name [String] target collection
693
+ # @param name [String] search index name to drop
694
+ # @param confirm [String] must equal
695
+ # `"drop_search:#{collection_name}:#{name}"`
696
+ # @param allow_system_classes [Boolean] opt-in for Parse-internal
697
+ # @return [Symbol] `:dropped` on success, `:absent` when no such
698
+ # search index existed (idempotent).
699
+ # @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection, ArgumentError]
700
+ def drop_search_index(collection_name, name, confirm:, allow_system_classes: false)
701
+ assert_mutations_allowed!
702
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
703
+ expected = "drop_search:#{collection_name}:#{name}"
704
+ unless confirm.to_s == expected
705
+ raise ArgumentError,
706
+ "drop_search_index confirmation mismatch. Pass confirm: #{expected.inspect} " \
707
+ "to drop search index #{name.inspect} from #{collection_name.inspect}."
708
+ end
709
+ existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
710
+ unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
711
+ audit_writer_event(:drop_search_index_absent, collection_name, name: name)
712
+ return :absent
713
+ end
714
+ audit_writer_event(:drop_search_index, collection_name, name: name)
715
+ writer_client.database.command(
716
+ dropSearchIndex: collection_name.to_s,
717
+ name: name.to_s,
718
+ )
719
+ :dropped
720
+ end
721
+
722
+ # Replace the definition of an existing Atlas Search index. The
723
+ # rebuild runs asynchronously on the Atlas Search node; the new
724
+ # mapping is not live until the index's status transitions back to
725
+ # `READY`. Poll {Parse::AtlasSearch::IndexManager.index_ready?}
726
+ # to confirm.
727
+ #
728
+ # Raises `ArgumentError` if no search index with that name exists
729
+ # — use {.create_search_index} for new indexes. The mapping diff
730
+ # is not computed; the command is issued unconditionally for
731
+ # existing indexes (Atlas itself handles "definition unchanged"
732
+ # cases gracefully).
733
+ #
734
+ # @param collection_name [String]
735
+ # @param name [String] existing search index name
736
+ # @param definition [Hash] replacement definition
737
+ # @param allow_system_classes [Boolean]
738
+ # @return [Symbol] `:updated` on submission
739
+ # @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection, ArgumentError]
740
+ def update_search_index(collection_name, name, definition, allow_system_classes: false)
741
+ assert_mutations_allowed!
742
+ assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
743
+ validate_search_index_name!(name)
744
+ validate_search_index_definition!(definition)
745
+ existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
746
+ unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
747
+ audit_writer_event(:update_search_index_absent, collection_name, name: name)
748
+ raise ArgumentError,
749
+ "update_search_index: no Atlas Search index named #{name.inspect} " \
750
+ "on collection #{collection_name.inspect}. Use create_search_index to create one."
751
+ end
752
+ audit_writer_event(:update_search_index, collection_name, name: name)
753
+ writer_client.database.command(
754
+ updateSearchIndex: collection_name.to_s,
755
+ name: name.to_s,
756
+ definition: stringify_keys_deep(definition),
757
+ )
758
+ :updated
759
+ end
760
+
761
+ private
762
+
763
+ # The active writer collection handle. Private — never exposed in
764
+ # a public accessor. The only sites that hold a `Mongo::Collection`
765
+ # from the writer are the mutation methods above.
766
+ def writer_collection(name)
767
+ writer_client[name]
768
+ end
769
+
770
+ def writer_client
771
+ require_gem!
772
+ unless writer_configured?
773
+ raise WriterNotConfigured,
774
+ "Writer is not configured. Call Parse::MongoDB.configure_writer(uri:) first."
775
+ end
776
+ @writer_client ||= begin
777
+ # min_pool_size: 0 — keep idle pool drained when not in use.
778
+ # The writer should be a rare-use connection.
779
+ ::Mongo::Client.new(@writer_uri, min_pool_size: 0, max_pool_size: 2,
780
+ server_selection_timeout: 10,
781
+ socket_timeout: 10,
782
+ connect_timeout: 5,
783
+ monitoring: false)
784
+ rescue => e
785
+ raise ConnectionError, "Failed to connect writer client: #{e.message}"
786
+ end
787
+ end
788
+
789
+ def assert_writer_role_acceptable!
790
+ result = writer_client.database.command(connectionStatus: 1, showPrivileges: true).first
791
+ privileges = result && result.dig("authInfo", "authenticatedUserPrivileges")
792
+ if privileges.nil?
793
+ # Can't verify — fail closed for the writer (the reader can
794
+ # tolerate :unknown, the writer cannot).
795
+ raise WriterRoleTooPermissive,
796
+ "Could not verify writer role privileges (connectionStatus returned no privilege list). " \
797
+ "Writer must be explicitly bound to a role granting only #{WRITER_ALLOWED_ACTIONS.inspect}."
798
+ end
799
+ allowed = WRITER_ALLOWED_ACTIONS.to_set
800
+ actions_seen = privileges.flat_map { |p| Array(p["actions"]) }.map(&:to_s).uniq
801
+ extras = actions_seen.reject { |a| allowed.include?(a) }
802
+ unless extras.empty?
803
+ raise WriterRoleTooPermissive,
804
+ "Writer role grants disallowed actions: #{extras.inspect}. " \
805
+ "Writer must be bound to a role granting only #{WRITER_ALLOWED_ACTIONS.inspect}. " \
806
+ "Create a dedicated Mongo user with the parse_index_admin role pattern."
807
+ end
808
+ nil
809
+ end
810
+
811
+ def assert_collection_allowed!(collection_name, allow_system_classes:)
812
+ name = collection_name.to_s
813
+ # Parse-internal classes start with `_` (e.g. `_User`, `_Role`).
814
+ # Parse Relation join collections are `_Join:<field>:<ParentClass>`
815
+ # where the parent class may itself start with `_` (e.g. the
816
+ # canonical `Parse::Role.users` relation → `_Join:users:_Role`).
817
+ # Allow both shapes here; the dedicated denylist below produces
818
+ # the clearer error for top-level Parse-internal names.
819
+ unless name.match?(/\A(_?[A-Za-z][A-Za-z0-9_]*|_Join:[A-Za-z][A-Za-z0-9_]*:_?[A-Za-z][A-Za-z0-9_]*)\z/)
820
+ raise ForbiddenCollection,
821
+ "Collection name #{name.inspect} must be either a Parse class " \
822
+ "(matches /\\A_?[A-Za-z][A-Za-z0-9_]*\\z/) or a Parse Relation " \
823
+ "join collection (matches /\\A_Join:<field>:<ParentClass>\\z/)."
824
+ end
825
+ if PARSE_INTERNAL_CLASSES.include?(name) && !allow_system_classes
826
+ raise ForbiddenCollection,
827
+ "Index mutations against Parse-internal collection #{name.inspect} are forbidden. " \
828
+ "Pass allow_system_classes: true to opt in (audit-logged at WARN)."
829
+ end
830
+ nil
831
+ end
832
+
833
+ def normalize_index_keys(keys)
834
+ unless keys.is_a?(Hash) && !keys.empty?
835
+ raise ArgumentError, "Index keys must be a non-empty Hash like { field: 1 }; got #{keys.inspect}"
836
+ end
837
+ keys.each_with_object({}) do |(field, dir), h|
838
+ h[field.to_s] = dir
839
+ end
840
+ end
841
+
842
+ def build_index_options(name:, unique:, sparse:, partial_filter:, expire_after:)
843
+ opts = {}
844
+ opts[:name] = name if name
845
+ opts[:unique] = true if unique
846
+ opts[:sparse] = true if sparse
847
+ opts[:partial_filter_expression] = partial_filter if partial_filter
848
+ opts[:expire_after] = expire_after if expire_after
849
+ opts
850
+ end
851
+
852
+ # Whether the existing index list contains an entry matching the
853
+ # requested spec. Compared by key signature first (canonical
854
+ # ordering), then by the small set of options that meaningfully
855
+ # change index semantics (`unique`, `sparse`, `partialFilterExpression`,
856
+ # `expireAfterSeconds`). When `name:` is supplied, the existing
857
+ # index's name must also match.
858
+ def index_matches?(existing, keys, name:, unique:, sparse:, partial_filter:, expire_after:)
859
+ existing.any? do |idx|
860
+ ex_keys = stringify_keys(idx["key"] || idx[:key])
861
+ next false unless ex_keys == stringify_keys(keys)
862
+ next false if name && (idx["name"] || idx[:name]) != name
863
+ next false if !!unique != (idx["unique"] == true)
864
+ next false if !!sparse != (idx["sparse"] == true)
865
+ next false if (partial_filter || nil) != (idx["partialFilterExpression"] || nil)
866
+ next false if expire_after && idx["expireAfterSeconds"] != expire_after
867
+ true
868
+ end
869
+ end
870
+
871
+ def stringify_keys(hash)
872
+ return {} if hash.nil?
873
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
874
+ end
875
+
876
+ # MongoDB raises NamespaceNotFound (code 26) when `listIndexes`
877
+ # runs against a collection that does not exist yet. Match by
878
+ # error code AND by message substring — the driver class path
879
+ # for `Mongo::Error::OperationFailure` is stable but the response-
880
+ # parsing path that surfaces the code has varied across versions.
881
+ def mongo_namespace_not_found?(err)
882
+ return true if err.respond_to?(:code) && err.code == 26
883
+ msg = err.message.to_s
884
+ msg.include?("NamespaceNotFound") || msg.include?("ns does not exist")
885
+ end
886
+
887
+ # Emit a structured audit line for writer events. Matches the
888
+ # `[Parse::*:SECURITY]` warn-line style used elsewhere in the gem.
889
+ def audit_writer_event(event, collection_name, **fields)
890
+ payload = fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
891
+ warn "[Parse::MongoDB:WRITER] event=#{event} collection=#{collection_name.inspect} " \
892
+ "pid=#{Process.pid} #{payload}"
893
+ end
894
+
895
+ # Atlas Search index names share the URL/path space with Mongo
896
+ # commands; constrain them to a conservative identifier shape to
897
+ # avoid surprises from operators pasting whitespace, slashes, or
898
+ # control characters into a definition file.
899
+ def validate_search_index_name!(name)
900
+ s = name.to_s
901
+ unless s.match?(/\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/)
902
+ raise ArgumentError,
903
+ "Atlas Search index name #{name.inspect} is invalid. " \
904
+ "Must match /\\A[A-Za-z][A-Za-z0-9_-]{0,63}\\z/."
905
+ end
906
+ end
907
+
908
+ def validate_search_index_definition!(definition)
909
+ unless definition.is_a?(Hash) && !definition.empty?
910
+ raise ArgumentError,
911
+ "Atlas Search index definition must be a non-empty Hash; got #{definition.inspect}"
912
+ end
913
+ end
914
+
915
+ # Mongo's command parser tolerates symbol keys at the top level of
916
+ # the command Hash, but nested driver serialization for arbitrary
917
+ # mapping shapes (e.g. `fields: { title: { type: "string" } }`) is
918
+ # safer with string keys throughout. Mirrors {#stringify_keys} but
919
+ # recurses into Arrays and Hashes.
920
+ def stringify_keys_deep(value)
921
+ case value
922
+ when Hash
923
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
924
+ when Array
925
+ value.map { |v| stringify_keys_deep(v) }
926
+ else
927
+ value
928
+ end
929
+ end
930
+
931
+ public
932
+
933
+ # Re-expose `collection` as public after the private block above.
934
+ # (Ruby's `private` is sticky to the end of the class body; the
935
+ # writer-internal methods above are intentionally private but
936
+ # `collection` and the existing public surface must remain public.)
937
+ #
938
+ # No-op marker — the actual `public` reset happens below by
939
+ # explicitly listing the methods to re-publish.
940
+
941
+ # @deprecated Retained for backwards compatibility. The canonical list now lives
942
+ # in {Parse::PipelineSecurity::DENIED_OPERATORS}.
943
+ DENIED_OPERATORS = Parse::PipelineSecurity::DENIED_OPERATORS
944
+
945
+ # @!visibility private
946
+ # Default BFS depth for role-graph expansion. Real-world role graphs
947
+ # are 2-4 deep; 6 leaves headroom for unusual hierarchies without
948
+ # encouraging runaway $graphLookup fan-out on pathological inputs.
949
+ ROLE_GRAPH_DEFAULT_DEPTH = 6
950
+
951
+ # @!visibility private
952
+ # Hard ceiling on accepted `max_depth:` for the role-graph helpers.
953
+ # Anything above raises `ArgumentError` — the helpers do not silently
954
+ # clamp because a caller passing 100 is a bug worth surfacing.
955
+ # Lowered from 20 to 6 (matches DEFAULT_DEPTH) to prevent the helper
956
+ # from being used as a `$graphLookup` DoS amplifier on pathological
957
+ # role hierarchies. Real-world Parse `_Role` graphs are 2-4 deep;
958
+ # callers needing more should examine why their hierarchy is so
959
+ # deep before raising this ceiling.
960
+ ROLE_GRAPH_MAX_DEPTH = 6
961
+
962
+ # @!visibility private
963
+ # Hardcoded `maxTimeMS` budget for the role-graph aggregations. Both
964
+ # the forward (user → roles) and reverse (role → users) helpers run
965
+ # under this cap; an attacker who synthesizes a deep / fan-out-heavy
966
+ # role graph cannot extend execution beyond this budget.
967
+ ROLE_GRAPH_MAX_TIME_MS = 5000
968
+
969
+ # @!visibility private
970
+ # Strict regex for Parse objectIds passed into the role-graph helpers.
971
+ # Parse Server's default IDs are 10 alphanumeric chars; configurable
972
+ # custom-ID rules permit `_`/`-` and lengths up to 64. The regex fails
973
+ # closed on NUL bytes, Unicode RTL marks, dotted forms, etc.
974
+ ROLE_GRAPH_ID_RE = /\A[A-Za-z0-9_\-]{1,64}\z/
975
+
976
+ # Resolve every role name a user inherits via a single
977
+ # `$graphLookup` aggregation against the Parse role-membership and
978
+ # role-inheritance join tables.
979
+ #
980
+ # This is the mongo-direct fast path that {Parse::Role.all_for_user}
981
+ # falls into when an explicit authorization scope is provided.
982
+ # The pipeline shape is hardcoded; only `user_id` and `max_depth`
983
+ # are interpolated, and both are validated against {ROLE_GRAPH_ID_RE}
984
+ # / {ROLE_GRAPH_MAX_DEPTH}.
985
+ #
986
+ # The call bypasses {Parse::MongoDB.aggregate} on purpose: that
987
+ # entry point injects an `_rperm` `$match` and rewrites
988
+ # `$lookup` / `$graphLookup` stages with the same predicate, which
989
+ # would filter every `_Join:*:_Role` row to zero (those join
990
+ # collections have no `_rperm` column). {Parse::PipelineSecurity.validate_filter!}
991
+ # still runs against the constructed pipeline as belt-and-braces
992
+ # protection against a future regression that interpolates a caller
993
+ # value into a denied operator.
994
+ #
995
+ # If `_Join:roles:_Role` doesn't exist (the app uses flat roles
996
+ # without inheritance), MongoDB treats the missing collection as
997
+ # empty and `$graphLookup` returns no parents — the result collapses
998
+ # to direct memberships only, matching the Parse-Server-backed walk.
999
+ #
1000
+ # ## Authorization contract
1001
+ #
1002
+ # The helper requires an EXPLICIT per-call authorization:
1003
+ #
1004
+ # * `master: true` — explicit master-mode opt-in. Bypasses
1005
+ # `_Role` CLP. Use for admin tooling, analytics jobs, and
1006
+ # any code path that legitimately needs to read role graphs
1007
+ # across users.
1008
+ #
1009
+ # * `as: <User|Pointer>` — caller scope. The supplied user must
1010
+ # be permitted to `find` on `_Role` under the cached CLP, or
1011
+ # {Parse::CLPScope::Denied} is raised. `_Role`'s default CLP
1012
+ # is master-only, so this path will fail closed unless the
1013
+ # operator has explicitly opened `_Role` CLP for the user.
1014
+ #
1015
+ # Passing neither raises `ArgumentError`. The previous behavior
1016
+ # (gated only on the process-level `master_key_available?`
1017
+ # boolean — a check on the SDK's boot config, not the caller's
1018
+ # authority) is removed — it provided no per-call authorization.
1019
+ #
1020
+ # ## Return-value contract
1021
+ # - `Set<String>` on success (possibly empty if the user has no
1022
+ # direct memberships).
1023
+ # - `nil` when the fast path is unavailable (mongo gem missing,
1024
+ # {Parse::MongoDB.available?} false). Callers fall back to the
1025
+ # Parse-Server N+1 walk.
1026
+ # - Raises {Parse::MongoDB::ExecutionTimeout} on Mongo timeout
1027
+ # (attack-signal — do not silently fall back), `ArgumentError`
1028
+ # on input-validation failure or missing authorization, and
1029
+ # propagates other `Mongo::Error` subclasses that aren't
1030
+ # recognized as benign availability errors.
1031
+ #
1032
+ # @param user_id [String] a Parse `_User.objectId`.
1033
+ # @param max_depth [Integer] BFS depth bound. See
1034
+ # {ROLE_GRAPH_DEFAULT_DEPTH} for the default and
1035
+ # {ROLE_GRAPH_MAX_DEPTH} for the upper bound.
1036
+ # @param master [Boolean] when `true`, bypass `_Role` CLP. Mutually
1037
+ # exclusive with `as:`.
1038
+ # @param as [Parse::User, Parse::Pointer, nil] caller-scope user.
1039
+ # When provided (and `master:` is not), the scope is resolved
1040
+ # via {Parse::ACLScope.resolve!} and the resulting permission
1041
+ # set is checked against `_Role` CLP before the pipeline runs.
1042
+ # @return [Set<String>, nil] resolved role names, or nil when the
1043
+ # fast path is unavailable.
1044
+ # @raise [ArgumentError] when neither `master:` nor `as:` is
1045
+ # supplied, or when both are supplied.
1046
+ # @raise [Parse::CLPScope::Denied] when `as:` is supplied and the
1047
+ # scope cannot `find` on `_Role`.
1048
+ def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
1049
+ authorize_role_graph_call!(:role_names_for_user, master: master, as: as)
1050
+ validate_role_graph_id!(user_id, "user_id")
1051
+ depth = validate_role_graph_depth!(max_depth)
1052
+ return Set.new if depth <= 0
1053
+ return nil unless available?
1054
+
1055
+ graph_depth = depth - 1
1056
+ pipeline = build_user_role_names_pipeline(user_id, graph_depth)
1057
+ Parse::PipelineSecurity.validate_filter!(
1058
+ pipeline, allow_internal_fields: true,
1059
+ )
1060
+
1061
+ result_set = nil
1062
+ ActiveSupport::Notifications.instrument(
1063
+ "parse.mongodb.role_graph",
1064
+ direction: :forward, target_id: user_id, depth: depth,
1065
+ ) do |payload|
1066
+ docs = collection("_Join:users:_Role").aggregate(
1067
+ pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
1068
+ ).to_a
1069
+ names = Array(docs.first && docs.first["names"])
1070
+ result_set = Set.new(
1071
+ names.reject { |n| n.nil? || n.to_s.empty? }.map(&:to_s),
1072
+ )
1073
+ payload[:result_count] = result_set.size
1074
+ end
1075
+ result_set
1076
+ rescue NotEnabled, GemNotAvailable
1077
+ nil
1078
+ rescue StandardError => e
1079
+ if defined?(::Mongo::Error::OperationFailure) &&
1080
+ e.is_a?(::Mongo::Error::OperationFailure)
1081
+ raise_if_timeout!(e, "_Join:users:_Role", ROLE_GRAPH_MAX_TIME_MS)
1082
+ end
1083
+ raise
1084
+ end
1085
+
1086
+ # Resolve every `_User.objectId` whose effective role set includes
1087
+ # `role_id` — i.e., direct members of `role_id` PLUS direct members
1088
+ # of any descendant role in `role_id`'s inheritance subtree.
1089
+ #
1090
+ # Walks DOWN the inheritance tree via `$graphLookup` against
1091
+ # `_Join:roles:_Role` (parent → children → grandchildren), then
1092
+ # joins to `_Join:users:_Role` to pluck member ids, and finally
1093
+ # filters out tombstoned `_User` rows so the fast path matches
1094
+ # the soft-delete semantics the Parse-Server-backed path gets for
1095
+ # free via REST CLP enforcement.
1096
+ #
1097
+ # When called with a scoped `as:` argument (not master mode),
1098
+ # the `_User` `$lookup` sub-pipeline is augmented with an
1099
+ # `_rperm` `$match` so the joined `_User` rows are filtered to
1100
+ # ones the scope can read. Without this, the join leaks
1101
+ # `_User._id` regardless of caller authorization.
1102
+ #
1103
+ # Same authorization contract, return-value contract, and
1104
+ # error-policy as {role_names_for_user}.
1105
+ #
1106
+ # @param role_id [String] a Parse `_Role.objectId`.
1107
+ # @param max_depth [Integer] BFS depth bound.
1108
+ # @param master [Boolean] when `true`, bypass `_Role` CLP and the
1109
+ # `_User` `_rperm` filter on the join. Mutually exclusive with
1110
+ # `as:`.
1111
+ # @param as [Parse::User, Parse::Pointer, nil] caller-scope user.
1112
+ # When provided, the scope is resolved via
1113
+ # {Parse::ACLScope.resolve!}, the resulting permission set is
1114
+ # checked against `_Role` CLP, and the resolved `_rperm`
1115
+ # allow-set is injected into the `_User` join sub-pipeline.
1116
+ # @return [Set<String>, nil] resolved `_User.objectId`s, or nil
1117
+ # when the fast path is unavailable.
1118
+ # @raise [ArgumentError] when neither `master:` nor `as:` is
1119
+ # supplied, or when both are supplied.
1120
+ # @raise [Parse::CLPScope::Denied] when `as:` is supplied and the
1121
+ # scope cannot `find` on `_Role`.
1122
+ def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
1123
+ resolution = authorize_role_graph_call!(
1124
+ :users_in_role_subtree, master: master, as: as,
1125
+ )
1126
+ validate_role_graph_id!(role_id, "role_id")
1127
+ depth = validate_role_graph_depth!(max_depth)
1128
+ return Set.new if depth <= 0
1129
+ return nil unless available?
1130
+
1131
+ graph_depth = depth - 1
1132
+ # Caller-scope path injects the resolved _rperm allow-set into
1133
+ # the _User sub-pipeline so the join honors row-level ACL.
1134
+ # Master mode leaves the sub-pipeline unscoped — the explicit
1135
+ # `master: true` is the operator's intent.
1136
+ rperm_allow = nil
1137
+ unless resolution.nil? || resolution.master?
1138
+ rperm_allow = resolution.permission_strings
1139
+ end
1140
+ pipeline = build_role_subtree_users_pipeline(
1141
+ role_id, graph_depth, rperm_allow: rperm_allow,
1142
+ )
1143
+ Parse::PipelineSecurity.validate_filter!(
1144
+ pipeline, allow_internal_fields: true,
1145
+ )
1146
+
1147
+ result_set = nil
1148
+ ActiveSupport::Notifications.instrument(
1149
+ "parse.mongodb.role_graph",
1150
+ direction: :reverse, target_id: role_id, depth: depth,
1151
+ ) do |payload|
1152
+ docs = collection("_Join:roles:_Role").aggregate(
1153
+ pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
1154
+ ).to_a
1155
+ ids = Array(docs.first && docs.first["user_ids"])
1156
+ result_set = Set.new(
1157
+ ids.reject { |i| i.nil? || i.to_s.empty? }.map(&:to_s),
1158
+ )
1159
+ payload[:result_count] = result_set.size
1160
+ end
1161
+ result_set
1162
+ rescue NotEnabled, GemNotAvailable
1163
+ nil
1164
+ rescue StandardError => e
1165
+ if defined?(::Mongo::Error::OperationFailure) &&
1166
+ e.is_a?(::Mongo::Error::OperationFailure)
1167
+ raise_if_timeout!(e, "_Join:roles:_Role", ROLE_GRAPH_MAX_TIME_MS)
1168
+ end
1169
+ raise
1170
+ end
1171
+
1172
+ # @!visibility private
1173
+ # True when the SDK's default client has a non-empty master key in
1174
+ # its boot configuration. This is a **process-level configuration
1175
+ # check**, NOT a per-call authorization check — it tells you that
1176
+ # the SDK was constructed with a master key, not that the caller
1177
+ # presented one. The two states are very different: a scoped agent
1178
+ # (acl_user / acl_role / session_token) running in a process whose
1179
+ # default client was booted with a master key will still see this
1180
+ # method return `true`, even though the caller has no master-key
1181
+ # authority.
1182
+ #
1183
+ # Retained for backwards-compat callers that introspect SDK boot
1184
+ # state. **Never use as an authorization gate**; use
1185
+ # {.authorize_role_graph_call!} (or the equivalent path-specific
1186
+ # check) for per-call authorization.
1187
+ def master_key_available?
1188
+ return false unless defined?(Parse) && Parse.respond_to?(:client)
1189
+ c = begin
1190
+ Parse.client
1191
+ rescue StandardError
1192
+ nil
1193
+ end
1194
+ return false if c.nil?
1195
+ key = c.respond_to?(:master_key) ? c.master_key : nil
1196
+ key.is_a?(String) && !key.empty?
1197
+ end
1198
+
1199
+ # @!visibility private
1200
+ # Backwards-compat alias for {.master_key_available?}. Prefer the
1201
+ # new name in new code — `available?` reflects the actual meaning
1202
+ # ("the SDK has a master key it could use") more clearly than
1203
+ # `configured?` (which sounded like "the caller has master-key
1204
+ # authority"). Same warning applies: never an authorization gate.
1205
+ def master_key_configured?
1206
+ master_key_available?
1207
+ end
1208
+
1209
+ # @!visibility private
1210
+ # Enforce per-call authorization for the role-graph helpers.
1211
+ # Caller must supply either `master: true` OR an explicit
1212
+ # `as: <User|Pointer>` scope; passing both is rejected. When `as:`
1213
+ # is supplied, the scope is resolved through
1214
+ # {Parse::ACLScope.resolve!} and the resulting permission set is
1215
+ # checked against `_Role` CLP via {Parse::CLPScope.permits?}. CLP
1216
+ # denial raises {Parse::CLPScope::Denied}. Master mode bypasses
1217
+ # the CLP check (analytics jobs, admin tooling).
1218
+ #
1219
+ # @param method_name [Symbol] caller's method name, for error msgs.
1220
+ # @param master [Boolean] explicit master-mode opt-in.
1221
+ # @param as [Parse::User, Parse::Pointer, nil] caller-scope user.
1222
+ # @return [Parse::ACLScope::Resolution] the resolved auth state.
1223
+ # `resolution.master?` is true in master mode; otherwise the
1224
+ # resolution carries the user's permission strings.
1225
+ # @raise [ArgumentError] when neither (or both) of `master:`/`as:`
1226
+ # are provided.
1227
+ # @raise [Parse::CLPScope::Denied] when the resolved scope cannot
1228
+ # `find` on `_Role`.
1229
+ def authorize_role_graph_call!(method_name, master:, as:)
1230
+ if master == true && !as.nil?
1231
+ raise ArgumentError,
1232
+ "Parse::MongoDB.#{method_name}: pass exactly one of " \
1233
+ "`master: true` or `as: <Parse::User|Parse::Pointer>`. " \
1234
+ "They are mutually exclusive."
1235
+ end
1236
+
1237
+ if master == true
1238
+ return Parse::ACLScope::Resolution.new(
1239
+ mode: :master, permission_strings: nil, user_id: nil, session: nil,
1240
+ )
1241
+ end
1242
+
1243
+ if as.nil?
1244
+ raise ArgumentError,
1245
+ "Parse::MongoDB.#{method_name}: refusing to enumerate the " \
1246
+ "role graph without an explicit authorization scope. Pass " \
1247
+ "`master: true` for admin/analytics use, OR `as: current_user` " \
1248
+ "to run under the caller's scope (subject to `_Role` CLP)."
1249
+ end
1250
+
1251
+ resolution = Parse::ACLScope.resolve!({ acl_user: as }, method_name: method_name)
1252
+ unless resolution.master?
1253
+ perms = resolution.permission_strings
1254
+ unless Parse::CLPScope.permits?(Parse::Model::CLASS_ROLE, :find, perms)
1255
+ raise Parse::CLPScope::Denied.new(
1256
+ Parse::Model::CLASS_ROLE, :find,
1257
+ "Parse::MongoDB.#{method_name}: scope cannot `find` on " \
1258
+ "#{Parse::Model::CLASS_ROLE.inspect} under the current CLP. " \
1259
+ "Pass `master: true` to bypass, or grant the scope `find` " \
1260
+ "permission on _Role.",
1261
+ )
1262
+ end
1263
+ end
1264
+ resolution
1265
+ end
1266
+
1267
+ # @!visibility private
1268
+ # Format-only validation: confirms `id` is a non-empty String of
1269
+ # up to 64 chars matching {ROLE_GRAPH_ID_RE}. Does **not** check
1270
+ # that the id exists in `_User` / `_Role` — that lookup would
1271
+ # require a second round-trip and would itself be subject to
1272
+ # authorization. The authorization contract enforced via
1273
+ # {.authorize_role_graph_call!} is the primary defense; this
1274
+ # validator is defense-in-depth against control-char injection
1275
+ # and oversize-string DoS in the `$match` predicate.
1276
+ def validate_role_graph_id!(id, name)
1277
+ unless id.is_a?(String) && ROLE_GRAPH_ID_RE.match?(id)
1278
+ raise ArgumentError,
1279
+ "Parse::MongoDB role-graph helpers require #{name} to match #{ROLE_GRAPH_ID_RE.inspect}; got #{id.inspect}"
1280
+ end
1281
+ end
1282
+
1283
+ # @!visibility private
1284
+ def validate_role_graph_depth!(max_depth)
1285
+ unless max_depth.is_a?(Integer) && max_depth <= ROLE_GRAPH_MAX_DEPTH
1286
+ raise ArgumentError,
1287
+ "Parse::MongoDB role-graph helpers require max_depth to be an Integer no greater than #{ROLE_GRAPH_MAX_DEPTH}; got #{max_depth.inspect}"
1288
+ end
1289
+ max_depth
1290
+ end
1291
+
1292
+ # @!visibility private
1293
+ def build_user_role_names_pipeline(user_id, graph_depth)
1294
+ pipeline = [
1295
+ { "$match" => { "relatedId" => user_id } },
1296
+ { "$graphLookup" => {
1297
+ "from" => "_Join:roles:_Role",
1298
+ "startWith" => "$owningId",
1299
+ "connectFromField" => "owningId",
1300
+ "connectToField" => "relatedId",
1301
+ "as" => "parent_chain",
1302
+ "maxDepth" => graph_depth,
1303
+ } },
1304
+ { "$project" => {
1305
+ "_id" => 0,
1306
+ "role_ids" => {
1307
+ "$setUnion" => [["$owningId"], "$parent_chain.owningId"],
1308
+ },
1309
+ } },
1310
+ { "$unwind" => "$role_ids" },
1311
+ { "$group" => { "_id" => nil, "ids" => { "$addToSet" => "$role_ids" } } },
1312
+ { "$lookup" => {
1313
+ "from" => "_Role",
1314
+ "localField" => "ids",
1315
+ "foreignField" => "_id",
1316
+ "as" => "roles",
1317
+ } },
1318
+ { "$project" => { "_id" => 0, "names" => "$roles.name" } },
1319
+ ]
1320
+ # Defense-in-depth: hardcoded-shape assertions catch any future
1321
+ # regression that interpolates a caller value into a
1322
+ # graph-traversal field. The validator can't tell the difference
1323
+ # between an SDK-built constant and a tainted value once the
1324
+ # pipeline is assembled, so we check here at the boundary.
1325
+ assert_user_role_names_pipeline_shape!(pipeline, user_id, graph_depth)
1326
+ pipeline
1327
+ end
1328
+
1329
+ # @!visibility private
1330
+ def build_role_subtree_users_pipeline(role_id, graph_depth, rperm_allow: nil)
1331
+ # `_User` sub-pipeline: by default filter only tombstones; when
1332
+ # a scoped caller is in effect, also filter on _rperm so the
1333
+ # join honors row-level ACL. Master mode passes `rperm_allow: nil`
1334
+ # and gets the unscoped form (the explicit master opt-in is the
1335
+ # operator's intent).
1336
+ user_match = {
1337
+ "$expr" => { "$in" => ["$_id", "$$ids"] },
1338
+ "_tombstone" => { "$exists" => false },
1339
+ }
1340
+ if rperm_allow.is_a?(Array) && rperm_allow.any?
1341
+ user_match.merge!(Parse::ACL.read_predicate(rperm_allow))
1342
+ end
1343
+
1344
+ pipeline = [
1345
+ { "$match" => { "owningId" => role_id } },
1346
+ { "$graphLookup" => {
1347
+ "from" => "_Join:roles:_Role",
1348
+ "startWith" => "$relatedId",
1349
+ "connectFromField" => "relatedId",
1350
+ "connectToField" => "owningId",
1351
+ "as" => "descendant_chain",
1352
+ "maxDepth" => graph_depth,
1353
+ } },
1354
+ { "$project" => {
1355
+ "_id" => 0,
1356
+ "role_ids" => {
1357
+ "$setUnion" => [["$relatedId"], "$descendant_chain.relatedId"],
1358
+ },
1359
+ } },
1360
+ { "$unwind" => "$role_ids" },
1361
+ { "$group" => { "_id" => nil, "ids" => { "$addToSet" => "$role_ids" } } },
1362
+ { "$project" => {
1363
+ "_id" => 0,
1364
+ "ids" => { "$setUnion" => ["$ids", [role_id]] },
1365
+ } },
1366
+ { "$lookup" => {
1367
+ "from" => "_Join:users:_Role",
1368
+ "localField" => "ids",
1369
+ "foreignField" => "owningId",
1370
+ "as" => "memberships",
1371
+ } },
1372
+ { "$project" => {
1373
+ "_id" => 0,
1374
+ "user_id_candidates" => "$memberships.relatedId",
1375
+ } },
1376
+ # Filter tombstoned _User rows AND project only `_id` server-side
1377
+ # via pipeline-form $lookup (3.6+). Without this, a role with N
1378
+ # members pulls N full _User docs (hashed_password, session
1379
+ # tokens, _auth_data_*) over the wire just to read `_id`. That
1380
+ # shape DoSes on a large role; the pipeline-form keeps the wire
1381
+ # payload bounded to N `_id` strings.
1382
+ #
1383
+ # When `rperm_allow` is non-empty (caller-scope path), the
1384
+ # `_rperm` match is folded into the sub-pipeline filter so the
1385
+ # join honors row-level ACL.
1386
+ { "$lookup" => {
1387
+ "from" => "_User",
1388
+ "let" => { "ids" => "$user_id_candidates" },
1389
+ "pipeline" => [
1390
+ { "$match" => user_match },
1391
+ { "$project" => { "_id" => 1 } },
1392
+ ],
1393
+ "as" => "active_users",
1394
+ } },
1395
+ { "$project" => {
1396
+ "_id" => 0,
1397
+ "user_ids" => "$active_users._id",
1398
+ } },
1399
+ ]
1400
+ # Defense-in-depth shape assertions (see comment in
1401
+ # build_user_role_names_pipeline for rationale).
1402
+ assert_role_subtree_users_pipeline_shape!(pipeline, role_id, graph_depth)
1403
+ pipeline
1404
+ end
1405
+
1406
+ # @!visibility private
1407
+ # Hardcoded-shape assertion for build_user_role_names_pipeline.
1408
+ # Designed to fail loudly if a future change interpolates a caller
1409
+ # value into `connectFromField` / `connectToField` / `from` /
1410
+ # `startWith`. These fields drive the BFS direction in MongoDB; a
1411
+ # caller value here would be a query-injection primitive.
1412
+ def assert_user_role_names_pipeline_shape!(pipeline, user_id, graph_depth)
1413
+ raise "role-graph pipeline shape regression: $match.relatedId must equal user_id" \
1414
+ unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
1415
+ pipeline[0]["$match"]["relatedId"] == user_id
1416
+ gl = pipeline[1] && pipeline[1]["$graphLookup"]
1417
+ raise "role-graph pipeline shape regression: missing $graphLookup stage" \
1418
+ unless gl.is_a?(Hash)
1419
+ raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" \
1420
+ unless gl["from"] == "_Join:roles:_Role"
1421
+ raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" \
1422
+ unless gl["connectFromField"] == "owningId"
1423
+ raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" \
1424
+ unless gl["connectToField"] == "relatedId"
1425
+ raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" \
1426
+ unless gl["startWith"] == "$owningId"
1427
+ raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" \
1428
+ unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
1429
+ end
1430
+
1431
+ # @!visibility private
1432
+ # Hardcoded-shape assertion for build_role_subtree_users_pipeline.
1433
+ def assert_role_subtree_users_pipeline_shape!(pipeline, role_id, graph_depth)
1434
+ raise "role-graph pipeline shape regression: $match.owningId must equal role_id" \
1435
+ unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
1436
+ pipeline[0]["$match"]["owningId"] == role_id
1437
+ gl = pipeline[1] && pipeline[1]["$graphLookup"]
1438
+ raise "role-graph pipeline shape regression: missing $graphLookup stage" \
1439
+ unless gl.is_a?(Hash)
1440
+ raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" \
1441
+ unless gl["from"] == "_Join:roles:_Role"
1442
+ raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" \
1443
+ unless gl["connectFromField"] == "relatedId"
1444
+ raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" \
1445
+ unless gl["connectToField"] == "owningId"
1446
+ raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" \
1447
+ unless gl["startWith"] == "$relatedId"
1448
+ raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" \
1449
+ unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
1450
+ # Final _User $lookup carries the hardcoded foreign collection.
1451
+ user_lookup = pipeline.find { |s| s.dig("$lookup", "from") == "_User" }
1452
+ raise "role-graph pipeline shape regression: missing _User $lookup stage" \
1453
+ unless user_lookup.is_a?(Hash)
1454
+ end
1455
+
1456
+ # Execute an aggregation pipeline directly on MongoDB
1457
+ # @param collection_name [String] the collection name
1458
+ # @param pipeline [Array<Hash>] the aggregation pipeline stages
1459
+ # @param max_time_ms [Integer, nil] optional server-side time limit in milliseconds.
1460
+ # When provided, MongoDB will cancel the query if it exceeds this budget and
1461
+ # the driver error is translated to {Parse::MongoDB::ExecutionTimeout}.
1462
+ # Pass +nil+ (the default) for no cap.
1463
+ # @return [Array<Hash>] the raw results from MongoDB
1464
+ # @param rewrite_lookups [Boolean, nil] when true (default `nil` --
1465
+ # reads `Parse.rewrite_lookups`), auto-rewrite LLM-style $lookup
1466
+ # stages against logical class names into the Parse-on-Mongo
1467
+ # column form when the foreign class declares `parse_reference`.
1468
+ # @param allow_internal_fields [Boolean] when true, skip the
1469
+ # internal-fields denylist check (e.g. for SDK-generated ACL
1470
+ # filters produced by {Parse::Query#readable_by_role} and friends
1471
+ # that legitimately reference +_rperm+/+_wperm+). The
1472
+ # DENIED_OPERATORS walk, forensic-operator-in-+$expr+ check, and
1473
+ # internal-field +$+-reference string check all still run.
1474
+ # Passed +true+ only from the SDK direct-execution sites that
1475
+ # build their pipeline entirely from {Parse::Query#compile_where}:
1476
+ # +Parse::Query#results_direct+, +#first_direct+ (via
1477
+ # +results_direct+), +#count_direct+, +#distinct_direct+,
1478
+ # +#atlas_search+ builder-block, and the two +#group_by_*+ direct
1479
+ # paths. The Agent MCP tool path and +Aggregation#execute_direct!+
1480
+ # keep the default +false+ so attacker-controlled or user-supplied
1481
+ # aggregate stages cannot reach internal columns.
1482
+ # @param session_token [String, nil] when provided, the SDK
1483
+ # resolves the token to the requesting user + role membership
1484
+ # (via {Parse::AtlasSearch::Session}) and prepends an
1485
+ # `_rperm` `$match` stage to the pipeline so the result set
1486
+ # simulates Parse Server's row-level ACL enforcement. This
1487
+ # path is the only ACL boundary on a mongo-direct call — the
1488
+ # underlying Mongo connection is admin-credentialed at
1489
+ # `Parse::MongoDB.configure` time, so the SDK *is* the
1490
+ # enforcement layer. Mutually exclusive with `master:`.
1491
+ # @param master [Boolean, nil] pass `true` to explicitly bypass
1492
+ # the SDK's row-ACL injection (analytics jobs, admin tooling
1493
+ # that legitimately needs to read across users). Mutually
1494
+ # exclusive with `session_token:`.
1495
+ # @raise [Parse::MongoDB::DeniedOperator] if the pipeline contains
1496
+ # a server-side JS or data-mutating operator at any depth.
1497
+ # @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
1498
+ # @raise [Parse::ACLScope::ACLRequired] when neither
1499
+ # `session_token:` nor `master: true` is supplied and
1500
+ # {Parse::ACLScope.require_session_token} is enabled.
1501
+ def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil)
1502
+ # Resolve auth kwargs into a Parse::ACLScope::Resolution. The
1503
+ # call MUTATES the temporary kwargs hash (popping the auth
1504
+ # entries) before the resolution; we package them into a hash
1505
+ # here only so the shared helper can stay path-agnostic. The
1506
+ # hash is local and discarded after the call.
1507
+ auth_kwargs = {
1508
+ session_token: session_token,
1509
+ master: master,
1510
+ acl_user: acl_user,
1511
+ acl_role: acl_role,
1512
+ }.compact
1513
+ resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)
1514
+
1515
+ # Validate BEFORE rewrite so the security denylist is applied to the
1516
+ # caller's original pipeline (which an attacker controls), not to
1517
+ # the gem-rewritten form (which it doesn't). Matches the ordering
1518
+ # used by Parse::Query#aggregate and Parse::Agent::Tools.aggregate.
1519
+ assert_no_denied_operators!(pipeline, allow_internal_fields: allow_internal_fields)
1520
+
1521
+ # Wave-3 TRACK-CLP-4: refuse any caller-supplied `$<field>`
1522
+ # reference that names a protectedField for the queried class
1523
+ # in the current scope. The post-fetch redact strips by NAME,
1524
+ # so a pipeline can launder a protected value through a
1525
+ # `$project: { renamed: "$ssn" }` (and similar) clauses and
1526
+ # bypass the strip silently. Catching the reference here at
1527
+ # parse-time refuses the join with `Parse::CLPScope::Denied`
1528
+ # so the bypass surfaces as an explicit error rather than a
1529
+ # quiet exfiltration. Master mode short-circuits inside the
1530
+ # scanner (no protected set on master).
1531
+ Parse::PipelineSecurity.refuse_protected_field_references!(
1532
+ pipeline, collection_name, resolution,
1533
+ )
1534
+
1535
+ pipeline = Parse::LookupRewriter.auto_rewrite(
1536
+ pipeline, class_name: collection_name, enabled: rewrite_lookups,
1537
+ )
1538
+
1539
+ # Three-layer ACL simulation on the mongo-direct path:
1540
+ #
1541
+ # 1. Top-level $match: filter the queried collection's rows by
1542
+ # the session's _rperm allow-set. Mirrors Parse Server's
1543
+ # REST find behavior.
1544
+ # 2. Pipeline rewriter: every $lookup / $unionWith / $graphLookup /
1545
+ # $facet sub-pipeline gets the same _rperm filter embedded
1546
+ # so joined rows from other collections are filtered at the
1547
+ # database. Without this, includes/joins would silently leak
1548
+ # rows the requesting session has no permission to read.
1549
+ # 3. Post-fetch redaction: walk the returned documents and
1550
+ # scrub any embedded sub-documents whose stored _rperm
1551
+ # doesn't match the perms set. Catches cases the rewriter
1552
+ # can't reach (e.g., :object columns embedding raw pointer
1553
+ # hashes, or caller-supplied $lookup stages that escaped
1554
+ # rewriting because of unusual shapes).
1555
+ #
1556
+ # The security validator already ran on the caller's original
1557
+ # pipeline above; the injected stages reference `_rperm` but
1558
+ # are SDK-generated (not attacker-controlled), so no
1559
+ # re-validation is needed before they're handed to MongoDB.
1560
+ if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
1561
+ pipeline = [acl_stage] + pipeline
1562
+ end
1563
+ pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
1564
+
1565
+ # Class-Level Permissions boundary check. Parse Server's REST
1566
+ # aggregate endpoint runs master-key-only and does NOT enforce
1567
+ # CLP; the mongo-direct path bypasses Parse Server entirely so
1568
+ # the SDK is the only enforcement layer. Refuse the call when
1569
+ # the resolved scope can't `find` on the collection. Master-
1570
+ # key (resolution.master? / nil permission_strings) bypasses.
1571
+ perms_for_clp = resolution&.permission_strings
1572
+ unless resolution.nil? || resolution.master?
1573
+ unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp)
1574
+ raise Parse::CLPScope::Denied.new(
1575
+ collection_name, :find,
1576
+ "CLP refuses find on '#{collection_name}' for the current scope.",
1577
+ )
1578
+ end
1579
+ end
1580
+
1581
+ # Resolve the pointerFields constraint (if any) BEFORE running
1582
+ # the query — we apply the filter post-fetch but want to fail
1583
+ # loudly when the scope can't satisfy the constraint at all
1584
+ # (acl_role-only / public agents have no user_id to match).
1585
+ pointer_fields = nil
1586
+ unless resolution.nil? || resolution.master?
1587
+ pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
1588
+ if pointer_fields && resolution.user_id.nil?
1589
+ raise Parse::CLPScope::Denied.new(
1590
+ collection_name, :find,
1591
+ "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
1592
+ "but the current scope has no user_id.",
1593
+ )
1594
+ end
1595
+ end
1596
+
1597
+ agg_opts = {}
1598
+ agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1599
+ coll = collection(collection_name)
1600
+ if (mode = normalize_read_preference(read_preference))
1601
+ coll = coll.with(read: { mode: mode })
1602
+ end
1603
+ results = coll.aggregate(pipeline, agg_opts).to_a
1604
+ Parse::ACLScope.redact_results!(results, resolution)
1605
+
1606
+ # Post-fetch pointerFields filter: drop rows where none of the
1607
+ # named pointer fields references the requesting user. Skipped
1608
+ # for master-key and when the CLP has no pointerFields entry.
1609
+ if pointer_fields
1610
+ results = Parse::CLPScope.filter_by_pointer_fields(
1611
+ results, pointer_fields, resolution.user_id,
1612
+ )
1613
+ end
1614
+
1615
+ # Protected fields stripping. Resolve the field set per the
1616
+ # session's claim composition and walk-delete from every
1617
+ # row + embedded sub-document. Top-level $project would also
1618
+ # work but doesn't reach inside `$lookup`-included sub-docs,
1619
+ # so the post-walker is the defense-in-depth layer.
1620
+ unless resolution.nil? || resolution.master?
1621
+ strip_set = Parse::CLPScope.protected_fields_for(
1622
+ collection_name, perms_for_clp,
1623
+ )
1624
+ Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
1625
+ end
1626
+
1627
+ results
1628
+ rescue => e
1629
+ raise_if_timeout!(e, collection_name, max_time_ms)
1630
+ raise
1631
+ end
1632
+
1633
+ # Execute a `$geoNear` aggregation against a collection, returning
1634
+ # documents sorted by proximity to `near` along with their computed
1635
+ # distance. `$geoNear` is the aggregation-pipeline analogue of
1636
+ # `$nearSphere`; the headline differences are that it emits the
1637
+ # distance value on each returned doc (`distance_field:`) and that
1638
+ # downstream pipeline stages can compose with the proximity sort.
1639
+ #
1640
+ # A `2dsphere` index on the queried geo field is **required**; the
1641
+ # operation errors loudly without one (no silent collection scan).
1642
+ # `$geoNear` must be the first stage in the pipeline — Parse::MongoDB
1643
+ # places it correctly. The Mongo default 100-document cap was removed
1644
+ # in recent server versions, so pass an explicit `limit:` whenever
1645
+ # the caller would otherwise drain the entire collection.
1646
+ #
1647
+ # @example
1648
+ # center = Parse::GeoPoint.new(32.7157, -117.1611)
1649
+ # Parse::MongoDB.geo_near("Place",
1650
+ # near: center,
1651
+ # max_distance: 5,
1652
+ # unit: :km,
1653
+ # query: { category: "Park" },
1654
+ # limit: 25,
1655
+ # )
1656
+ # # Each result document carries a `dist.calculated` field (meters).
1657
+ #
1658
+ # @param collection_name [String] the MongoDB collection name. Use
1659
+ # `klass.parse_class` when starting from a Parse::Object subclass.
1660
+ # @param near [Parse::GeoPoint, Hash, Array] the anchor point.
1661
+ # Accepts a {Parse::GeoPoint}, a GeoJSON `Point` Hash, or a
1662
+ # `[longitude, latitude]` Array. Modern Mongo (8.0+) strictly
1663
+ # validates GeoJSON-shaped input, so {Parse::GeoPoint} is preferred.
1664
+ # @param distance_field [String] output field name on each result
1665
+ # document for the computed distance. Dot notation is permitted
1666
+ # (e.g. `"dist.calculated"`). Defaults to `"distance"`.
1667
+ # @param max_distance [Numeric, nil] inclusive upper bound on
1668
+ # distance. With a 2dsphere index, the wire unit is **meters**;
1669
+ # pass `unit:` to convert from km or miles. With a legacy 2d
1670
+ # index the wire unit is radians (advanced; caller's burden).
1671
+ # @param min_distance [Numeric, nil] inclusive lower bound, same
1672
+ # unit semantics as `max_distance`.
1673
+ # @param unit [Symbol] one of `:meters` (default), `:km` /
1674
+ # `:kilometers`, `:miles`. Converts the user-supplied `max_distance`
1675
+ # and `min_distance` to meters before serializing.
1676
+ # @param spherical [Boolean] use spherical geometry. Defaults to
1677
+ # `true` — the conventional pairing with 2dsphere + GeoJSON. Set
1678
+ # to `false` only when querying a legacy planar 2d index.
1679
+ # @param query [Hash, nil] additional filter applied to candidate
1680
+ # documents. Cannot contain a `$near` predicate (Mongo rejects).
1681
+ # @param include_locs [String, nil] when set, the matched location
1682
+ # value is added to each result under this field name. Useful for
1683
+ # documents that may hold multiple geo fields.
1684
+ # @param key [String, nil] explicit geo field path. Required when
1685
+ # the collection has multiple geo indexes; otherwise Mongo picks
1686
+ # the unique 2d/2dsphere index automatically.
1687
+ # @param distance_multiplier [Numeric, nil] post-computation scalar
1688
+ # applied to every returned distance. The 2dsphere + meters path
1689
+ # typically does not need this; legacy 2d callers can pass an
1690
+ # Earth-radius constant to convert radians to km/miles.
1691
+ # @param limit [Integer, nil] when provided, appends a `$limit`
1692
+ # stage. The Mongo default 100-doc cap is no longer applied
1693
+ # automatically — set `limit:` (or pass `:limit => 0` to mean
1694
+ # "unbounded; I really mean it") to control the size.
1695
+ # @param additional_stages [Array<Hash>] extra pipeline stages to
1696
+ # append after `$geoNear` (and after `$limit` if any). Useful for
1697
+ # `$lookup` joins, `$project` field shaping, etc. Each stage
1698
+ # passes through the standard security validation.
1699
+ # @param max_time_ms [Integer, nil] server-side time limit; same
1700
+ # semantics as {.aggregate}.
1701
+ # @return [Array<Hash>] documents enriched with `distance_field`
1702
+ # (and `include_locs` when requested), in nearest-first order.
1703
+ # @raise [ArgumentError] when `near` is not a recognized point form
1704
+ # or when `unit` is unknown.
1705
+ # @raise [Parse::MongoDB::DeniedOperator] if `query:` or
1706
+ # `additional_stages:` contain a denied operator.
1707
+ # @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds
1708
+ # `max_time_ms`.
1709
+ def geo_near(collection_name,
1710
+ near:,
1711
+ distance_field: "distance",
1712
+ max_distance: nil,
1713
+ min_distance: nil,
1714
+ unit: :meters,
1715
+ spherical: true,
1716
+ query: nil,
1717
+ include_locs: nil,
1718
+ key: nil,
1719
+ distance_multiplier: nil,
1720
+ limit: nil,
1721
+ additional_stages: [],
1722
+ max_time_ms: nil,
1723
+ session_token: nil,
1724
+ master: nil,
1725
+ acl_user: nil,
1726
+ acl_role: nil,
1727
+ read_preference: nil)
1728
+ stage = { :$geoNear => {
1729
+ near: geojson_point_for(near),
1730
+ distanceField: distance_field.to_s,
1731
+ spherical: spherical ? true : false,
1732
+ } }
1733
+
1734
+ max_meters = convert_distance_to_meters(max_distance, unit) if max_distance
1735
+ min_meters = convert_distance_to_meters(min_distance, unit) if min_distance
1736
+ stage[:$geoNear][:maxDistance] = max_meters if max_meters
1737
+ stage[:$geoNear][:minDistance] = min_meters if min_meters
1738
+ stage[:$geoNear][:query] = query if query.is_a?(Hash) && !query.empty?
1739
+ stage[:$geoNear][:includeLocs] = include_locs.to_s if include_locs
1740
+ stage[:$geoNear][:key] = key.to_s if key
1741
+ stage[:$geoNear][:distanceMultiplier] = distance_multiplier if distance_multiplier
1742
+
1743
+ pipeline = [stage]
1744
+ pipeline << { :$limit => limit } if limit && limit > 0
1745
+ pipeline.concat(Array(additional_stages))
1746
+
1747
+ aggregate(collection_name, pipeline,
1748
+ max_time_ms: max_time_ms,
1749
+ session_token: session_token,
1750
+ master: master,
1751
+ acl_user: acl_user,
1752
+ acl_role: acl_role,
1753
+ read_preference: read_preference)
1754
+ end
1755
+
1756
+ # Execute a find query directly on MongoDB
1757
+ # @param collection_name [String] the collection name
1758
+ # @param filter [Hash] the query filter
1759
+ # @param options [Hash] additional options (limit, skip, sort, projection, max_time_ms).
1760
+ # When :limit is omitted, DEFAULT_FIND_LIMIT is applied before the
1761
+ # cursor is materialized and a warning is emitted if the cap is hit.
1762
+ # Pass `limit: 0` to explicitly request unbounded behavior.
1763
+ # When :max_time_ms is provided, MongoDB will cancel the query if it
1764
+ # exceeds the budget; the driver error is translated to
1765
+ # {Parse::MongoDB::ExecutionTimeout}.
1766
+ # @return [Array<Hash>] the raw results from MongoDB
1767
+ # @raise [Parse::MongoDB::DeniedOperator] if the filter contains
1768
+ # $where, $function, or $accumulator at any depth.
1769
+ # @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
1770
+ def find(collection_name, filter = {}, **options)
1771
+ allow_internal_fields = options.delete(:allow_internal_fields) || false
1772
+ assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
1773
+ max_time_ms = options.delete(:max_time_ms)
1774
+ cursor = collection(collection_name).find(filter)
1775
+ explicit_limit = options.key?(:limit)
1776
+ applied_default_limit = false
1777
+
1778
+ if explicit_limit
1779
+ cursor = cursor.limit(options[:limit]) if options[:limit] > 0
1780
+ else
1781
+ # Apply the hard default BEFORE to_a so we never materialize an
1782
+ # unbounded result set. Fetch one extra row so we can detect when
1783
+ # callers hit the cap and warn them.
1784
+ cursor = cursor.limit(DEFAULT_FIND_LIMIT + 1)
1785
+ applied_default_limit = true
1786
+ end
1787
+
1788
+ cursor = cursor.skip(options[:skip]) if options[:skip]
1789
+ cursor = cursor.sort(options[:sort]) if options[:sort]
1790
+ cursor = cursor.projection(options[:projection]) if options[:projection]
1791
+ cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
1792
+ results = cursor.to_a
1793
+
1794
+ if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
1795
+ # Trim the sentinel row and warn — the caller asked for everything
1796
+ # but the result set is larger than the safety cap.
1797
+ results = results.first(DEFAULT_FIND_LIMIT)
1798
+ warn "[Parse::MongoDB.find] on '#{collection_name}' truncated to " \
1799
+ "#{DEFAULT_FIND_LIMIT} rows (no :limit specified). Pass an " \
1800
+ "explicit :limit to control the size, or :limit => 0 for " \
1801
+ "unbounded behavior."
1802
+ end
1803
+
1804
+ results
1805
+ rescue => e
1806
+ raise_if_timeout!(e, collection_name, max_time_ms)
1807
+ raise
1808
+ end
1809
+
1810
+ # List Atlas Search indexes for a collection
1811
+ # Uses the $listSearchIndexes aggregation stage.
1812
+ # @param collection_name [String] the collection name
1813
+ # @return [Array<Hash>] array of search index definitions
1814
+ # @note Requires MongoDB Atlas or local Atlas deployment
1815
+ def list_search_indexes(collection_name)
1816
+ aggregate(collection_name, [{ "$listSearchIndexes" => {} }])
1817
+ end
1818
+
1819
+ # List regular MongoDB indexes for a collection.
1820
+ # Hits the system catalog via the driver's `indexes.list` and returns
1821
+ # the raw definitions — distinct from {.list_search_indexes}, which
1822
+ # only enumerates Atlas Search indexes. Operator-facing introspection
1823
+ # used by `Parse::Core::Describe`.
1824
+ #
1825
+ # @param collection_name [String] the Parse collection / class name
1826
+ # @return [Array<Hash>] each entry includes at least `"name"` and
1827
+ # `"key"` (`{ field => 1 | -1 | "text" | "2dsphere" }`), plus
1828
+ # driver-reported flags like `"unique"`, `"sparse"`,
1829
+ # `"partialFilterExpression"`, and `"expireAfterSeconds"` when set.
1830
+ def indexes(collection_name)
1831
+ collection(collection_name).indexes.to_a
1832
+ rescue StandardError => e
1833
+ # `listIndexes` raises NamespaceNotFound on collections that
1834
+ # haven't been created yet — treat as "no indexes" so describe
1835
+ # and plan paths degrade gracefully on empty databases.
1836
+ return [] if mongo_namespace_not_found?(e)
1837
+ raise
1838
+ end
1839
+
1840
+ # Per-index usage statistics via the `$indexStats` aggregation
1841
+ # stage. Returns a Hash keyed by index name with `{ops:, since:}`
1842
+ # for each — `ops` is the number of times the index has been
1843
+ # accessed since the last MongoDB restart, `since` is the timestamp
1844
+ # of that restart (i.e. the start of the counting window). Empty
1845
+ # Hash on access error so callers (e.g. `Model.describe(:indexes,
1846
+ # network: true, usage: true)`) degrade gracefully when the
1847
+ # authenticated role lacks `clusterMonitor` (the minimum privilege
1848
+ # `$indexStats` requires).
1849
+ #
1850
+ # **Admin-only.** This is a metadata-disclosure surface (which
1851
+ # indexes are hot fingerprints which classes hold interesting
1852
+ # data) and so requires explicit `master: true` to invoke. The
1853
+ # previous behavior hard-coded `master: true` internally, which
1854
+ # was a copy-paste-lethal pattern for any future row-returning
1855
+ # path. Callers without master scope raise `ArgumentError`
1856
+ # internally; that error is caught by the method's own
1857
+ # degrade-to-empty rescue so existing best-effort callers
1858
+ # (`Parse::Model.describe(:indexes, usage: true)`) continue to
1859
+ # surface `usage_available: false` instead of blowing up — but
1860
+ # the `ArgumentError` is the loud signal for anyone introducing
1861
+ # a new caller that forgets the opt-in. Direct callers that
1862
+ # disable the rescue (test mocks, callers wrapping with their
1863
+ # own error handling) will see the `ArgumentError` propagate.
1864
+ #
1865
+ # @param collection_name [String]
1866
+ # @param master [Boolean] explicit master-mode opt-in. Required.
1867
+ # @return [Hash{String => Hash}] `{ index_name => { ops:, since: } }`,
1868
+ # or `{}` when called without `master: true` (degrade-to-empty
1869
+ # rescue).
1870
+ def index_stats(collection_name, master: false)
1871
+ unless master == true
1872
+ raise ArgumentError,
1873
+ "Parse::MongoDB.index_stats is admin-only and requires `master: true`. " \
1874
+ "$indexStats discloses cluster metadata; pass `master: true` to confirm " \
1875
+ "the caller is authorized. Callers without master scope (e.g. agent " \
1876
+ "tools, request handlers) must not invoke this method."
1877
+ end
1878
+ results = aggregate(collection_name, [{ "$indexStats" => {} }], master: true)
1879
+ results.each_with_object({}) do |row, h|
1880
+ name = row["name"] || row[:name]
1881
+ next unless name
1882
+ accesses = row["accesses"] || row[:accesses] || {}
1883
+ h[name] = {
1884
+ ops: (accesses["ops"] || accesses[:ops]).to_i,
1885
+ since: accesses["since"] || accesses[:since],
1886
+ }
1887
+ end
1888
+ rescue StandardError
1889
+ # Lack of clusterMonitor / Atlas BI restriction / NamespaceNotFound
1890
+ # all surface here — `usage:` is best-effort by design.
1891
+ {}
1892
+ end
1893
+
1894
+ # Convert a MongoDB document to Parse REST API format
1895
+ # This transforms MongoDB's internal field names to Parse's format:
1896
+ # - _id -> objectId
1897
+ # - _created_at -> createdAt
1898
+ # - _updated_at -> updatedAt
1899
+ # - _p_fieldName -> fieldName (as pointer)
1900
+ # - _acl -> ACL (with r/w converted to read/write)
1901
+ # - Removes other internal fields (_rperm, _wperm, _hashed_password, etc.)
1902
+ #
1903
+ # @param doc [Hash] the MongoDB document
1904
+ # @param class_name [String] the Parse class name
1905
+ # @return [Hash] the Parse-formatted hash
1906
+ def convert_document_to_parse(doc, class_name = nil)
1907
+ return nil unless doc.is_a?(Hash)
1908
+
1909
+ result = {}
1910
+
1911
+ doc.each do |key, value|
1912
+ key_str = key.to_s
1913
+
1914
+ case key_str
1915
+ when "_id"
1916
+ # MongoDB _id becomes Parse objectId
1917
+ # Guard against BSON::ObjectId not being defined when mongo gem is not loaded
1918
+ result["objectId"] = if defined?(BSON::ObjectId) && value.is_a?(BSON::ObjectId)
1919
+ value.to_s
1920
+ else
1921
+ value
1922
+ end
1923
+ when "_created_at"
1924
+ # MongoDB _created_at becomes Parse createdAt
1925
+ result["createdAt"] = convert_date_to_parse(value)
1926
+ when "_updated_at"
1927
+ # MongoDB _updated_at becomes Parse updatedAt
1928
+ result["updatedAt"] = convert_date_to_parse(value)
1929
+ when /^_p_(.+)$/
1930
+ # Pointer fields: _p_author -> author
1931
+ field_name = $1
1932
+ result[field_name] = convert_pointer_to_parse(value)
1933
+ when "_acl"
1934
+ # Convert MongoDB ACL format (r/w) to Parse format (read/write)
1935
+ result["ACL"] = convert_acl_to_parse(value)
1936
+ when /^_included_(.+)$/
1937
+ # Included/resolved pointer field from $lookup - convert embedded document
1938
+ # This handles eager loading: _included_artist -> artist (as full object)
1939
+ field_name = $1
1940
+ if value.is_a?(Hash)
1941
+ # Recursively convert the embedded document to Parse format
1942
+ result[field_name] = convert_document_to_parse(value)
1943
+ elsif value.nil?
1944
+ # Preserve nil for unresolved optional relationships
1945
+ result[field_name] = nil
1946
+ else
1947
+ result[field_name] = value
1948
+ end
1949
+ when /^_include_id_/
1950
+ # Skip temporary lookup ID fields (used internally for $lookup)
1951
+ next
1952
+ when "_rperm", "_wperm", "_hashed_password", "_email_verify_token",
1953
+ "_perishable_token", "_tombstone", "_failed_login_count",
1954
+ "_account_lockout_expires_at", "_session_token"
1955
+ # Skip internal Parse Server fields (not needed since we use _acl)
1956
+ next
1957
+ when /^_/
1958
+ # Skip other internal fields starting with underscore
1959
+ next
1960
+ else
1961
+ # Regular fields - recursively convert nested documents
1962
+ result[key_str] = convert_value_to_parse(value)
1963
+ end
1964
+ end
1965
+
1966
+ # Add className if provided
1967
+ result["className"] = class_name if class_name
1968
+
1969
+ result
1970
+ end
1971
+
1972
+ # Convert multiple MongoDB documents to Parse format
1973
+ # @param docs [Array<Hash>] the MongoDB documents
1974
+ # @param class_name [String] the Parse class name
1975
+ # @return [Array<Hash>] the Parse-formatted hashes
1976
+ def convert_documents_to_parse(docs, class_name = nil)
1977
+ docs.map { |doc| convert_document_to_parse(doc, class_name) }
1978
+ end
1979
+
1980
+ # Convert a raw MongoDB aggregation row, coercing values (BSON ObjectIds,
1981
+ # dates, nested documents) but preserving all field names including +_id+.
1982
+ # Unlike {.convert_document_to_parse}, this does NOT rename +_id+ to
1983
+ # +objectId+, because aggregation +$group+ stages reuse +_id+ as the
1984
+ # group key (e.g. a pointer string like +"Team$abc"+) rather than as a
1985
+ # Parse object identifier.
1986
+ #
1987
+ # @param doc [Hash] a raw MongoDB aggregation result row
1988
+ # @return [Hash] the coerced hash with stringified keys
1989
+ def convert_aggregation_document(doc)
1990
+ return nil unless doc.is_a?(Hash)
1991
+ doc.each_with_object({}) do |(key, value), result|
1992
+ result[key.to_s] = convert_value_to_parse(value)
1993
+ end
1994
+ end
1995
+
1996
+ # Convert a date value to a UTC Time object suitable for MongoDB queries.
1997
+ # MongoDB stores all dates in UTC, so this helper ensures consistent date handling
1998
+ # when building aggregation pipelines or direct queries.
1999
+ #
2000
+ # @param value [Date, Time, DateTime, String, nil] the date value to convert
2001
+ # @return [Time, nil] a UTC Time object, or nil if value is nil
2002
+ # @raise [ArgumentError] if the value cannot be parsed as a date
2003
+ #
2004
+ # @example Converting different date types
2005
+ # Parse::MongoDB.to_mongodb_date(Date.new(2024, 1, 15))
2006
+ # # => 2024-01-15 00:00:00 UTC
2007
+ #
2008
+ # Parse::MongoDB.to_mongodb_date(Time.now)
2009
+ # # => 2024-11-30 12:30:45 UTC (converted to UTC)
2010
+ #
2011
+ # Parse::MongoDB.to_mongodb_date("2024-01-15")
2012
+ # # => 2024-01-15 00:00:00 UTC
2013
+ #
2014
+ # Parse::MongoDB.to_mongodb_date("2024-01-15T10:30:00Z")
2015
+ # # => 2024-01-15 10:30:00 UTC
2016
+ #
2017
+ # @example Using in aggregation pipelines
2018
+ # cutoff = Parse::MongoDB.to_mongodb_date(Date.today - 30)
2019
+ # pipeline = [{ "$match" => { "createdAt" => { "$gte" => cutoff } } }]
2020
+ # results = Song.query.aggregate(pipeline, mongo_direct: true).results
2021
+ #
2022
+ # @example Using with query constraints
2023
+ # # For date comparisons in queries, this ensures UTC consistency
2024
+ # start_date = Parse::MongoDB.to_mongodb_date(params[:start_date])
2025
+ # end_date = Parse::MongoDB.to_mongodb_date(params[:end_date])
2026
+ # songs = Song.query(:release_date.gte => start_date, :release_date.lt => end_date)
2027
+ def to_mongodb_date(value)
2028
+ return nil if value.nil?
2029
+
2030
+ case value
2031
+ when ::Time
2032
+ value.utc
2033
+ when ::DateTime
2034
+ value.to_time.utc
2035
+ when ::Date
2036
+ # Convert Date to midnight UTC
2037
+ ::Time.utc(value.year, value.month, value.day)
2038
+ when ::String
2039
+ # Parse string dates - try ISO 8601 first, then Date.parse
2040
+ begin
2041
+ if value =~ /T/
2042
+ # ISO 8601 with time component
2043
+ ::Time.parse(value).utc
2044
+ else
2045
+ # Date-only string, convert to midnight UTC
2046
+ date = ::Date.parse(value)
2047
+ ::Time.utc(date.year, date.month, date.day)
2048
+ end
2049
+ rescue ::ArgumentError => e
2050
+ raise ::ArgumentError, "Cannot parse '#{value}' as a date: #{e.message}"
2051
+ end
2052
+ when ::Integer
2053
+ # Assume Unix timestamp
2054
+ ::Time.at(value).utc
2055
+ else
2056
+ raise ::ArgumentError, "Cannot convert #{value.class} to MongoDB date. " \
2057
+ "Expected Date, Time, DateTime, String, or Integer."
2058
+ end
2059
+ end
2060
+
2061
+ private
2062
+
2063
+ # MongoDB error code for MaxTimeMSExpired
2064
+ MONGO_MAX_TIME_MS_EXPIRED_CODE = 50
2065
+
2066
+ # Inspect a driver exception and raise {ExecutionTimeout} if it carries
2067
+ # error code 50 (MaxTimeMSExpired). Otherwise, the original exception is
2068
+ # re-raised by the caller.
2069
+ #
2070
+ # @param err [StandardError] the exception to inspect
2071
+ # @param collection_name [String] the collection name (for the timeout error)
2072
+ # @param max_time_ms [Integer, nil] the budget that was exceeded (may be nil)
2073
+ # @return [void]
2074
+ # @raise [Parse::MongoDB::ExecutionTimeout] when code == 50
2075
+ def raise_if_timeout!(err, collection_name, max_time_ms)
2076
+ return unless defined?(::Mongo::Error::OperationFailure)
2077
+ return unless err.is_a?(::Mongo::Error::OperationFailure)
2078
+ return unless err.respond_to?(:code) && err.code == MONGO_MAX_TIME_MS_EXPIRED_CODE
2079
+
2080
+ raise ExecutionTimeout.new(
2081
+ collection_name: collection_name.to_s,
2082
+ max_time_ms: max_time_ms,
2083
+ )
2084
+ end
2085
+
2086
+ def extract_database_from_uri(uri)
2087
+ return nil unless uri
2088
+ # Extract database name from MongoDB URI
2089
+ # Format: mongodb://[user:pass@]host[:port]/database[?options]
2090
+ if uri =~ %r{mongodb(?:\+srv)?://[^/]+/([^?]+)}
2091
+ $1
2092
+ end
2093
+ end
2094
+
2095
+ def convert_date_to_parse(value)
2096
+ case value
2097
+ when Time, DateTime
2098
+ { "__type" => "Date", "iso" => value.utc.iso8601(3) }
2099
+ when Date
2100
+ { "__type" => "Date", "iso" => value.to_time.utc.iso8601(3) }
2101
+ when String
2102
+ # Already a string date, wrap in Parse format
2103
+ { "__type" => "Date", "iso" => value }
2104
+ else
2105
+ value
2106
+ end
2107
+ end
2108
+
2109
+ def convert_pointer_to_parse(value)
2110
+ return nil if value.nil?
2111
+
2112
+ if value.is_a?(String) && value.include?("$")
2113
+ # Parse pointer format: "ClassName$objectId"
2114
+ class_name, object_id = value.split("$", 2)
2115
+ {
2116
+ "__type" => "Pointer",
2117
+ "className" => class_name,
2118
+ "objectId" => object_id,
2119
+ }
2120
+ else
2121
+ value
2122
+ end
2123
+ end
2124
+
2125
+ # Convert MongoDB ACL format to Parse REST API format
2126
+ # MongoDB uses short keys: { "*": { r: true, w: false }, "userId": { r: true, w: true } }
2127
+ # Parse uses full keys: { "*": { read: true }, "userId": { read: true, write: true } }
2128
+ # @param value [Hash] the MongoDB ACL hash
2129
+ # @return [Hash] the Parse-formatted ACL hash
2130
+ def convert_acl_to_parse(value)
2131
+ return nil if value.nil?
2132
+ return value unless value.is_a?(Hash)
2133
+
2134
+ result = {}
2135
+ value.each do |entity, permissions|
2136
+ entity_str = entity.to_s
2137
+ next unless permissions.is_a?(Hash)
2138
+
2139
+ parsed_perms = {}
2140
+ # Convert r -> read, w -> write
2141
+ if permissions["r"] == true || permissions[:r] == true
2142
+ parsed_perms["read"] = true
2143
+ end
2144
+ if permissions["w"] == true || permissions[:w] == true
2145
+ parsed_perms["write"] = true
2146
+ end
2147
+ # Also handle if already in full format
2148
+ if permissions["read"] == true || permissions[:read] == true
2149
+ parsed_perms["read"] = true
2150
+ end
2151
+ if permissions["write"] == true || permissions[:write] == true
2152
+ parsed_perms["write"] = true
2153
+ end
2154
+
2155
+ result[entity_str] = parsed_perms if parsed_perms.any?
2156
+ end
2157
+ result
2158
+ end
2159
+
2160
+ def convert_value_to_parse(value)
2161
+ case value
2162
+ when Hash
2163
+ if value["__type"]
2164
+ # Already a Parse type, return as-is
2165
+ value
2166
+ elsif value[:__type]
2167
+ # Symbol keys, convert to string keys
2168
+ value.transform_keys(&:to_s)
2169
+ elsif (geojson = detect_geojson_geometry(value))
2170
+ # MongoDB stores GeoJSON natively for any 2dsphere-indexed
2171
+ # field. Translate the two geometries Parse Server models
2172
+ # (Point/Polygon) back into their Parse wire-format hashes so
2173
+ # the caller's downstream code can treat mongo-direct results
2174
+ # identically to Parse REST responses. Other geometry types
2175
+ # (LineString, MultiPolygon, etc.) are left as raw GeoJSON
2176
+ # hashes since Parse Server has no schema slot for them.
2177
+ geojson
2178
+ else
2179
+ # Regular hash, recursively convert
2180
+ value.transform_values { |v| convert_value_to_parse(v) }
2181
+ end
2182
+ when Array
2183
+ value.map { |v| convert_value_to_parse(v) }
2184
+ when Time, DateTime
2185
+ convert_date_to_parse(value)
2186
+ when Date
2187
+ convert_date_to_parse(value)
2188
+ else
2189
+ # Handle BSON::ObjectId if mongo gem is loaded
2190
+ if defined?(BSON::ObjectId) && value.is_a?(BSON::ObjectId)
2191
+ value.to_s
2192
+ else
2193
+ value
2194
+ end
2195
+ end
2196
+ end
2197
+
2198
+ # Coerce a user-supplied point value to a GeoJSON `Point` literal.
2199
+ # Accepts a {Parse::GeoPoint}, an already-shaped GeoJSON Point
2200
+ # Hash, or a `[longitude, latitude]` numeric Array.
2201
+ # @!visibility private
2202
+ def geojson_point_for(value)
2203
+ case value
2204
+ when Parse::GeoPoint
2205
+ { type: "Point", coordinates: [value.longitude, value.latitude] }
2206
+ when Hash
2207
+ hash = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
2208
+ type = hash[:type] || hash["type"]
2209
+ coords = hash[:coordinates] || hash["coordinates"]
2210
+ unless type.to_s == "Point" && coords.is_a?(Array) && coords.length == 2 &&
2211
+ coords.all? { |n| n.is_a?(Numeric) }
2212
+ raise ArgumentError, "[Parse::MongoDB.geo_near] `near:` hash must be a GeoJSON Point."
2213
+ end
2214
+ { type: "Point", coordinates: [coords[0].to_f, coords[1].to_f] }
2215
+ when Array
2216
+ unless value.length == 2 && value.all? { |n| n.is_a?(Numeric) }
2217
+ raise ArgumentError, "[Parse::MongoDB.geo_near] `near:` array must be [longitude, latitude]."
2218
+ end
2219
+ { type: "Point", coordinates: [value[0].to_f, value[1].to_f] }
2220
+ else
2221
+ raise ArgumentError, "[Parse::MongoDB.geo_near] `near:` must be a Parse::GeoPoint, " \
2222
+ "GeoJSON Point Hash, or [longitude, latitude] Array."
2223
+ end
2224
+ end
2225
+
2226
+ METERS_PER_KILOMETER = 1_000.0
2227
+ METERS_PER_MILE = 1_609.344
2228
+
2229
+ # Convert a user-supplied distance + unit to meters (the wire unit
2230
+ # for `$geoNear` against a 2dsphere index).
2231
+ # @!visibility private
2232
+ def convert_distance_to_meters(value, unit)
2233
+ return value.to_f if unit == :meters || unit.nil?
2234
+ case unit
2235
+ when :km, :kilometers then value.to_f * METERS_PER_KILOMETER
2236
+ when :miles then value.to_f * METERS_PER_MILE
2237
+ else
2238
+ raise ArgumentError, "[Parse::MongoDB.geo_near] `unit:` must be :meters, :km, or :miles."
2239
+ end
2240
+ end
2241
+
2242
+ # Detect a GeoJSON Point or Polygon geometry hash and convert it to
2243
+ # the equivalent Parse REST wire-format hash. Returns nil when the
2244
+ # input is not a recognized geometry, leaving the caller free to
2245
+ # treat it as a generic hash.
2246
+ # @return [Hash, nil]
2247
+ def detect_geojson_geometry(value)
2248
+ type = value["type"] || value[:type]
2249
+ coords = value["coordinates"] || value[:coordinates]
2250
+ return nil unless type.is_a?(String) && coords.is_a?(Array)
2251
+
2252
+ case type
2253
+ when "Point"
2254
+ return nil unless coords.length == 2 && coords.all? { |n| n.is_a?(Numeric) }
2255
+ lng, lat = coords
2256
+ { "__type" => "GeoPoint", "latitude" => lat.to_f, "longitude" => lng.to_f }
2257
+ when "Polygon"
2258
+ # GeoJSON Polygon outer ring -> Parse [lat, lng] pairs.
2259
+ return nil unless coords.first.is_a?(Array)
2260
+ pairs = coords.first.map do |pair|
2261
+ return nil unless pair.is_a?(Array) && pair.length == 2 &&
2262
+ pair[0].is_a?(Numeric) && pair[1].is_a?(Numeric)
2263
+ [pair[1].to_f, pair[0].to_f]
2264
+ end
2265
+ { "__type" => "Polygon", "coordinates" => pairs }
2266
+ end
2267
+ end
2268
+
2269
+ public
2270
+
2271
+ # Walk a filter hash or aggregation pipeline (Hash or Array) and
2272
+ # raise {DeniedOperator} if any nested key matches an entry in
2273
+ # {Parse::PipelineSecurity::DENIED_OPERATORS}.
2274
+ #
2275
+ # @param node [Hash, Array, Object] structure to walk.
2276
+ # @param allow_internal_fields [Boolean] when true, skip the
2277
+ # {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST} check.
2278
+ # Forwarded to {Parse::PipelineSecurity.validate_filter!}. The
2279
+ # DENIED_OPERATORS walk still runs. Intended only for callers
2280
+ # that built the pipeline via {Parse::Query}'s own constraint
2281
+ # DSL (e.g. {Parse::Query#readable_by_role}); raw user-supplied
2282
+ # pipelines (Agent MCP tools) must keep the default +false+.
2283
+ #
2284
+ # Public for testability and for callers that want to validate
2285
+ # input before forwarding to {.find} / {.aggregate}.
2286
+ def assert_no_denied_operators!(node, allow_internal_fields: false)
2287
+ Parse::PipelineSecurity.validate_filter!(node, allow_internal_fields: allow_internal_fields)
2288
+ nil
2289
+ rescue Parse::PipelineSecurity::Error => e
2290
+ raise DeniedOperator, e.message
2291
+ end
2292
+ end
2293
+
2294
+ # Initialize defaults
2295
+ @enabled = false
2296
+ @uri = nil
2297
+ @database = nil
2298
+ @client = nil
2299
+ end
2300
+ end