parse-stack-next 5.6.0 → 5.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +489 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +219 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +366 -113
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +6 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +6 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +136 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +120 -5
- data/parse-stack-next.gemspec +6 -6
- metadata +10 -1
data/lib/parse/agent/tools.rb
CHANGED
|
@@ -112,8 +112,8 @@ module Parse
|
|
|
112
112
|
items: {
|
|
113
113
|
type: "object",
|
|
114
114
|
properties: {
|
|
115
|
-
name:
|
|
116
|
-
category:
|
|
115
|
+
name: { type: "string" },
|
|
116
|
+
category: { type: "string" },
|
|
117
117
|
description: { type: "string" },
|
|
118
118
|
},
|
|
119
119
|
required: %w[name category description],
|
|
@@ -138,8 +138,8 @@ module Parse
|
|
|
138
138
|
parameters: {
|
|
139
139
|
type: "object",
|
|
140
140
|
properties: {
|
|
141
|
-
names:
|
|
142
|
-
|
|
141
|
+
names: { type: "array", items: { type: "string" },
|
|
142
|
+
description: "Optional. Restrict the output to these class names (exact match)." },
|
|
143
143
|
prefix: { type: "string",
|
|
144
144
|
description: "Optional. Restrict the output to class names that start with this prefix (case-sensitive)." },
|
|
145
145
|
},
|
|
@@ -148,16 +148,16 @@ module Parse
|
|
|
148
148
|
output_schema: {
|
|
149
149
|
type: "object",
|
|
150
150
|
properties: {
|
|
151
|
-
total:
|
|
152
|
-
note:
|
|
151
|
+
total: { type: "integer", minimum: 0 },
|
|
152
|
+
note: { type: "string" },
|
|
153
153
|
built_in: {
|
|
154
154
|
type: "array",
|
|
155
155
|
items: {
|
|
156
156
|
type: "object",
|
|
157
157
|
properties: {
|
|
158
|
-
name:
|
|
159
|
-
fields:
|
|
160
|
-
desc:
|
|
158
|
+
name: { type: "string" },
|
|
159
|
+
fields: { type: "integer", minimum: 0 },
|
|
160
|
+
desc: { type: "string" },
|
|
161
161
|
methods: { type: "integer", minimum: 0 },
|
|
162
162
|
},
|
|
163
163
|
required: %w[name fields],
|
|
@@ -169,9 +169,9 @@ module Parse
|
|
|
169
169
|
items: {
|
|
170
170
|
type: "object",
|
|
171
171
|
properties: {
|
|
172
|
-
name:
|
|
173
|
-
fields:
|
|
174
|
-
desc:
|
|
172
|
+
name: { type: "string" },
|
|
173
|
+
fields: { type: "integer", minimum: 0 },
|
|
174
|
+
desc: { type: "string" },
|
|
175
175
|
methods: { type: "integer", minimum: 0 },
|
|
176
176
|
},
|
|
177
177
|
required: %w[name fields],
|
|
@@ -205,18 +205,18 @@ module Parse
|
|
|
205
205
|
output_schema: {
|
|
206
206
|
type: "object",
|
|
207
207
|
properties: {
|
|
208
|
-
class_name:
|
|
209
|
-
type:
|
|
208
|
+
class_name: { type: "string" },
|
|
209
|
+
type: { type: "string" },
|
|
210
210
|
description: { type: "string" },
|
|
211
|
-
usage:
|
|
212
|
-
fields:
|
|
213
|
-
indexes:
|
|
211
|
+
usage: { type: "string" },
|
|
212
|
+
fields: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
213
|
+
indexes: { type: "object", additionalProperties: true },
|
|
214
214
|
permissions: { type: "object", additionalProperties: true },
|
|
215
|
-
agent_methods:
|
|
216
|
-
canonical_filter:
|
|
217
|
-
agent_fields:
|
|
215
|
+
agent_methods: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
216
|
+
canonical_filter: { type: "object", additionalProperties: true },
|
|
217
|
+
agent_fields: { type: "array", items: { type: "string" } },
|
|
218
218
|
agent_join_fields: { type: "array", items: { type: "string" } },
|
|
219
|
-
relations:
|
|
219
|
+
relations: { type: "object", additionalProperties: true },
|
|
220
220
|
},
|
|
221
221
|
required: %w[class_name type fields indexes permissions],
|
|
222
222
|
},
|
|
@@ -249,13 +249,13 @@ module Parse
|
|
|
249
249
|
keys: { type: "array", items: { type: "string" } },
|
|
250
250
|
include: { type: "array", items: { type: "string" } },
|
|
251
251
|
apply_canonical_filter: { type: "boolean",
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
description: "Default true. When true and the class declares an " \
|
|
253
|
+
"agent_canonical_filter, it is merged into the where via " \
|
|
254
|
+
"$and so the caller's constraints compose rather than override." },
|
|
255
255
|
format: { type: "string", enum: %w[json csv markdown table],
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
description: "Output format. Defaults to 'json' (structured row envelope). When set to " \
|
|
257
|
+
"csv/markdown/table the response carries {format:, headers:, row_count:, output:} " \
|
|
258
|
+
"instead of the row envelope; columns are inferred from the first row." },
|
|
259
259
|
},
|
|
260
260
|
required: ["class_name"],
|
|
261
261
|
},
|
|
@@ -271,35 +271,35 @@ module Parse
|
|
|
271
271
|
output_schema: {
|
|
272
272
|
type: "object",
|
|
273
273
|
properties: {
|
|
274
|
-
class_name:
|
|
274
|
+
class_name: { type: "string" },
|
|
275
275
|
# json envelope
|
|
276
|
-
result_count:
|
|
276
|
+
result_count: { type: "integer", minimum: 0 },
|
|
277
277
|
pagination: {
|
|
278
278
|
type: "object",
|
|
279
279
|
properties: {
|
|
280
|
-
limit:
|
|
281
|
-
skip:
|
|
280
|
+
limit: { type: "integer", minimum: 0 },
|
|
281
|
+
skip: { type: "integer", minimum: 0 },
|
|
282
282
|
has_more: { type: "boolean" },
|
|
283
283
|
},
|
|
284
284
|
required: %w[limit skip has_more],
|
|
285
285
|
},
|
|
286
|
-
truncated:
|
|
287
|
-
truncated_note:
|
|
286
|
+
truncated: { type: "boolean" },
|
|
287
|
+
truncated_note: { type: "string" },
|
|
288
288
|
truncated_include_fields: { type: "object", additionalProperties: true },
|
|
289
289
|
next_call: {
|
|
290
290
|
type: "object",
|
|
291
291
|
properties: {
|
|
292
|
-
tool:
|
|
292
|
+
tool: { type: "string" },
|
|
293
293
|
arguments: { type: "object", additionalProperties: true },
|
|
294
294
|
},
|
|
295
295
|
required: %w[tool arguments],
|
|
296
296
|
},
|
|
297
297
|
results: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
298
298
|
# csv / markdown / table envelope
|
|
299
|
-
format:
|
|
300
|
-
headers:
|
|
299
|
+
format: { type: "string", enum: %w[csv markdown table] },
|
|
300
|
+
headers: { type: "array", items: { type: "string" } },
|
|
301
301
|
row_count: { type: "integer", minimum: 0 },
|
|
302
|
-
output:
|
|
302
|
+
output: { type: "string" },
|
|
303
303
|
},
|
|
304
304
|
required: %w[class_name],
|
|
305
305
|
},
|
|
@@ -327,8 +327,8 @@ module Parse
|
|
|
327
327
|
output_schema: {
|
|
328
328
|
type: "object",
|
|
329
329
|
properties: {
|
|
330
|
-
class_name:
|
|
331
|
-
count:
|
|
330
|
+
class_name: { type: "string" },
|
|
331
|
+
count: { type: "integer", minimum: 0 },
|
|
332
332
|
constraints: { type: "object" },
|
|
333
333
|
},
|
|
334
334
|
required: %w[class_name count constraints],
|
|
@@ -349,11 +349,11 @@ module Parse
|
|
|
349
349
|
object_id: { type: "string" },
|
|
350
350
|
include: { type: "array", items: { type: "string" } },
|
|
351
351
|
apply_canonical_filter: { type: "boolean",
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
352
|
+
description: "Default true. When true and the class declares an " \
|
|
353
|
+
"agent_canonical_filter, the fetch is rewritten as a " \
|
|
354
|
+
"find_objects with where: { objectId: id, ...filter } " \
|
|
355
|
+
"so a filtered-out row returns 'not found'. Set to false " \
|
|
356
|
+
"to bypass the predicate and fetch the row directly." },
|
|
357
357
|
},
|
|
358
358
|
required: ["class_name", "object_id"],
|
|
359
359
|
},
|
|
@@ -361,10 +361,10 @@ module Parse
|
|
|
361
361
|
type: "object",
|
|
362
362
|
properties: {
|
|
363
363
|
class_name: { type: "string" },
|
|
364
|
-
object_id:
|
|
364
|
+
object_id: { type: "string" },
|
|
365
365
|
created_at: { type: %w[string null] },
|
|
366
366
|
updated_at: { type: %w[string null] },
|
|
367
|
-
object:
|
|
367
|
+
object: { type: "object" },
|
|
368
368
|
truncated_include_fields: { type: "object" },
|
|
369
369
|
},
|
|
370
370
|
required: %w[class_name object_id object],
|
|
@@ -387,10 +387,10 @@ module Parse
|
|
|
387
387
|
ids: { type: "array", items: { type: "string" }, description: "Array of objectId values (max 50, dedup'd)" },
|
|
388
388
|
include: { type: "array", items: { type: "string" }, description: "Pointer fields to include/resolve" },
|
|
389
389
|
apply_canonical_filter: { type: "boolean",
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
390
|
+
description: "Default true. When true and the class declares an " \
|
|
391
|
+
"agent_canonical_filter, it composes with the objectId $in " \
|
|
392
|
+
"constraint via $and so 'invalid state' rows are filtered out " \
|
|
393
|
+
"(they appear in the :missing array). Set to false to bypass." },
|
|
394
394
|
},
|
|
395
395
|
required: ["class_name", "ids"],
|
|
396
396
|
},
|
|
@@ -403,9 +403,9 @@ module Parse
|
|
|
403
403
|
additionalProperties: { type: "object" },
|
|
404
404
|
description: "Map of objectId => fetched object. Empty when no ids resolved.",
|
|
405
405
|
},
|
|
406
|
-
missing:
|
|
406
|
+
missing: { type: "array", items: { type: "string" } },
|
|
407
407
|
requested: { type: "integer", minimum: 0 },
|
|
408
|
-
found:
|
|
408
|
+
found: { type: "integer", minimum: 0 },
|
|
409
409
|
truncated_include_fields: { type: "object" },
|
|
410
410
|
},
|
|
411
411
|
required: %w[class_name objects missing requested found],
|
|
@@ -430,10 +430,10 @@ module Parse
|
|
|
430
430
|
output_schema: {
|
|
431
431
|
type: "object",
|
|
432
432
|
properties: {
|
|
433
|
-
class_name:
|
|
433
|
+
class_name: { type: "string" },
|
|
434
434
|
sample_count: { type: "integer", minimum: 0 },
|
|
435
|
-
samples:
|
|
436
|
-
note:
|
|
435
|
+
samples: { type: "array", items: { type: "object" } },
|
|
436
|
+
note: { type: "string" },
|
|
437
437
|
},
|
|
438
438
|
required: %w[class_name sample_count samples],
|
|
439
439
|
},
|
|
@@ -456,29 +456,29 @@ module Parse
|
|
|
456
456
|
type: "object",
|
|
457
457
|
properties: {
|
|
458
458
|
class_name: { type: "string" },
|
|
459
|
-
pipeline:
|
|
459
|
+
pipeline: { type: "array", items: { type: "object" } },
|
|
460
460
|
compact_pointers: { type: "boolean",
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
461
|
+
description: "Default true. When true, storage-form pointer columns (`_p_*`) are " \
|
|
462
|
+
"rewritten and the envelope carries a `pointer_classes` map. Set to " \
|
|
463
|
+
"false to receive raw Mongo shapes." },
|
|
464
464
|
apply_canonical_filter: { type: "boolean",
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
465
|
+
description: "Default true. When true and the class declares an " \
|
|
466
|
+
"agent_canonical_filter, it is prepended as a $match stage so " \
|
|
467
|
+
"the pipeline starts from the class's 'valid state' subset. " \
|
|
468
|
+
"Set to false to operate on the full collection." },
|
|
469
469
|
},
|
|
470
470
|
required: ["class_name", "pipeline"],
|
|
471
471
|
},
|
|
472
472
|
output_schema: {
|
|
473
473
|
type: "object",
|
|
474
474
|
properties: {
|
|
475
|
-
class_name:
|
|
475
|
+
class_name: { type: "string" },
|
|
476
476
|
pipeline_stages: { type: "integer", minimum: 0 },
|
|
477
|
-
result_count:
|
|
477
|
+
result_count: { type: "integer", minimum: 0 },
|
|
478
478
|
# `route` is :mongo_direct or :parse_server but serializes
|
|
479
479
|
# to a Symbol-shaped String in JSON envelopes; declare it
|
|
480
480
|
# permissively as string.
|
|
481
|
-
route:
|
|
481
|
+
route: { type: "string", description: "Routing tag: 'mongo_direct' or 'parse_server'." },
|
|
482
482
|
# Aggregation result rows are class-shape-dependent and may
|
|
483
483
|
# be the output of arbitrary $project / $group / $lookup
|
|
484
484
|
# stages. Object envelopes with open property sets are the
|
|
@@ -493,8 +493,8 @@ module Parse
|
|
|
493
493
|
description: "Optional. Field-name → Parse-class-name map when compact_pointers is on.",
|
|
494
494
|
},
|
|
495
495
|
auto_limited: { type: "boolean" },
|
|
496
|
-
auto_limit:
|
|
497
|
-
hint:
|
|
496
|
+
auto_limit: { type: "integer", minimum: 1 },
|
|
497
|
+
hint: { type: "string" },
|
|
498
498
|
},
|
|
499
499
|
required: %w[class_name pipeline_stages result_count route results],
|
|
500
500
|
},
|
|
@@ -554,55 +554,55 @@ module Parse
|
|
|
554
554
|
parameters: {
|
|
555
555
|
type: "object",
|
|
556
556
|
properties: {
|
|
557
|
-
class_name:
|
|
558
|
-
field:
|
|
559
|
-
operation:
|
|
557
|
+
class_name: { type: "string" },
|
|
558
|
+
field: { type: "string", description: "Field to group by (wire-format name; pointers auto-detected)" },
|
|
559
|
+
operation: {
|
|
560
560
|
type: "string",
|
|
561
561
|
enum: %w[count sum avg average min max],
|
|
562
562
|
description: "Aggregation to apply per group. Default: count.",
|
|
563
563
|
},
|
|
564
|
-
value_field:
|
|
565
|
-
where:
|
|
564
|
+
value_field: { type: "string", description: "Required for sum/avg/min/max — the field to aggregate within each group." },
|
|
565
|
+
where: { type: "object", description: "Optional constraints applied via $match before grouping." },
|
|
566
566
|
flatten_arrays: { type: "boolean", description: "When true, $unwind the field before grouping so individual array elements are counted." },
|
|
567
|
-
sort:
|
|
567
|
+
sort: {
|
|
568
568
|
type: "string",
|
|
569
569
|
enum: %w[value_desc value_asc key_desc key_asc],
|
|
570
570
|
description: "Sort the result. Use value_desc for top-K. Default: server-natural order.",
|
|
571
571
|
},
|
|
572
|
-
limit:
|
|
573
|
-
dry_run:
|
|
572
|
+
limit: { type: "integer", description: "Cap the number of groups returned. Default: 200, max: 1000." },
|
|
573
|
+
dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it. Use to inspect / hand-modify before running via the aggregate tool." },
|
|
574
574
|
apply_canonical_filter: { type: "boolean",
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
575
|
+
description: "Default true. When true and the class declares an " \
|
|
576
|
+
"agent_canonical_filter, it is prepended as a $match stage " \
|
|
577
|
+
"so the group operates only on the class's 'valid state' " \
|
|
578
|
+
"subset. Set to false to group across the full collection." },
|
|
579
579
|
},
|
|
580
580
|
required: ["class_name", "field"],
|
|
581
581
|
},
|
|
582
582
|
output_schema: {
|
|
583
583
|
type: "object",
|
|
584
584
|
properties: {
|
|
585
|
-
class_name:
|
|
586
|
-
field:
|
|
587
|
-
operation:
|
|
588
|
-
group_count:
|
|
585
|
+
class_name: { type: "string" },
|
|
586
|
+
field: { type: "string" },
|
|
587
|
+
operation: { type: "string" },
|
|
588
|
+
group_count: { type: "integer", minimum: 0 },
|
|
589
589
|
groups: {
|
|
590
590
|
type: "array",
|
|
591
591
|
items: {
|
|
592
592
|
type: "object",
|
|
593
593
|
properties: {
|
|
594
|
-
key:
|
|
594
|
+
key: {},
|
|
595
595
|
value: { type: %w[number null] },
|
|
596
596
|
},
|
|
597
597
|
required: %w[key value],
|
|
598
598
|
},
|
|
599
599
|
},
|
|
600
|
-
value_field:
|
|
601
|
-
pointer_class:
|
|
600
|
+
value_field: { type: "string" },
|
|
601
|
+
pointer_class: { type: "string" },
|
|
602
602
|
flatten_arrays: { type: "boolean" },
|
|
603
|
-
sort:
|
|
604
|
-
truncated:
|
|
605
|
-
limit:
|
|
603
|
+
sort: { type: "string" },
|
|
604
|
+
truncated: { type: "boolean" },
|
|
605
|
+
limit: { type: "integer" },
|
|
606
606
|
},
|
|
607
607
|
required: %w[class_name field operation group_count groups limit],
|
|
608
608
|
},
|
|
@@ -621,60 +621,60 @@ module Parse
|
|
|
621
621
|
parameters: {
|
|
622
622
|
type: "object",
|
|
623
623
|
properties: {
|
|
624
|
-
class_name:
|
|
625
|
-
field:
|
|
626
|
-
interval:
|
|
624
|
+
class_name: { type: "string" },
|
|
625
|
+
field: { type: "string", description: "Date field to bucket on (e.g. 'createdAt', 'updatedAt', or a custom Date column)." },
|
|
626
|
+
interval: {
|
|
627
627
|
type: "string",
|
|
628
628
|
enum: %w[year month week day hour minute second],
|
|
629
629
|
description: "Bucket size.",
|
|
630
630
|
},
|
|
631
|
-
operation:
|
|
631
|
+
operation: {
|
|
632
632
|
type: "string",
|
|
633
633
|
enum: %w[count sum avg average min max],
|
|
634
634
|
description: "Aggregation per bucket. Default: count.",
|
|
635
635
|
},
|
|
636
636
|
value_field: { type: "string", description: "Required for sum/avg/min/max — the field to aggregate within each bucket." },
|
|
637
|
-
where:
|
|
638
|
-
timezone:
|
|
639
|
-
sort:
|
|
637
|
+
where: { type: "object", description: "Optional constraints applied via $match before grouping." },
|
|
638
|
+
timezone: { type: "string", description: "IANA tz name (e.g. 'America/New_York') or fixed offset ('+05:00'). Default: UTC." },
|
|
639
|
+
sort: {
|
|
640
640
|
type: "string",
|
|
641
641
|
enum: %w[key_asc key_desc value_asc value_desc],
|
|
642
642
|
description: "Sort the result. Default: key_asc (chronological).",
|
|
643
643
|
},
|
|
644
|
-
limit:
|
|
645
|
-
dry_run:
|
|
644
|
+
limit: { type: "integer", description: "Cap the number of buckets returned. Default: 200, max: 1000." },
|
|
645
|
+
dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it." },
|
|
646
646
|
apply_canonical_filter: { type: "boolean",
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
647
|
+
description: "Default true. When true and the class declares an " \
|
|
648
|
+
"agent_canonical_filter, it is prepended as a $match stage " \
|
|
649
|
+
"so the buckets reflect only the class's 'valid state' " \
|
|
650
|
+
"subset. Set to false to bucket the full collection." },
|
|
651
651
|
},
|
|
652
652
|
required: ["class_name", "field", "interval"],
|
|
653
653
|
},
|
|
654
654
|
output_schema: {
|
|
655
655
|
type: "object",
|
|
656
656
|
properties: {
|
|
657
|
-
class_name:
|
|
658
|
-
field:
|
|
659
|
-
interval:
|
|
660
|
-
operation:
|
|
661
|
-
group_count:
|
|
657
|
+
class_name: { type: "string" },
|
|
658
|
+
field: { type: "string" },
|
|
659
|
+
interval: { type: "string" },
|
|
660
|
+
operation: { type: "string" },
|
|
661
|
+
group_count: { type: "integer", minimum: 0 },
|
|
662
662
|
groups: {
|
|
663
663
|
type: "array",
|
|
664
664
|
items: {
|
|
665
665
|
type: "object",
|
|
666
666
|
properties: {
|
|
667
|
-
key:
|
|
667
|
+
key: { type: %w[string null] },
|
|
668
668
|
value: { type: %w[number null] },
|
|
669
669
|
},
|
|
670
670
|
required: %w[key value],
|
|
671
671
|
},
|
|
672
672
|
},
|
|
673
|
-
value_field:
|
|
674
|
-
timezone:
|
|
675
|
-
sort:
|
|
676
|
-
truncated:
|
|
677
|
-
limit:
|
|
673
|
+
value_field: { type: "string" },
|
|
674
|
+
timezone: { type: "string" },
|
|
675
|
+
sort: { type: "string" },
|
|
676
|
+
truncated: { type: "boolean" },
|
|
677
|
+
limit: { type: "integer" },
|
|
678
678
|
},
|
|
679
679
|
required: %w[class_name field interval operation group_count groups sort limit],
|
|
680
680
|
},
|
|
@@ -692,35 +692,35 @@ module Parse
|
|
|
692
692
|
type: "object",
|
|
693
693
|
properties: {
|
|
694
694
|
class_name: { type: "string" },
|
|
695
|
-
field:
|
|
696
|
-
where:
|
|
697
|
-
sort:
|
|
695
|
+
field: { type: "string", description: "Field to extract distinct values from (wire-format name; pointers auto-detected)." },
|
|
696
|
+
where: { type: "object", description: "Optional constraints applied via $match before distinct." },
|
|
697
|
+
sort: {
|
|
698
698
|
type: "string",
|
|
699
699
|
enum: %w[asc desc],
|
|
700
700
|
description: "Sort the returned values alphanumerically. Default: server-natural order.",
|
|
701
701
|
},
|
|
702
|
-
limit:
|
|
703
|
-
dry_run:
|
|
702
|
+
limit: { type: "integer", description: "Cap the number of distinct values returned. Default: 1000, max: 5000." },
|
|
703
|
+
dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it." },
|
|
704
704
|
apply_canonical_filter: { type: "boolean",
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
705
|
+
description: "Default true. When true and the class declares an " \
|
|
706
|
+
"agent_canonical_filter, it is prepended as a $match stage " \
|
|
707
|
+
"so values are extracted only from the class's 'valid state' " \
|
|
708
|
+
"subset. Set to false to extract across the full collection." },
|
|
709
709
|
},
|
|
710
710
|
required: ["class_name", "field"],
|
|
711
711
|
},
|
|
712
712
|
output_schema: {
|
|
713
713
|
type: "object",
|
|
714
714
|
properties: {
|
|
715
|
-
class_name:
|
|
716
|
-
field:
|
|
717
|
-
count:
|
|
718
|
-
values:
|
|
719
|
-
|
|
715
|
+
class_name: { type: "string" },
|
|
716
|
+
field: { type: "string" },
|
|
717
|
+
count: { type: "integer", minimum: 0 },
|
|
718
|
+
values: { type: "array",
|
|
719
|
+
items: { type: %w[string number boolean null] } },
|
|
720
720
|
pointer_class: { type: "string" },
|
|
721
|
-
sort:
|
|
722
|
-
truncated:
|
|
723
|
-
limit:
|
|
721
|
+
sort: { type: "string" },
|
|
722
|
+
truncated: { type: "boolean" },
|
|
723
|
+
limit: { type: "integer" },
|
|
724
724
|
},
|
|
725
725
|
required: %w[class_name field count values limit],
|
|
726
726
|
},
|
|
@@ -744,16 +744,16 @@ module Parse
|
|
|
744
744
|
properties: {
|
|
745
745
|
class_name: { type: "string" },
|
|
746
746
|
# query mode
|
|
747
|
-
where:
|
|
748
|
-
keys:
|
|
749
|
-
include:
|
|
750
|
-
order:
|
|
751
|
-
limit:
|
|
752
|
-
skip:
|
|
747
|
+
where: { type: "object" },
|
|
748
|
+
keys: { type: "array", items: { type: "string" } },
|
|
749
|
+
include: { type: "array", items: { type: "string" } },
|
|
750
|
+
order: { type: "string" },
|
|
751
|
+
limit: { type: "integer" },
|
|
752
|
+
skip: { type: "integer" },
|
|
753
753
|
# aggregate mode (mutually exclusive with where/keys/order/limit)
|
|
754
754
|
pipeline: { type: "array", items: { type: "object" } },
|
|
755
755
|
# output control
|
|
756
|
-
columns:
|
|
756
|
+
columns: {
|
|
757
757
|
type: "array",
|
|
758
758
|
# Each entry is either a string (used as both path and header) or a
|
|
759
759
|
# single-entry { "<field>" => "<Header>" } object for renaming.
|
|
@@ -768,7 +768,7 @@ module Parse
|
|
|
768
768
|
description: "Column spec. Each entry is either a string (field name, used as header) " \
|
|
769
769
|
"or an object {field => header} to rename. Dotted paths supported.",
|
|
770
770
|
},
|
|
771
|
-
format:
|
|
771
|
+
format: {
|
|
772
772
|
type: "string",
|
|
773
773
|
enum: %w[csv markdown table],
|
|
774
774
|
description: "Output format. Defaults to 'csv'.",
|
|
@@ -788,16 +788,16 @@ module Parse
|
|
|
788
788
|
class_name: { type: "string" },
|
|
789
789
|
# `format` is one of csv|markdown|table; same shape as the
|
|
790
790
|
# input enum.
|
|
791
|
-
format:
|
|
791
|
+
format: { type: "string", enum: %w[csv markdown table] },
|
|
792
792
|
headers: { type: "array", items: { type: "string" } },
|
|
793
793
|
row_count: { type: "integer", minimum: 0 },
|
|
794
794
|
# The serialized output is the formatted CSV / Markdown /
|
|
795
795
|
# text-table string itself — clients render it as-is.
|
|
796
|
-
output:
|
|
797
|
-
truncated:
|
|
796
|
+
output: { type: "string" },
|
|
797
|
+
truncated: { type: "boolean" },
|
|
798
798
|
available_rows: { type: "integer", minimum: 0 },
|
|
799
|
-
row_cap:
|
|
800
|
-
hint:
|
|
799
|
+
row_cap: { type: "integer", minimum: 1 },
|
|
800
|
+
hint: { type: "string" },
|
|
801
801
|
},
|
|
802
802
|
required: %w[class_name format headers row_count output],
|
|
803
803
|
},
|
|
@@ -819,14 +819,14 @@ module Parse
|
|
|
819
819
|
type: "object",
|
|
820
820
|
properties: {
|
|
821
821
|
class_name: { type: "string" },
|
|
822
|
-
query:
|
|
823
|
-
fields:
|
|
822
|
+
query: { type: "string", description: "Search query text. Non-empty." },
|
|
823
|
+
fields: {
|
|
824
824
|
type: "array",
|
|
825
825
|
items: { type: "string" },
|
|
826
826
|
description: "Optional. Restrict search to these fields. When omitted, all indexed fields are " \
|
|
827
827
|
"searched. Subject to the class's agent_fields allowlist when one is declared.",
|
|
828
828
|
},
|
|
829
|
-
limit:
|
|
829
|
+
limit: {
|
|
830
830
|
type: "integer",
|
|
831
831
|
description: "Optional. Max results, default 10, hard cap 20.",
|
|
832
832
|
},
|
|
@@ -834,16 +834,16 @@ module Parse
|
|
|
834
834
|
type: "string",
|
|
835
835
|
description: "Optional. Field to return highlight snippets for. Subject to agent_fields allowlist.",
|
|
836
836
|
},
|
|
837
|
-
filter:
|
|
837
|
+
filter: {
|
|
838
838
|
type: "object",
|
|
839
839
|
description: "Optional. Additional MongoDB filter applied after the $search stage. Same security " \
|
|
840
840
|
"validation as aggregate's pipeline: no $where / $function / $accumulator.",
|
|
841
841
|
},
|
|
842
842
|
apply_canonical_filter: { type: "boolean",
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
843
|
+
description: "Default true. When true and the class declares an " \
|
|
844
|
+
"agent_canonical_filter, it is AND-merged into the " \
|
|
845
|
+
"post-$search $match (alongside any caller filter:) " \
|
|
846
|
+
"so search results come from the 'valid state' subset only." },
|
|
847
847
|
},
|
|
848
848
|
required: %w[class_name query],
|
|
849
849
|
},
|
|
@@ -851,7 +851,7 @@ module Parse
|
|
|
851
851
|
type: "object",
|
|
852
852
|
properties: {
|
|
853
853
|
class_name: { type: "string" },
|
|
854
|
-
count:
|
|
854
|
+
count: { type: "integer", minimum: 0 },
|
|
855
855
|
# Each row is a Parse object projected through the class's
|
|
856
856
|
# agent_fields allowlist, with an Atlas-supplied `score`
|
|
857
857
|
# numeric and an optional `highlights` array when the
|
|
@@ -875,7 +875,7 @@ module Parse
|
|
|
875
875
|
type: "object",
|
|
876
876
|
properties: {
|
|
877
877
|
value: { type: "string" },
|
|
878
|
-
type:
|
|
878
|
+
type: { type: "string", description: "'hit' or 'text' per Atlas spec." },
|
|
879
879
|
},
|
|
880
880
|
required: %w[value],
|
|
881
881
|
},
|
|
@@ -908,25 +908,25 @@ module Parse
|
|
|
908
908
|
type: "object",
|
|
909
909
|
properties: {
|
|
910
910
|
class_name: { type: "string" },
|
|
911
|
-
query:
|
|
912
|
-
field:
|
|
911
|
+
query: { type: "string", description: "Prefix to autocomplete against. Non-empty." },
|
|
912
|
+
field: {
|
|
913
913
|
type: "string",
|
|
914
914
|
description: "Field name configured for autocomplete in the search index. Must be in " \
|
|
915
915
|
"agent_fields allowlist when one is declared.",
|
|
916
916
|
},
|
|
917
|
-
limit:
|
|
917
|
+
limit: {
|
|
918
918
|
type: "integer",
|
|
919
919
|
description: "Optional. Max suggestions, default 10, hard cap 20.",
|
|
920
920
|
},
|
|
921
|
-
fuzzy:
|
|
921
|
+
fuzzy: {
|
|
922
922
|
type: "boolean",
|
|
923
923
|
description: "Optional. Enable single-edit fuzzy matching. Default false.",
|
|
924
924
|
},
|
|
925
925
|
apply_canonical_filter: { type: "boolean",
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
926
|
+
description: "Default true. When true and the class declares an " \
|
|
927
|
+
"agent_canonical_filter, it is applied as a post-$search " \
|
|
928
|
+
"$match so autocomplete suggestions exclude 'invalid state' " \
|
|
929
|
+
"rows that the rest of the read-tool surface hides." },
|
|
930
930
|
},
|
|
931
931
|
required: %w[class_name query field],
|
|
932
932
|
},
|
|
@@ -934,12 +934,12 @@ module Parse
|
|
|
934
934
|
type: "object",
|
|
935
935
|
properties: {
|
|
936
936
|
class_name: { type: "string" },
|
|
937
|
-
field:
|
|
937
|
+
field: { type: "string" },
|
|
938
938
|
# `suggestions` is the list of distinct field values that
|
|
939
939
|
# matched the autocomplete query (deduped, ordered by Atlas
|
|
940
940
|
# ranking). Strings only — autocomplete operates on text.
|
|
941
941
|
suggestions: { type: "array", items: { type: "string" } },
|
|
942
|
-
count:
|
|
942
|
+
count: { type: "integer", minimum: 0 },
|
|
943
943
|
# Full matching Parse objects, projected through the class
|
|
944
944
|
# agent_fields allowlist.
|
|
945
945
|
results: {
|
|
@@ -968,29 +968,29 @@ module Parse
|
|
|
968
968
|
type: "object",
|
|
969
969
|
properties: {
|
|
970
970
|
class_name: { type: "string" },
|
|
971
|
-
query:
|
|
972
|
-
facets:
|
|
971
|
+
query: { type: "string", description: "Optional. Search query text; pass empty for match-all." },
|
|
972
|
+
facets: {
|
|
973
973
|
type: "object",
|
|
974
974
|
description: "Facet definitions keyed by facet name. Each value is " \
|
|
975
975
|
"{ type: 'string'|'number'|'date', path: <field>, boundaries?: [...] }. " \
|
|
976
976
|
"Paths must be in agent_fields allowlist when one is declared.",
|
|
977
977
|
},
|
|
978
|
-
limit:
|
|
978
|
+
limit: {
|
|
979
979
|
type: "integer",
|
|
980
980
|
description: "Optional. Max documents in the result list (NOT bucket counts), default 10, max 20.",
|
|
981
981
|
},
|
|
982
982
|
apply_canonical_filter: { type: "boolean",
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
983
|
+
description: "Default true. When the class declares an " \
|
|
984
|
+
"agent_canonical_filter, this tool refuses by default. " \
|
|
985
|
+
"Pass false to acknowledge that $searchMeta bucket counts " \
|
|
986
|
+
"WILL include rows the canonical filter normally hides." },
|
|
987
987
|
},
|
|
988
988
|
required: %w[class_name facets],
|
|
989
989
|
},
|
|
990
990
|
output_schema: {
|
|
991
991
|
type: "object",
|
|
992
992
|
properties: {
|
|
993
|
-
class_name:
|
|
993
|
+
class_name: { type: "string" },
|
|
994
994
|
# $searchMeta lower-bound count. May be approximate for very
|
|
995
995
|
# large corpora — Atlas documents this; downstream clients
|
|
996
996
|
# should treat it as informative, not a precise total.
|
|
@@ -1013,7 +1013,7 @@ module Parse
|
|
|
1013
1013
|
required: %w[buckets],
|
|
1014
1014
|
},
|
|
1015
1015
|
},
|
|
1016
|
-
count:
|
|
1016
|
+
count: { type: "integer", minimum: 0 },
|
|
1017
1017
|
results: {
|
|
1018
1018
|
type: "array",
|
|
1019
1019
|
items: { type: "object", additionalProperties: true },
|
|
@@ -1030,12 +1030,12 @@ module Parse
|
|
|
1030
1030
|
# `category:` value via `Tools.register(category: "...")`; the
|
|
1031
1031
|
# default for un-categorized registrations is "custom".
|
|
1032
1032
|
BUILTIN_CATEGORIES = {
|
|
1033
|
-
"schema"
|
|
1034
|
-
"query"
|
|
1033
|
+
"schema" => "Class introspection — discover available classes, fields, indexes, and permissions.",
|
|
1034
|
+
"query" => "Read-only data access — fetch records, counts, samples, and execution plans.",
|
|
1035
1035
|
"aggregate" => "MongoDB aggregation pipelines for grouping, statistics, and joins.",
|
|
1036
|
-
"mutation"
|
|
1037
|
-
"export"
|
|
1038
|
-
"custom"
|
|
1036
|
+
"mutation" => "Domain-action methods declared via agent_method.",
|
|
1037
|
+
"export" => "Bulk data export in CSV, Markdown, or fixed-width text.",
|
|
1038
|
+
"custom" => "Application-registered tools not assigned to a built-in category.",
|
|
1039
1039
|
}.freeze
|
|
1040
1040
|
|
|
1041
1041
|
# ============================================================
|
|
@@ -1173,12 +1173,12 @@ module Parse
|
|
|
1173
1173
|
|
|
1174
1174
|
REGISTRY_MUTEX.synchronize do
|
|
1175
1175
|
@registry[sym] = {
|
|
1176
|
-
definition:
|
|
1177
|
-
permission:
|
|
1178
|
-
timeout:
|
|
1179
|
-
handler:
|
|
1176
|
+
definition: definition,
|
|
1177
|
+
permission: permission,
|
|
1178
|
+
timeout: timeout.to_i,
|
|
1179
|
+
handler: handler,
|
|
1180
1180
|
output_schema: output_schema,
|
|
1181
|
-
client_safe:
|
|
1181
|
+
client_safe: client_safe == true,
|
|
1182
1182
|
}
|
|
1183
1183
|
end
|
|
1184
1184
|
notify_subscribers
|
|
@@ -1496,7 +1496,7 @@ module Parse
|
|
|
1496
1496
|
# leading-substring filter. Both nil/empty means no filter.
|
|
1497
1497
|
if names.respond_to?(:any?) && names.any?
|
|
1498
1498
|
name_set = names.map(&:to_s)
|
|
1499
|
-
schemas
|
|
1499
|
+
schemas = schemas.select { |s| name_set.include?(s["className"]) }
|
|
1500
1500
|
end
|
|
1501
1501
|
if prefix.is_a?(String) && !prefix.empty?
|
|
1502
1502
|
schemas = schemas.select { |s| s["className"].to_s.start_with?(prefix) }
|
|
@@ -1610,7 +1610,7 @@ module Parse
|
|
|
1610
1610
|
raise Parse::Agent::AccessDenied.new(
|
|
1611
1611
|
class_name,
|
|
1612
1612
|
"Class '#{class_name}' is outside this agent's classes: allowlist",
|
|
1613
|
-
kind: :class_filter
|
|
1613
|
+
kind: :class_filter,
|
|
1614
1614
|
)
|
|
1615
1615
|
end
|
|
1616
1616
|
|
|
@@ -1633,6 +1633,7 @@ module Parse
|
|
|
1633
1633
|
end
|
|
1634
1634
|
end
|
|
1635
1635
|
end
|
|
1636
|
+
|
|
1636
1637
|
module_function :assert_class_accessible!
|
|
1637
1638
|
|
|
1638
1639
|
# NEW-TOOLS-9: validate an object_id argument format. objectIds in Parse
|
|
@@ -1648,6 +1649,7 @@ module Parse
|
|
|
1648
1649
|
"Must be 1-32 characters of letters, digits, hyphens, or underscores."
|
|
1649
1650
|
end
|
|
1650
1651
|
end
|
|
1652
|
+
|
|
1651
1653
|
module_function :assert_object_id!
|
|
1652
1654
|
|
|
1653
1655
|
# NEW-TOOLS-9: validate a method_name argument format.
|
|
@@ -1662,6 +1664,7 @@ module Parse
|
|
|
1662
1664
|
"letters, digits, and underscores (max 128 chars, optional !/?/= suffix)."
|
|
1663
1665
|
end
|
|
1664
1666
|
end
|
|
1667
|
+
|
|
1665
1668
|
module_function :assert_method_name!
|
|
1666
1669
|
|
|
1667
1670
|
# Resolve the effective tenant scope for a class and agent.
|
|
@@ -1678,6 +1681,7 @@ module Parse
|
|
|
1678
1681
|
def resolve_tenant_scope!(agent, class_name)
|
|
1679
1682
|
Parse::Agent::MetadataRegistry.resolve_tenant_scope(class_name, agent)
|
|
1680
1683
|
end
|
|
1684
|
+
|
|
1681
1685
|
module_function :resolve_tenant_scope!
|
|
1682
1686
|
|
|
1683
1687
|
# Merge tenant scope into a caller-supplied `where:` hash (or nil).
|
|
@@ -1697,8 +1701,8 @@ module Parse
|
|
|
1697
1701
|
# @raise [Parse::Agent::AccessDenied]
|
|
1698
1702
|
def apply_tenant_scope_to_where(where, scope, class_name)
|
|
1699
1703
|
return where unless scope
|
|
1700
|
-
field
|
|
1701
|
-
value
|
|
1704
|
+
field = scope[:field]
|
|
1705
|
+
value = scope[:value]
|
|
1702
1706
|
field_str = field.to_s
|
|
1703
1707
|
field_sym = field.to_sym
|
|
1704
1708
|
# Also check the camelCase wire form (e.g. org_id -> orgId) because an LLM
|
|
@@ -1712,10 +1716,10 @@ module Parse
|
|
|
1712
1716
|
|
|
1713
1717
|
where_h = (where || {})
|
|
1714
1718
|
# Collect candidate values from all four key forms (snake str/sym, camel str/sym).
|
|
1715
|
-
candidate_keys
|
|
1716
|
-
present_keys
|
|
1717
|
-
caller_value
|
|
1718
|
-
field_present
|
|
1719
|
+
candidate_keys = [field_str, field_sym, camel_str, camel_sym]
|
|
1720
|
+
present_keys = candidate_keys.select { |k| where_h.key?(k) }
|
|
1721
|
+
caller_value = present_keys.any? ? where_h[present_keys.first] : nil
|
|
1722
|
+
field_present = present_keys.any?
|
|
1719
1723
|
|
|
1720
1724
|
if !field_present
|
|
1721
1725
|
# Case 1: field absent in any form — inject using snake_case string key.
|
|
@@ -1731,6 +1735,7 @@ module Parse
|
|
|
1731
1735
|
)
|
|
1732
1736
|
end
|
|
1733
1737
|
end
|
|
1738
|
+
|
|
1734
1739
|
module_function :apply_tenant_scope_to_where
|
|
1735
1740
|
|
|
1736
1741
|
# Prepend a $match tenant scope stage at index 0 of an aggregation pipeline.
|
|
@@ -1747,10 +1752,11 @@ module Parse
|
|
|
1747
1752
|
# as Parse Server's stored field names). Camelize the scope field so the
|
|
1748
1753
|
# prepended $match stage is structurally equivalent to what the LLM would
|
|
1749
1754
|
# write when querying by the field directly.
|
|
1750
|
-
field
|
|
1751
|
-
wire_key
|
|
1755
|
+
field = scope[:field].to_s
|
|
1756
|
+
wire_key = field.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
|
|
1752
1757
|
[{ "$match" => { wire_key => scope[:value] } }] + pipeline
|
|
1753
1758
|
end
|
|
1759
|
+
|
|
1754
1760
|
module_function :apply_tenant_scope_to_pipeline
|
|
1755
1761
|
|
|
1756
1762
|
# Default-deny joins under an active tenant scope. A field-based
|
|
@@ -1787,6 +1793,7 @@ module Parse
|
|
|
1787
1793
|
)
|
|
1788
1794
|
end
|
|
1789
1795
|
end
|
|
1796
|
+
|
|
1790
1797
|
module_function :assert_joins_tenant_safe!
|
|
1791
1798
|
|
|
1792
1799
|
# Yield each join-target collection name found in `$lookup` /
|
|
@@ -1824,6 +1831,7 @@ module Parse
|
|
|
1824
1831
|
end
|
|
1825
1832
|
end
|
|
1826
1833
|
end
|
|
1834
|
+
|
|
1827
1835
|
module_function :each_join_target
|
|
1828
1836
|
|
|
1829
1837
|
# === Per-agent filter and canonical filter helpers ===
|
|
@@ -1863,6 +1871,7 @@ module Parse
|
|
|
1863
1871
|
per_agent = agent && agent.respond_to?(:filter_for) ? agent.filter_for(class_name) : nil
|
|
1864
1872
|
compose_filter_into_where(where, per_agent, class_name, helper_name: "apply_per_agent_filter_to_where")
|
|
1865
1873
|
end
|
|
1874
|
+
|
|
1866
1875
|
module_function :apply_per_agent_filter_to_where
|
|
1867
1876
|
|
|
1868
1877
|
# Merge the class's canonical "valid state" filter (declared via
|
|
@@ -1881,6 +1890,7 @@ module Parse
|
|
|
1881
1890
|
canonical = Parse::Agent::MetadataRegistry.canonical_filter(class_name)
|
|
1882
1891
|
compose_filter_into_where(where, canonical, class_name, helper_name: "apply_canonical_filter_to_where")
|
|
1883
1892
|
end
|
|
1893
|
+
|
|
1884
1894
|
module_function :apply_canonical_filter_to_where
|
|
1885
1895
|
|
|
1886
1896
|
# @api private
|
|
@@ -1908,6 +1918,7 @@ module Parse
|
|
|
1908
1918
|
"got #{where.class}"
|
|
1909
1919
|
end
|
|
1910
1920
|
end
|
|
1921
|
+
|
|
1911
1922
|
module_function :compose_filter_into_where
|
|
1912
1923
|
|
|
1913
1924
|
# Prepend the per-agent per-class filter (declared via
|
|
@@ -1919,6 +1930,7 @@ module Parse
|
|
|
1919
1930
|
per_agent = agent && agent.respond_to?(:filter_for) ? agent.filter_for(class_name) : nil
|
|
1920
1931
|
compose_filter_into_pipeline(pipeline, per_agent)
|
|
1921
1932
|
end
|
|
1933
|
+
|
|
1922
1934
|
module_function :apply_per_agent_filter_to_pipeline
|
|
1923
1935
|
|
|
1924
1936
|
# Prepend the class's canonical filter as a `$match` stage.
|
|
@@ -1930,6 +1942,7 @@ module Parse
|
|
|
1930
1942
|
canonical = Parse::Agent::MetadataRegistry.canonical_filter(class_name)
|
|
1931
1943
|
compose_filter_into_pipeline(pipeline, canonical)
|
|
1932
1944
|
end
|
|
1945
|
+
|
|
1933
1946
|
module_function :apply_canonical_filter_to_pipeline
|
|
1934
1947
|
|
|
1935
1948
|
# @api private
|
|
@@ -1945,6 +1958,7 @@ module Parse
|
|
|
1945
1958
|
[match_stage] + pipeline
|
|
1946
1959
|
end
|
|
1947
1960
|
end
|
|
1961
|
+
|
|
1948
1962
|
module_function :compose_filter_into_pipeline
|
|
1949
1963
|
|
|
1950
1964
|
# Verify that a fetched record's scope field matches the bound scope value.
|
|
@@ -1956,8 +1970,8 @@ module Parse
|
|
|
1956
1970
|
# @raise [Parse::Agent::AccessDenied]
|
|
1957
1971
|
def assert_record_in_tenant_scope!(record, scope, class_name)
|
|
1958
1972
|
return unless scope
|
|
1959
|
-
field
|
|
1960
|
-
value
|
|
1973
|
+
field = scope[:field].to_s
|
|
1974
|
+
value = scope[:value]
|
|
1961
1975
|
# Parse Server returns camelCase field names on the wire (e.g. orgId for
|
|
1962
1976
|
# the Ruby field org_id). A mongo-direct hit (semantic_search's raw
|
|
1963
1977
|
# $vectorSearch path) instead carries the field under its STORAGE column
|
|
@@ -1972,9 +1986,9 @@ module Parse
|
|
|
1972
1986
|
camel_field = field.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
|
|
1973
1987
|
# Assign unconditionally (the modifier-if is the RHS, yielding nil when
|
|
1974
1988
|
# false) so neither local is ever read before initialization.
|
|
1975
|
-
klass
|
|
1976
|
-
mapped
|
|
1977
|
-
rec_value
|
|
1989
|
+
klass = (Parse::Model.find_class(class_name) if defined?(Parse::Model))
|
|
1990
|
+
mapped = (klass.field_map[field.to_sym].to_s if klass.respond_to?(:field_map))
|
|
1991
|
+
rec_value = if record.is_a?(Hash)
|
|
1978
1992
|
keys = [field, camel_field]
|
|
1979
1993
|
keys << mapped if mapped && !mapped.empty?
|
|
1980
1994
|
found = keys.find { |k| record.key?(k) }
|
|
@@ -1987,6 +2001,7 @@ module Parse
|
|
|
1987
2001
|
)
|
|
1988
2002
|
end
|
|
1989
2003
|
end
|
|
2004
|
+
|
|
1990
2005
|
module_function :assert_record_in_tenant_scope!
|
|
1991
2006
|
|
|
1992
2007
|
# Walk an aggregation pipeline and enforce two boundaries that
|
|
@@ -2027,6 +2042,7 @@ module Parse
|
|
|
2027
2042
|
agent: agent,
|
|
2028
2043
|
)
|
|
2029
2044
|
end
|
|
2045
|
+
|
|
2030
2046
|
module_function :enforce_pipeline_access_policy!
|
|
2031
2047
|
|
|
2032
2048
|
# @api private
|
|
@@ -2039,6 +2055,7 @@ module Parse
|
|
|
2039
2055
|
return nil unless allowlist && allowlist.any?
|
|
2040
2056
|
allowlist.map(&:to_s) | Parse::Agent::MetadataRegistry::ALWAYS_KEEP_FIELDS
|
|
2041
2057
|
end
|
|
2058
|
+
|
|
2042
2059
|
module_function :compute_source_allowlist_for
|
|
2043
2060
|
|
|
2044
2061
|
# @api private
|
|
@@ -2072,6 +2089,7 @@ module Parse
|
|
|
2072
2089
|
end
|
|
2073
2090
|
end
|
|
2074
2091
|
end
|
|
2092
|
+
|
|
2075
2093
|
module_function :walk_pipeline_with_state!
|
|
2076
2094
|
|
|
2077
2095
|
# @api private
|
|
@@ -2087,6 +2105,7 @@ module Parse
|
|
|
2087
2105
|
set |= available
|
|
2088
2106
|
set
|
|
2089
2107
|
end
|
|
2108
|
+
|
|
2090
2109
|
module_function :effective_permitted_set
|
|
2091
2110
|
|
|
2092
2111
|
# @api private
|
|
@@ -2185,6 +2204,7 @@ module Parse
|
|
|
2185
2204
|
[[], false]
|
|
2186
2205
|
end
|
|
2187
2206
|
end
|
|
2207
|
+
|
|
2188
2208
|
module_function :stage_field_delta
|
|
2189
2209
|
|
|
2190
2210
|
# @api private
|
|
@@ -2193,6 +2213,7 @@ module Parse
|
|
|
2193
2213
|
def keys_excluding_operators(hash)
|
|
2194
2214
|
hash.keys.map(&:to_s).reject { |k| k.empty? || k.start_with?("$") }
|
|
2195
2215
|
end
|
|
2216
|
+
|
|
2196
2217
|
module_function :keys_excluding_operators
|
|
2197
2218
|
|
|
2198
2219
|
# @api private
|
|
@@ -2203,18 +2224,21 @@ module Parse
|
|
|
2203
2224
|
def root_keys_excluding_operators(hash)
|
|
2204
2225
|
keys_excluding_operators(hash).map { |k| k.split(".").first }.uniq
|
|
2205
2226
|
end
|
|
2227
|
+
|
|
2206
2228
|
module_function :root_keys_excluding_operators
|
|
2207
2229
|
|
|
2208
2230
|
# @api private
|
|
2209
2231
|
def projection_is_inclusion?(expr)
|
|
2210
2232
|
expr == 1 || expr == true
|
|
2211
2233
|
end
|
|
2234
|
+
|
|
2212
2235
|
module_function :projection_is_inclusion?
|
|
2213
2236
|
|
|
2214
2237
|
# @api private
|
|
2215
2238
|
def projection_is_exclusion?(expr)
|
|
2216
2239
|
expr == 0 || expr == false
|
|
2217
2240
|
end
|
|
2241
|
+
|
|
2218
2242
|
module_function :projection_is_exclusion?
|
|
2219
2243
|
|
|
2220
2244
|
# @api private
|
|
@@ -2225,6 +2249,7 @@ module Parse
|
|
|
2225
2249
|
def project_is_exclusion_only?(value)
|
|
2226
2250
|
value.any? && value.values.all? { |v| projection_is_exclusion?(v) }
|
|
2227
2251
|
end
|
|
2252
|
+
|
|
2228
2253
|
module_function :project_is_exclusion_only?
|
|
2229
2254
|
|
|
2230
2255
|
# @api private
|
|
@@ -2236,6 +2261,7 @@ module Parse
|
|
|
2236
2261
|
included = value.reject { |_, v| projection_is_exclusion?(v) }
|
|
2237
2262
|
root_keys_excluding_operators(included)
|
|
2238
2263
|
end
|
|
2264
|
+
|
|
2239
2265
|
module_function :project_introduced_roots
|
|
2240
2266
|
|
|
2241
2267
|
# @api private
|
|
@@ -2255,6 +2281,7 @@ module Parse
|
|
|
2255
2281
|
)
|
|
2256
2282
|
end
|
|
2257
2283
|
end
|
|
2284
|
+
|
|
2258
2285
|
module_function :assert_output_key_not_internal!
|
|
2259
2286
|
|
|
2260
2287
|
# @api private
|
|
@@ -2269,8 +2296,8 @@ module Parse
|
|
|
2269
2296
|
# that pass only `permitted_fields:` (the existing single-stage
|
|
2270
2297
|
# test surface).
|
|
2271
2298
|
def walk_pipeline_stage!(stage, permitted_fields:, agent: nil,
|
|
2272
|
-
|
|
2273
|
-
|
|
2299
|
+
source_permitted: nil, available: [],
|
|
2300
|
+
source_addressable: true)
|
|
2274
2301
|
return unless stage.is_a?(Hash)
|
|
2275
2302
|
stage.each do |op, value|
|
|
2276
2303
|
case op.to_s
|
|
@@ -2279,8 +2306,7 @@ module Parse
|
|
|
2279
2306
|
# `{ "$unionWith" => "Collection" }`. Extract that target too,
|
|
2280
2307
|
# otherwise the underscore denylist, hidden?, class-filter
|
|
2281
2308
|
# allowlist, and CLP-find gates below silently skip it.
|
|
2282
|
-
target =
|
|
2283
|
-
if value.is_a?(Hash)
|
|
2309
|
+
target = if value.is_a?(Hash)
|
|
2284
2310
|
value["from"] || value[:from] || value["coll"] || value[:coll]
|
|
2285
2311
|
elsif value.is_a?(String)
|
|
2286
2312
|
value
|
|
@@ -2310,7 +2336,7 @@ module Parse
|
|
|
2310
2336
|
raise Parse::Agent::AccessDenied.new(
|
|
2311
2337
|
target_str,
|
|
2312
2338
|
"Pipeline target '#{target_str}' is outside this agent's classes: allowlist",
|
|
2313
|
-
kind: :class_filter
|
|
2339
|
+
kind: :class_filter,
|
|
2314
2340
|
)
|
|
2315
2341
|
end
|
|
2316
2342
|
# CLP gate for joined classes. A $lookup / $graphLookup /
|
|
@@ -2474,6 +2500,7 @@ module Parse
|
|
|
2474
2500
|
end
|
|
2475
2501
|
end
|
|
2476
2502
|
end
|
|
2503
|
+
|
|
2477
2504
|
module_function :walk_pipeline_stage!
|
|
2478
2505
|
|
|
2479
2506
|
# @api private
|
|
@@ -2505,6 +2532,7 @@ module Parse
|
|
|
2505
2532
|
end
|
|
2506
2533
|
end
|
|
2507
2534
|
end
|
|
2535
|
+
|
|
2508
2536
|
module_function :check_match_keys_for_restricted_fields!
|
|
2509
2537
|
|
|
2510
2538
|
# @api private
|
|
@@ -2526,6 +2554,7 @@ module Parse
|
|
|
2526
2554
|
expr.each_value { |v| check_expression_for_restricted_fields!(v, permitted_fields) }
|
|
2527
2555
|
end
|
|
2528
2556
|
end
|
|
2557
|
+
|
|
2529
2558
|
module_function :check_expression_for_restricted_fields!
|
|
2530
2559
|
|
|
2531
2560
|
# @api private
|
|
@@ -2551,7 +2580,7 @@ module Parse
|
|
|
2551
2580
|
msg = +"#{context} '#{fname}' (#{root.inspect}) outside agent_fields allowlist"
|
|
2552
2581
|
if permitted_fields.is_a?(Array) && permitted_fields.any?
|
|
2553
2582
|
preview = permitted_fields.first(ALLOWLIST_PREVIEW_CAP)
|
|
2554
|
-
suffix
|
|
2583
|
+
suffix = permitted_fields.size > ALLOWLIST_PREVIEW_CAP ?
|
|
2555
2584
|
" (+#{permitted_fields.size - ALLOWLIST_PREVIEW_CAP} more)" : ""
|
|
2556
2585
|
msg << ". Allowed: #{preview.join(", ")}#{suffix}"
|
|
2557
2586
|
end
|
|
@@ -2568,13 +2597,14 @@ module Parse
|
|
|
2568
2597
|
end
|
|
2569
2598
|
end
|
|
2570
2599
|
{
|
|
2571
|
-
message:
|
|
2572
|
-
kind:
|
|
2573
|
-
denied_field:
|
|
2574
|
-
allowed_fields:
|
|
2600
|
+
message: msg,
|
|
2601
|
+
kind: kind,
|
|
2602
|
+
denied_field: root.is_a?(String) ? root : root.to_s,
|
|
2603
|
+
allowed_fields: permitted_fields.is_a?(Array) ? permitted_fields.first(ALLOWLIST_PREVIEW_CAP).map(&:to_s) : nil,
|
|
2575
2604
|
suggested_rewrite: suggested,
|
|
2576
2605
|
}
|
|
2577
2606
|
end
|
|
2607
|
+
|
|
2578
2608
|
module_function :build_allowlist_refusal
|
|
2579
2609
|
|
|
2580
2610
|
# @api private
|
|
@@ -2585,12 +2615,13 @@ module Parse
|
|
|
2585
2615
|
info = build_allowlist_refusal(context, fname, root, permitted_fields)
|
|
2586
2616
|
raise Parse::Agent::AccessDenied.new(
|
|
2587
2617
|
nil, info[:message],
|
|
2588
|
-
kind:
|
|
2589
|
-
denied_field:
|
|
2590
|
-
allowed_fields:
|
|
2618
|
+
kind: info[:kind],
|
|
2619
|
+
denied_field: info[:denied_field],
|
|
2620
|
+
allowed_fields: info[:allowed_fields],
|
|
2591
2621
|
suggested_rewrite: info[:suggested_rewrite],
|
|
2592
2622
|
)
|
|
2593
2623
|
end
|
|
2624
|
+
|
|
2594
2625
|
module_function :raise_allowlist_refusal!
|
|
2595
2626
|
|
|
2596
2627
|
# Resolve each dotted `include:` path through belongs_to / has_one
|
|
@@ -2613,6 +2644,7 @@ module Parse
|
|
|
2613
2644
|
walk_pointer_path!(klass, path.to_s.split("."), agent: agent)
|
|
2614
2645
|
end
|
|
2615
2646
|
end
|
|
2647
|
+
|
|
2616
2648
|
module_function :assert_include_paths_accessible!
|
|
2617
2649
|
|
|
2618
2650
|
# Auto-projection for `keys: + include:`. When the caller passed a
|
|
@@ -2709,6 +2741,7 @@ module Parse
|
|
|
2709
2741
|
effective = (keys_str | appended)
|
|
2710
2742
|
{ effective_keys: effective, truncated: truncated }
|
|
2711
2743
|
end
|
|
2744
|
+
|
|
2712
2745
|
module_function :apply_include_projection
|
|
2713
2746
|
|
|
2714
2747
|
# @api private
|
|
@@ -2718,11 +2751,12 @@ module Parse
|
|
|
2718
2751
|
def resolve_pointer_target(parent_klass, pointer_field)
|
|
2719
2752
|
refs = parent_klass.references
|
|
2720
2753
|
return nil unless refs
|
|
2721
|
-
seg_str
|
|
2754
|
+
seg_str = pointer_field.to_s
|
|
2722
2755
|
camel_str = seg_str.include?("_") ? seg_str.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } : seg_str
|
|
2723
2756
|
target = refs[seg_str.to_sym] || refs[seg_str] || refs[camel_str.to_sym] || refs[camel_str]
|
|
2724
2757
|
target&.to_s
|
|
2725
2758
|
end
|
|
2759
|
+
|
|
2726
2760
|
module_function :resolve_pointer_target
|
|
2727
2761
|
|
|
2728
2762
|
# @api private
|
|
@@ -2734,7 +2768,7 @@ module Parse
|
|
|
2734
2768
|
# `references` is keyed by the Parse field name (camelCase). Accept
|
|
2735
2769
|
# both forms: snake_case as Ruby methods are usually named, and
|
|
2736
2770
|
# camelCase as it appears on the wire and in the schema.
|
|
2737
|
-
seg_str
|
|
2771
|
+
seg_str = seg.to_s
|
|
2738
2772
|
camel_str = seg_str.include?("_") ? seg_str.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } : seg_str
|
|
2739
2773
|
target = refs[seg_str.to_sym] || refs[seg_str] || refs[camel_str.to_sym] || refs[camel_str]
|
|
2740
2774
|
return unless target
|
|
@@ -2754,7 +2788,7 @@ module Parse
|
|
|
2754
2788
|
raise Parse::Agent::AccessDenied.new(
|
|
2755
2789
|
target_name,
|
|
2756
2790
|
"Pointer-include target '#{target_name}' is outside this agent's classes: allowlist",
|
|
2757
|
-
kind: :class_filter
|
|
2791
|
+
kind: :class_filter,
|
|
2758
2792
|
)
|
|
2759
2793
|
end
|
|
2760
2794
|
current = begin
|
|
@@ -2765,6 +2799,7 @@ module Parse
|
|
|
2765
2799
|
return unless current
|
|
2766
2800
|
end
|
|
2767
2801
|
end
|
|
2802
|
+
|
|
2768
2803
|
module_function :walk_pointer_path!
|
|
2769
2804
|
|
|
2770
2805
|
# Post-fetch defense-in-depth: walk the result data and replace any
|
|
@@ -2792,6 +2827,7 @@ module Parse
|
|
|
2792
2827
|
# rows. The walker scrubs them post-fetch.
|
|
2793
2828
|
walk_and_redact(data, hidden, agent: agent)
|
|
2794
2829
|
end
|
|
2830
|
+
|
|
2795
2831
|
module_function :redact_hidden_classes!
|
|
2796
2832
|
|
|
2797
2833
|
# Parse-on-Mongo pointer column shape: a string value paired with a
|
|
@@ -2863,6 +2899,7 @@ module Parse
|
|
|
2863
2899
|
obj
|
|
2864
2900
|
end
|
|
2865
2901
|
end
|
|
2902
|
+
|
|
2866
2903
|
module_function :walk_and_redact
|
|
2867
2904
|
|
|
2868
2905
|
# Compact Parse-on-Mongo storage-form pointer columns.
|
|
@@ -2923,6 +2960,7 @@ module Parse
|
|
|
2923
2960
|
rewrite_pointer_columns!(data, compressible)
|
|
2924
2961
|
compressible
|
|
2925
2962
|
end
|
|
2963
|
+
|
|
2926
2964
|
module_function :compact_pointers!
|
|
2927
2965
|
|
|
2928
2966
|
# @api private
|
|
@@ -2971,6 +3009,7 @@ module Parse
|
|
|
2971
3009
|
obj.each { |v| scan_for_pointer_columns(v, acc) }
|
|
2972
3010
|
end
|
|
2973
3011
|
end
|
|
3012
|
+
|
|
2974
3013
|
module_function :scan_for_pointer_columns
|
|
2975
3014
|
|
|
2976
3015
|
# @api private
|
|
@@ -3006,6 +3045,7 @@ module Parse
|
|
|
3006
3045
|
obj.each { |v| rewrite_pointer_columns!(v, compressible) }
|
|
3007
3046
|
end
|
|
3008
3047
|
end
|
|
3048
|
+
|
|
3009
3049
|
module_function :rewrite_pointer_columns!
|
|
3010
3050
|
|
|
3011
3051
|
# Discovery: return a lightweight catalog of every tool this agent
|
|
@@ -3023,13 +3063,13 @@ module Parse
|
|
|
3023
3063
|
rows = defs.map do |entry|
|
|
3024
3064
|
fn = entry[:function] || entry
|
|
3025
3065
|
{
|
|
3026
|
-
name:
|
|
3027
|
-
category:
|
|
3066
|
+
name: fn[:name],
|
|
3067
|
+
category: fn[:category] || "custom",
|
|
3028
3068
|
description: fn[:description],
|
|
3029
3069
|
}
|
|
3030
3070
|
end
|
|
3031
3071
|
{
|
|
3032
|
-
tools:
|
|
3072
|
+
tools: rows,
|
|
3033
3073
|
categories: BUILTIN_CATEGORIES,
|
|
3034
3074
|
}
|
|
3035
3075
|
end
|
|
@@ -3080,6 +3120,7 @@ module Parse
|
|
|
3080
3120
|
hidden = reg.respond_to?(:hidden_class_names) ? Array(reg.hidden_class_names) : []
|
|
3081
3121
|
names.compact.map(&:to_s).uniq - hidden.map(&:to_s)
|
|
3082
3122
|
end
|
|
3123
|
+
|
|
3083
3124
|
module_function :known_class_names_for_suggestions
|
|
3084
3125
|
|
|
3085
3126
|
# Up to `limit` known class names within a small edit distance of the
|
|
@@ -3096,6 +3137,7 @@ module Parse
|
|
|
3096
3137
|
.first(limit)
|
|
3097
3138
|
.map(&:first)
|
|
3098
3139
|
end
|
|
3140
|
+
|
|
3099
3141
|
module_function :suggest_class_names
|
|
3100
3142
|
|
|
3101
3143
|
# Compact iterative Levenshtein distance.
|
|
@@ -3113,6 +3155,7 @@ module Parse
|
|
|
3113
3155
|
end
|
|
3114
3156
|
prev[b.length]
|
|
3115
3157
|
end
|
|
3158
|
+
|
|
3116
3159
|
module_function :name_edit_distance
|
|
3117
3160
|
|
|
3118
3161
|
# ============================================================
|
|
@@ -3148,7 +3191,7 @@ module Parse
|
|
|
3148
3191
|
# the effective where (with scope injected) is what everything else sees.
|
|
3149
3192
|
# TRACK-AGENT-7 split: per-agent filter is UNCONDITIONAL; canonical
|
|
3150
3193
|
# filter remains LLM-controllable via apply_canonical_filter:.
|
|
3151
|
-
scope
|
|
3194
|
+
scope = resolve_tenant_scope!(agent, class_name)
|
|
3152
3195
|
effective_where = apply_tenant_scope_to_where(where, scope, class_name)
|
|
3153
3196
|
effective_where = apply_per_agent_filter_to_where(effective_where, class_name, agent: agent)
|
|
3154
3197
|
effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent) if apply_canonical_filter
|
|
@@ -3159,7 +3202,6 @@ module Parse
|
|
|
3159
3202
|
if effective_where && !effective_where.empty? &&
|
|
3160
3203
|
Parse::Agent.refuse_collscan? &&
|
|
3161
3204
|
!MetadataRegistry.allow_collscan?(class_name)
|
|
3162
|
-
|
|
3163
3205
|
refusal = collscan_preflight(agent, class_name, effective_where)
|
|
3164
3206
|
return refusal if refusal
|
|
3165
3207
|
end
|
|
@@ -3184,8 +3226,7 @@ module Parse
|
|
|
3184
3226
|
validated_keys = validate_keys!(keys)
|
|
3185
3227
|
allowlist = MetadataRegistry.field_allowlist(class_name)
|
|
3186
3228
|
caller_keys = validated_keys&.any? ? validated_keys : nil
|
|
3187
|
-
effective_keys =
|
|
3188
|
-
if allowlist && allowlist.any?
|
|
3229
|
+
effective_keys = if allowlist && allowlist.any?
|
|
3189
3230
|
permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
|
|
3190
3231
|
caller_keys ? (caller_keys & permitted) : allowlist.map(&:to_s)
|
|
3191
3232
|
else
|
|
@@ -3228,8 +3269,7 @@ module Parse
|
|
|
3228
3269
|
end
|
|
3229
3270
|
|
|
3230
3271
|
with_timeout(:query_class) do
|
|
3231
|
-
results =
|
|
3232
|
-
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3272
|
+
results = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3233
3273
|
# Auto-route through Parse::MongoDB.aggregate so ACLScope's
|
|
3234
3274
|
# `_rperm` $match injection runs — REST find_objects has
|
|
3235
3275
|
# no "act as role" affordance for acl_user/acl_role agents.
|
|
@@ -3275,23 +3315,24 @@ module Parse
|
|
|
3275
3315
|
# standard text-export envelope shape.
|
|
3276
3316
|
def format_query_results_as(format, class_name, results)
|
|
3277
3317
|
col_specs = results.any? ? infer_export_columns_from(results.first) : []
|
|
3278
|
-
headers
|
|
3279
|
-
rows
|
|
3318
|
+
headers = col_specs.map { |s| s[:header] }
|
|
3319
|
+
rows = results.map do |obj|
|
|
3280
3320
|
col_specs.map { |s| stringify_export_value(extract_export_value(obj, s[:path])) }
|
|
3281
3321
|
end
|
|
3282
3322
|
output = case format
|
|
3283
|
-
when "csv"
|
|
3323
|
+
when "csv" then format_export_csv(headers, rows)
|
|
3284
3324
|
when "markdown" then format_export_markdown(headers, rows)
|
|
3285
|
-
when "table"
|
|
3325
|
+
when "table" then format_export_text_table(headers, rows)
|
|
3286
3326
|
end
|
|
3287
3327
|
{
|
|
3288
3328
|
class_name: class_name,
|
|
3289
|
-
format:
|
|
3290
|
-
headers:
|
|
3291
|
-
row_count:
|
|
3292
|
-
output:
|
|
3329
|
+
format: format,
|
|
3330
|
+
headers: headers,
|
|
3331
|
+
row_count: rows.size,
|
|
3332
|
+
output: output,
|
|
3293
3333
|
}
|
|
3294
3334
|
end
|
|
3335
|
+
|
|
3295
3336
|
module_function :format_query_results_as
|
|
3296
3337
|
|
|
3297
3338
|
# Count objects in a Parse class
|
|
@@ -3304,7 +3345,7 @@ module Parse
|
|
|
3304
3345
|
assert_class_accessible!(class_name, agent: agent, op: :count)
|
|
3305
3346
|
# Tenant scope enforcement. TRACK-AGENT-7 split: per-agent filter is
|
|
3306
3347
|
# UNCONDITIONAL, canonical filter is LLM-controllable.
|
|
3307
|
-
scope
|
|
3348
|
+
scope = resolve_tenant_scope!(agent, class_name)
|
|
3308
3349
|
effective_where = apply_tenant_scope_to_where(where, scope, class_name)
|
|
3309
3350
|
effective_where = apply_per_agent_filter_to_where(effective_where, class_name, agent: agent)
|
|
3310
3351
|
effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent) if apply_canonical_filter
|
|
@@ -3317,8 +3358,7 @@ module Parse
|
|
|
3317
3358
|
query[:where] = translated_where.to_json
|
|
3318
3359
|
end
|
|
3319
3360
|
|
|
3320
|
-
count =
|
|
3321
|
-
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3361
|
+
count = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3322
3362
|
execute_count_via_direct(agent, class_name, where: translated_where)
|
|
3323
3363
|
else
|
|
3324
3364
|
response = agent.client.find_objects(class_name, query, **agent.request_opts)
|
|
@@ -3344,7 +3384,7 @@ module Parse
|
|
|
3344
3384
|
# @return [Hash] the object data
|
|
3345
3385
|
# @raise [Parse::Agent::ValidationError] for invalid class_name or object_id
|
|
3346
3386
|
def get_object(agent, class_name:, object_id:, include: nil,
|
|
3347
|
-
|
|
3387
|
+
apply_canonical_filter: true, **_kwargs)
|
|
3348
3388
|
assert_class_accessible!(class_name, agent: agent, op: :get)
|
|
3349
3389
|
assert_object_id!(object_id)
|
|
3350
3390
|
# Resolve tenant scope early so we can verify after fetch.
|
|
@@ -3395,15 +3435,13 @@ module Parse
|
|
|
3395
3435
|
# Compose the objectId match into the filter; a hit returns exactly
|
|
3396
3436
|
# one row, a filtered-out match returns zero rows (treated as not-
|
|
3397
3437
|
# found below, identical to the genuine missing-row case).
|
|
3398
|
-
combined_where =
|
|
3399
|
-
if composed_filter.is_a?(Hash) && composed_filter.key?("$and")
|
|
3438
|
+
combined_where = if composed_filter.is_a?(Hash) && composed_filter.key?("$and")
|
|
3400
3439
|
{ "$and" => composed_filter["$and"] + [{ "objectId" => object_id }] }
|
|
3401
3440
|
else
|
|
3402
3441
|
{ "$and" => [composed_filter, { "objectId" => object_id }] }
|
|
3403
3442
|
end
|
|
3404
3443
|
translated_combined = ConstraintTranslator.translate(combined_where, agent)
|
|
3405
|
-
rows =
|
|
3406
|
-
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3444
|
+
rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3407
3445
|
execute_find_via_direct(
|
|
3408
3446
|
agent, class_name,
|
|
3409
3447
|
where: translated_combined, limit: 1,
|
|
@@ -3461,7 +3499,7 @@ module Parse
|
|
|
3461
3499
|
assert_record_in_tenant_scope!(result, scope, class_name)
|
|
3462
3500
|
|
|
3463
3501
|
ResultFormatter.format_object(class_name, result,
|
|
3464
|
-
|
|
3502
|
+
truncated_include_fields: truncated_includes)
|
|
3465
3503
|
end
|
|
3466
3504
|
|
|
3467
3505
|
# Batch-fetch multiple Parse objects by id in a single query.
|
|
@@ -3475,7 +3513,7 @@ module Parse
|
|
|
3475
3513
|
# @raise [Parse::Agent::ValidationError] if class_name invalid, ids not an Array,
|
|
3476
3514
|
# any id has invalid format, or more than 50 unique ids are requested
|
|
3477
3515
|
def get_objects(agent, class_name:, ids: nil, include: [],
|
|
3478
|
-
|
|
3516
|
+
apply_canonical_filter: true, **_kwargs)
|
|
3479
3517
|
assert_class_accessible!(class_name, agent: agent, op: :get)
|
|
3480
3518
|
# Resolve tenant scope early — verified post-fetch (oracle-prevention).
|
|
3481
3519
|
# TRACK-AGENT-1 / TRACK-AGENT-6 / TRACK-AGENT-7 fix: per-agent
|
|
@@ -3500,12 +3538,12 @@ module Parse
|
|
|
3500
3538
|
# Short-circuit on empty array — no query needed
|
|
3501
3539
|
if ids.empty?
|
|
3502
3540
|
return {
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3541
|
+
class_name: class_name,
|
|
3542
|
+
objects: {},
|
|
3543
|
+
missing: [],
|
|
3544
|
+
requested: 0,
|
|
3545
|
+
found: 0,
|
|
3546
|
+
}
|
|
3509
3547
|
end
|
|
3510
3548
|
|
|
3511
3549
|
unique_ids = ids.uniq
|
|
@@ -3533,9 +3571,9 @@ module Parse
|
|
|
3533
3571
|
# three layers reach the server in one query. Then route through
|
|
3534
3572
|
# ConstraintTranslator so snake_case keys are camelized to Parse
|
|
3535
3573
|
# Server wire format (mirrors count_objects / query_class).
|
|
3536
|
-
base_in_where
|
|
3537
|
-
composed
|
|
3538
|
-
composed
|
|
3574
|
+
base_in_where = { "objectId" => { "$in" => unique_ids } }
|
|
3575
|
+
composed = apply_per_agent_filter_to_where(base_in_where, class_name, agent: agent)
|
|
3576
|
+
composed = apply_canonical_filter_to_where(composed, class_name, agent: agent) if apply_canonical_filter
|
|
3539
3577
|
translated_where = ConstraintTranslator.translate(composed, agent)
|
|
3540
3578
|
|
|
3541
3579
|
# Build query
|
|
@@ -3560,8 +3598,7 @@ module Parse
|
|
|
3560
3598
|
query[:keys] = effective_keys.join(",") if effective_keys&.any?
|
|
3561
3599
|
|
|
3562
3600
|
with_timeout(:get_objects) do
|
|
3563
|
-
rows =
|
|
3564
|
-
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3601
|
+
rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3565
3602
|
# Feed the translated (already-composed) where into the
|
|
3566
3603
|
# direct-route helper. The $in constraint composes with
|
|
3567
3604
|
# the per-agent / canonical filter via the same $and the
|
|
@@ -3657,8 +3694,7 @@ module Parse
|
|
|
3657
3694
|
allowlist = MetadataRegistry.field_allowlist(class_name)
|
|
3658
3695
|
query[:keys] = allowlist.join(",") if allowlist&.any?
|
|
3659
3696
|
|
|
3660
|
-
rows =
|
|
3661
|
-
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3697
|
+
rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3662
3698
|
execute_find_via_direct(
|
|
3663
3699
|
agent, class_name,
|
|
3664
3700
|
where: translated_where, limit: limit, order: "-createdAt",
|
|
@@ -3713,7 +3749,7 @@ module Parse
|
|
|
3713
3749
|
AGGREGATE_DEFAULT_MONGO_DIRECT = true
|
|
3714
3750
|
|
|
3715
3751
|
def aggregate(agent, class_name:, pipeline:, rewrite_lookups: nil, compact_pointers: true,
|
|
3716
|
-
|
|
3752
|
+
apply_canonical_filter: true, mongo_direct: AGGREGATE_DEFAULT_MONGO_DIRECT,
|
|
3717
3753
|
**_kwargs)
|
|
3718
3754
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
3719
3755
|
# SECURITY: Validate pipeline BEFORE execution.
|
|
@@ -3740,12 +3776,12 @@ module Parse
|
|
|
3740
3776
|
# Tenant scope enforcement: prepend a $match stage at index 0.
|
|
3741
3777
|
# Done after pipeline validation so the injected stage doesn't
|
|
3742
3778
|
# interfere with the validator's denylist walk.
|
|
3743
|
-
scope
|
|
3779
|
+
scope = resolve_tenant_scope!(agent, class_name)
|
|
3744
3780
|
# Default-deny joins into tenant-incompatible classes BEFORE the
|
|
3745
3781
|
# outer $match is prepended (the outer scope can't reach a join
|
|
3746
3782
|
# sub-pipeline).
|
|
3747
3783
|
assert_joins_tenant_safe!(pipeline, scope)
|
|
3748
|
-
scoped_pipeline
|
|
3784
|
+
scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
|
|
3749
3785
|
|
|
3750
3786
|
# Per-agent filter (declared via Parse::Agent.new(filters: ...)) is
|
|
3751
3787
|
# UNCONDITIONAL — TRACK-AGENT-7 split. No LLM kwarg can drop it.
|
|
@@ -3773,7 +3809,6 @@ module Parse
|
|
|
3773
3809
|
if Parse::Agent.refuse_collscan? &&
|
|
3774
3810
|
!MetadataRegistry.allow_collscan?(class_name) &&
|
|
3775
3811
|
(match_stage = effective_pipeline.first&.dig("$match"))&.any?
|
|
3776
|
-
|
|
3777
3812
|
refusal = collscan_preflight(agent, class_name, match_stage)
|
|
3778
3813
|
return refusal if refusal
|
|
3779
3814
|
end
|
|
@@ -3876,10 +3911,10 @@ module Parse
|
|
|
3876
3911
|
# and the hint text is ~200 bytes on every call.
|
|
3877
3912
|
if auto_limited && results.size >= AGGREGATE_DEFAULT_LIMIT
|
|
3878
3913
|
result[:auto_limited] = true
|
|
3879
|
-
result[:auto_limit]
|
|
3880
|
-
result[:hint]
|
|
3881
|
-
|
|
3882
|
-
|
|
3914
|
+
result[:auto_limit] = AGGREGATE_DEFAULT_LIMIT
|
|
3915
|
+
result[:hint] = "Pipeline auto-bounded with $limit:#{AGGREGATE_DEFAULT_LIMIT} (no terminal $limit/$count supplied). " \
|
|
3916
|
+
"Add an explicit { \"$limit\": N } stage at the end of your pipeline to control the cap, " \
|
|
3917
|
+
"or call count_objects first to size the result before fetching rows."
|
|
3883
3918
|
end
|
|
3884
3919
|
result
|
|
3885
3920
|
end
|
|
@@ -3898,6 +3933,7 @@ module Parse
|
|
|
3898
3933
|
return [pipeline, false] if op == "$limit" || op == "$count"
|
|
3899
3934
|
[pipeline + [{ "$limit" => AGGREGATE_DEFAULT_LIMIT }], true]
|
|
3900
3935
|
end
|
|
3936
|
+
|
|
3901
3937
|
module_function :ensure_aggregate_terminal_limit
|
|
3902
3938
|
|
|
3903
3939
|
# ============================================================
|
|
@@ -3908,28 +3944,28 @@ module Parse
|
|
|
3908
3944
|
# grouped count (think tags, statuses, customer ids), so it gets
|
|
3909
3945
|
# a larger ceiling while group_by/group_by_date stay aligned with
|
|
3910
3946
|
# AGGREGATE_DEFAULT_LIMIT for context-safety.
|
|
3911
|
-
GROUP_DEFAULT_LIMIT
|
|
3912
|
-
GROUP_MAX_LIMIT
|
|
3947
|
+
GROUP_DEFAULT_LIMIT = 200
|
|
3948
|
+
GROUP_MAX_LIMIT = 1000
|
|
3913
3949
|
DISTINCT_DEFAULT_LIMIT = 1000
|
|
3914
|
-
DISTINCT_MAX_LIMIT
|
|
3950
|
+
DISTINCT_MAX_LIMIT = 5000
|
|
3915
3951
|
|
|
3916
3952
|
# Supported aggregation operations for group_by / group_by_date.
|
|
3917
3953
|
# Maps the LLM-facing name to the MongoDB accumulator operator.
|
|
3918
3954
|
GROUP_OPERATIONS = {
|
|
3919
|
-
"count"
|
|
3920
|
-
"sum"
|
|
3921
|
-
"avg"
|
|
3955
|
+
"count" => "$sum", # value_field is ignored; accumulator is { $sum: 1 }
|
|
3956
|
+
"sum" => "$sum",
|
|
3957
|
+
"avg" => "$avg",
|
|
3922
3958
|
"average" => "$avg",
|
|
3923
|
-
"min"
|
|
3924
|
-
"max"
|
|
3959
|
+
"min" => "$min",
|
|
3960
|
+
"max" => "$max",
|
|
3925
3961
|
}.freeze
|
|
3926
3962
|
|
|
3927
3963
|
GROUP_DATE_INTERVALS = %w[year month week day hour minute second].freeze
|
|
3928
3964
|
|
|
3929
3965
|
# Group records by a field and aggregate. See TOOL_DEFINITIONS[:group_by].
|
|
3930
3966
|
def group_by(agent, class_name:, field:, operation: nil, value_field: nil,
|
|
3931
|
-
|
|
3932
|
-
|
|
3967
|
+
where: nil, flatten_arrays: false, sort: nil, limit: nil,
|
|
3968
|
+
dry_run: false, apply_canonical_filter: true, **_kwargs)
|
|
3933
3969
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
3934
3970
|
validated_field = validate_group_field!(field, name: :field)
|
|
3935
3971
|
op_key, accumulator = resolve_group_operation!(operation, value_field)
|
|
@@ -3961,9 +3997,9 @@ module Parse
|
|
|
3961
3997
|
append_sort_limit!(pipeline, sort_choice: sort_choice, cap: cap, default_sort: nil)
|
|
3962
3998
|
|
|
3963
3999
|
return dry_run_envelope(class_name: class_name, pipeline: pipeline, params: {
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
4000
|
+
field: validated_field, operation: op_key, value_field: value_field,
|
|
4001
|
+
flatten_arrays: flatten_arrays, sort: sort_choice, limit: cap,
|
|
4002
|
+
}) if dry_run
|
|
3967
4003
|
|
|
3968
4004
|
result = run_aggregation_for_group_tool!(
|
|
3969
4005
|
agent,
|
|
@@ -3995,27 +4031,27 @@ module Parse
|
|
|
3995
4031
|
group_count: groups.size,
|
|
3996
4032
|
groups: groups.map { |k, v| { key: normalize_group_key(k), value: v } },
|
|
3997
4033
|
}
|
|
3998
|
-
envelope[:value_field]
|
|
3999
|
-
envelope[:pointer_class]
|
|
4034
|
+
envelope[:value_field] = value_field if value_field
|
|
4035
|
+
envelope[:pointer_class] = pointer_class if pointer_class
|
|
4000
4036
|
envelope[:flatten_arrays] = true if flatten_arrays
|
|
4001
|
-
envelope[:sort]
|
|
4002
|
-
envelope[:truncated]
|
|
4003
|
-
envelope[:limit]
|
|
4037
|
+
envelope[:sort] = sort_choice if sort_choice
|
|
4038
|
+
envelope[:truncated] = true if truncated
|
|
4039
|
+
envelope[:limit] = cap
|
|
4004
4040
|
envelope
|
|
4005
4041
|
end
|
|
4006
4042
|
|
|
4007
4043
|
# Group records by a date field bucketed at an interval. See
|
|
4008
4044
|
# TOOL_DEFINITIONS[:group_by_date].
|
|
4009
4045
|
def group_by_date(agent, class_name:, field:, interval:, operation: nil,
|
|
4010
|
-
|
|
4011
|
-
|
|
4046
|
+
value_field: nil, where: nil, timezone: nil, sort: nil,
|
|
4047
|
+
limit: nil, dry_run: false, apply_canonical_filter: true, **_kwargs)
|
|
4012
4048
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
4013
|
-
validated_field
|
|
4014
|
-
interval_sym
|
|
4049
|
+
validated_field = validate_group_field!(field, name: :field)
|
|
4050
|
+
interval_sym = validate_group_date_interval!(interval)
|
|
4015
4051
|
op_key, accumulator = resolve_group_operation!(operation, value_field)
|
|
4016
|
-
cap
|
|
4017
|
-
sort_choice
|
|
4018
|
-
tz
|
|
4052
|
+
cap = clamp_group_limit(limit, default: GROUP_DEFAULT_LIMIT, max: GROUP_MAX_LIMIT)
|
|
4053
|
+
sort_choice = normalize_group_sort(sort) || "key_asc"
|
|
4054
|
+
tz = validate_timezone!(timezone)
|
|
4019
4055
|
|
|
4020
4056
|
referenced = [validated_field]
|
|
4021
4057
|
referenced << validate_group_field!(value_field, name: :value_field) if value_field
|
|
@@ -4058,9 +4094,9 @@ module Parse
|
|
|
4058
4094
|
append_sort_limit!(pipeline, sort_choice: sort_choice, cap: cap, default_sort: "key_asc")
|
|
4059
4095
|
|
|
4060
4096
|
return dry_run_envelope(class_name: class_name, pipeline: pipeline, params: {
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4097
|
+
field: validated_field, interval: interval_sym.to_s, operation: op_key,
|
|
4098
|
+
value_field: value_field, timezone: tz, sort: sort_choice, limit: cap,
|
|
4099
|
+
}) if dry_run
|
|
4064
4100
|
|
|
4065
4101
|
result = run_aggregation_for_group_tool!(
|
|
4066
4102
|
agent,
|
|
@@ -4089,25 +4125,25 @@ module Parse
|
|
|
4089
4125
|
groups: groups.map { |k, v| { key: k, value: v } },
|
|
4090
4126
|
}
|
|
4091
4127
|
envelope[:value_field] = value_field if value_field
|
|
4092
|
-
envelope[:timezone]
|
|
4093
|
-
envelope[:sort]
|
|
4094
|
-
envelope[:truncated]
|
|
4095
|
-
envelope[:limit]
|
|
4128
|
+
envelope[:timezone] = tz if tz
|
|
4129
|
+
envelope[:sort] = sort_choice
|
|
4130
|
+
envelope[:truncated] = true if truncated
|
|
4131
|
+
envelope[:limit] = cap
|
|
4096
4132
|
envelope
|
|
4097
4133
|
end
|
|
4098
4134
|
|
|
4099
4135
|
# Return distinct values of a field. See TOOL_DEFINITIONS[:distinct].
|
|
4100
4136
|
def distinct(agent, class_name:, field:, where: nil, sort: nil, limit: nil,
|
|
4101
|
-
|
|
4137
|
+
dry_run: false, apply_canonical_filter: true, **_kwargs)
|
|
4102
4138
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
4103
4139
|
validated_field = validate_group_field!(field, name: :field)
|
|
4104
4140
|
cap = clamp_group_limit(limit, default: DISTINCT_DEFAULT_LIMIT, max: DISTINCT_MAX_LIMIT)
|
|
4105
4141
|
sort_choice = case sort.to_s
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4142
|
+
when "asc", "desc" then sort.to_s
|
|
4143
|
+
when "", "none", nil then nil
|
|
4144
|
+
else raise Parse::Agent::ValidationError,
|
|
4145
|
+
"Invalid sort #{sort.inspect}. Must be 'asc' or 'desc'."
|
|
4146
|
+
end
|
|
4111
4147
|
|
|
4112
4148
|
assert_fields_in_allowlist!(class_name, [validated_field])
|
|
4113
4149
|
assert_where_fields_in_allowlist!(class_name, where)
|
|
@@ -4126,14 +4162,14 @@ module Parse
|
|
|
4126
4162
|
# append_sort_limit! (which expects key_*/value_*). Distinct has
|
|
4127
4163
|
# no value column to sort on.
|
|
4128
4164
|
wire_sort = case sort_choice
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4165
|
+
when "asc" then "key_asc"
|
|
4166
|
+
when "desc" then "key_desc"
|
|
4167
|
+
end
|
|
4132
4168
|
append_sort_limit!(pipeline, sort_choice: wire_sort, cap: cap, default_sort: nil)
|
|
4133
4169
|
|
|
4134
4170
|
return dry_run_envelope(class_name: class_name, pipeline: pipeline, params: {
|
|
4135
|
-
|
|
4136
|
-
|
|
4171
|
+
field: validated_field, sort: sort_choice, limit: cap,
|
|
4172
|
+
}) if dry_run
|
|
4137
4173
|
|
|
4138
4174
|
result = run_aggregation_for_group_tool!(
|
|
4139
4175
|
agent,
|
|
@@ -4160,9 +4196,9 @@ module Parse
|
|
|
4160
4196
|
values: values,
|
|
4161
4197
|
}
|
|
4162
4198
|
envelope[:pointer_class] = pointer_class if pointer_class
|
|
4163
|
-
envelope[:sort]
|
|
4164
|
-
envelope[:truncated]
|
|
4165
|
-
envelope[:limit]
|
|
4199
|
+
envelope[:sort] = sort_choice if sort_choice
|
|
4200
|
+
envelope[:truncated] = true if truncated
|
|
4201
|
+
envelope[:limit] = cap
|
|
4166
4202
|
envelope
|
|
4167
4203
|
end
|
|
4168
4204
|
|
|
@@ -4184,6 +4220,7 @@ module Parse
|
|
|
4184
4220
|
end
|
|
4185
4221
|
s
|
|
4186
4222
|
end
|
|
4223
|
+
|
|
4187
4224
|
module_function :validate_group_field!
|
|
4188
4225
|
|
|
4189
4226
|
# @api private
|
|
@@ -4192,10 +4229,11 @@ module Parse
|
|
|
4192
4229
|
unless GROUP_DATE_INTERVALS.include?(sym.to_s)
|
|
4193
4230
|
raise Parse::Agent::ValidationError,
|
|
4194
4231
|
"interval #{interval.inspect} is invalid. Must be one of " \
|
|
4195
|
-
"#{GROUP_DATE_INTERVALS.join(
|
|
4232
|
+
"#{GROUP_DATE_INTERVALS.join(", ")}."
|
|
4196
4233
|
end
|
|
4197
4234
|
sym
|
|
4198
4235
|
end
|
|
4236
|
+
|
|
4199
4237
|
module_function :validate_group_date_interval!
|
|
4200
4238
|
|
|
4201
4239
|
# @api private
|
|
@@ -4212,6 +4250,7 @@ module Parse
|
|
|
4212
4250
|
end
|
|
4213
4251
|
s
|
|
4214
4252
|
end
|
|
4253
|
+
|
|
4215
4254
|
module_function :validate_timezone!
|
|
4216
4255
|
|
|
4217
4256
|
# @api private
|
|
@@ -4223,7 +4262,7 @@ module Parse
|
|
|
4223
4262
|
unless GROUP_OPERATIONS.key?(op_raw)
|
|
4224
4263
|
raise Parse::Agent::ValidationError,
|
|
4225
4264
|
"operation #{operation.inspect} is invalid. Must be one of " \
|
|
4226
|
-
"#{GROUP_OPERATIONS.keys.uniq.join(
|
|
4265
|
+
"#{GROUP_OPERATIONS.keys.uniq.join(", ")}."
|
|
4227
4266
|
end
|
|
4228
4267
|
op_key = op_raw == "average" ? "avg" : op_raw
|
|
4229
4268
|
if op_key == "count"
|
|
@@ -4236,6 +4275,7 @@ module Parse
|
|
|
4236
4275
|
[op_key, { GROUP_OPERATIONS[op_raw] => "$__VALUE__" }] # placeholder; substituted by build_group_pipeline
|
|
4237
4276
|
end
|
|
4238
4277
|
end
|
|
4278
|
+
|
|
4239
4279
|
module_function :resolve_group_operation!
|
|
4240
4280
|
|
|
4241
4281
|
# @api private
|
|
@@ -4244,10 +4284,11 @@ module Parse
|
|
|
4244
4284
|
allowed = %w[value_desc value_asc key_desc key_asc]
|
|
4245
4285
|
unless allowed.include?(sort.to_s)
|
|
4246
4286
|
raise Parse::Agent::ValidationError,
|
|
4247
|
-
"sort #{sort.inspect} is invalid. Must be one of #{allowed.join(
|
|
4287
|
+
"sort #{sort.inspect} is invalid. Must be one of #{allowed.join(", ")}."
|
|
4248
4288
|
end
|
|
4249
4289
|
sort.to_s
|
|
4250
4290
|
end
|
|
4291
|
+
|
|
4251
4292
|
module_function :normalize_group_sort
|
|
4252
4293
|
|
|
4253
4294
|
# @api private
|
|
@@ -4259,6 +4300,7 @@ module Parse
|
|
|
4259
4300
|
end
|
|
4260
4301
|
n
|
|
4261
4302
|
end
|
|
4303
|
+
|
|
4262
4304
|
module_function :clamp_group_limit
|
|
4263
4305
|
|
|
4264
4306
|
# @api private
|
|
@@ -4273,6 +4315,7 @@ module Parse
|
|
|
4273
4315
|
permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
|
|
4274
4316
|
check_match_keys_for_restricted_fields!(where, permitted)
|
|
4275
4317
|
end
|
|
4318
|
+
|
|
4276
4319
|
module_function :assert_where_fields_in_allowlist!
|
|
4277
4320
|
|
|
4278
4321
|
# @api private
|
|
@@ -4292,6 +4335,7 @@ module Parse
|
|
|
4292
4335
|
end
|
|
4293
4336
|
end
|
|
4294
4337
|
end
|
|
4338
|
+
|
|
4295
4339
|
module_function :assert_fields_in_allowlist!
|
|
4296
4340
|
|
|
4297
4341
|
# @api private
|
|
@@ -4316,6 +4360,7 @@ module Parse
|
|
|
4316
4360
|
wire
|
|
4317
4361
|
end
|
|
4318
4362
|
end
|
|
4363
|
+
|
|
4319
4364
|
module_function :resolve_aggregation_field
|
|
4320
4365
|
|
|
4321
4366
|
# @api private
|
|
@@ -4349,6 +4394,7 @@ module Parse
|
|
|
4349
4394
|
pipeline << { "$group" => group_stage }
|
|
4350
4395
|
pipeline
|
|
4351
4396
|
end
|
|
4397
|
+
|
|
4352
4398
|
module_function :build_group_pipeline
|
|
4353
4399
|
|
|
4354
4400
|
# @api private
|
|
@@ -4374,17 +4420,18 @@ module Parse
|
|
|
4374
4420
|
{ "year" => op.call("$year"), "month" => op.call("$month"), "day" => op.call("$dayOfMonth") }
|
|
4375
4421
|
when :hour
|
|
4376
4422
|
{ "year" => op.call("$year"), "month" => op.call("$month"),
|
|
4377
|
-
"day"
|
|
4423
|
+
"day" => op.call("$dayOfMonth"), "hour" => op.call("$hour") }
|
|
4378
4424
|
when :minute
|
|
4379
|
-
{ "year"
|
|
4380
|
-
"day"
|
|
4425
|
+
{ "year" => op.call("$year"), "month" => op.call("$month"),
|
|
4426
|
+
"day" => op.call("$dayOfMonth"), "hour" => op.call("$hour"),
|
|
4381
4427
|
"minute" => op.call("$minute") }
|
|
4382
4428
|
when :second
|
|
4383
|
-
{ "year"
|
|
4384
|
-
"day"
|
|
4429
|
+
{ "year" => op.call("$year"), "month" => op.call("$month"),
|
|
4430
|
+
"day" => op.call("$dayOfMonth"), "hour" => op.call("$hour"),
|
|
4385
4431
|
"minute" => op.call("$minute"), "second" => op.call("$second") }
|
|
4386
4432
|
end
|
|
4387
4433
|
end
|
|
4434
|
+
|
|
4388
4435
|
module_function :build_date_group_expression
|
|
4389
4436
|
|
|
4390
4437
|
# @api private
|
|
@@ -4398,17 +4445,18 @@ module Parse
|
|
|
4398
4445
|
return "null" unless key.is_a?(Hash)
|
|
4399
4446
|
y, mo, d = key["year"], key["month"], key["day"]
|
|
4400
4447
|
h, mi, s = key["hour"], key["minute"], key["second"]
|
|
4401
|
-
wk
|
|
4448
|
+
wk = key["week"]
|
|
4402
4449
|
case interval
|
|
4403
|
-
when :month
|
|
4404
|
-
when :week
|
|
4405
|
-
when :day
|
|
4406
|
-
when :hour
|
|
4450
|
+
when :month then (y.nil? || mo.nil?) ? "null" : sprintf("%04d-%02d", y, mo)
|
|
4451
|
+
when :week then (y.nil? || wk.nil?) ? "null" : sprintf("%04d-W%02d", y, wk)
|
|
4452
|
+
when :day then (y.nil? || mo.nil? || d.nil?) ? "null" : sprintf("%04d-%02d-%02d", y, mo, d)
|
|
4453
|
+
when :hour then (y.nil? || mo.nil? || d.nil? || h.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:00", y, mo, d, h)
|
|
4407
4454
|
when :minute then (y.nil? || mo.nil? || d.nil? || h.nil? || mi.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:%02d", y, mo, d, h, mi)
|
|
4408
4455
|
when :second then (y.nil? || mo.nil? || d.nil? || h.nil? || mi.nil? || s.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:%02d:%02d", y, mo, d, h, mi, s)
|
|
4409
4456
|
else "null"
|
|
4410
4457
|
end
|
|
4411
4458
|
end
|
|
4459
|
+
|
|
4412
4460
|
module_function :format_date_key
|
|
4413
4461
|
|
|
4414
4462
|
# @api private
|
|
@@ -4423,7 +4471,7 @@ module Parse
|
|
|
4423
4471
|
# - { rows: [...] } on success
|
|
4424
4472
|
# - { refused: true, reason: ..., ... } when COLLSCAN refused
|
|
4425
4473
|
def run_aggregation_for_group_tool!(agent, class_name:, pipeline:, tool:,
|
|
4426
|
-
|
|
4474
|
+
apply_canonical_filter: true)
|
|
4427
4475
|
scope = resolve_tenant_scope!(agent, class_name)
|
|
4428
4476
|
assert_joins_tenant_safe!(pipeline, scope)
|
|
4429
4477
|
scoped = apply_tenant_scope_to_pipeline(pipeline, scope)
|
|
@@ -4458,12 +4506,11 @@ module Parse
|
|
|
4458
4506
|
defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
4459
4507
|
|
|
4460
4508
|
with_timeout(tool) do
|
|
4461
|
-
rows =
|
|
4462
|
-
if use_direct
|
|
4509
|
+
rows = if use_direct
|
|
4463
4510
|
translated = Parse::Query.new(class_name).send(
|
|
4464
4511
|
:translate_pipeline_for_direct_mongodb, scoped,
|
|
4465
4512
|
)
|
|
4466
|
-
raw_rows = Parse::MongoDB.aggregate(class_name, translated, **agent
|
|
4513
|
+
raw_rows = Parse::MongoDB.aggregate(class_name, translated, **mongo_direct_auth_kwargs(agent))
|
|
4467
4514
|
raw_rows.map { |raw| Parse::MongoDB.convert_aggregation_document(raw) }
|
|
4468
4515
|
else
|
|
4469
4516
|
response = agent.client.aggregate_pipeline(class_name, scoped, **agent.request_opts)
|
|
@@ -4474,6 +4521,7 @@ module Parse
|
|
|
4474
4521
|
{ rows: rows }
|
|
4475
4522
|
end
|
|
4476
4523
|
end
|
|
4524
|
+
|
|
4477
4525
|
module_function :run_aggregation_for_group_tool!
|
|
4478
4526
|
|
|
4479
4527
|
# @api private
|
|
@@ -4491,6 +4539,7 @@ module Parse
|
|
|
4491
4539
|
rewritten = pairs.map { |k, v| [k.is_a?(String) ? k.sub(/\A#{cls}\$/, "") : k, v] }
|
|
4492
4540
|
[cls, rewritten]
|
|
4493
4541
|
end
|
|
4542
|
+
|
|
4494
4543
|
module_function :extract_pointer_class!
|
|
4495
4544
|
|
|
4496
4545
|
# @api private
|
|
@@ -4511,6 +4560,7 @@ module Parse
|
|
|
4511
4560
|
redacted_pairs = pairs.map { |_k, v| [nil, v] }
|
|
4512
4561
|
[nil, redacted_pairs]
|
|
4513
4562
|
end
|
|
4563
|
+
|
|
4514
4564
|
module_function :redact_hidden_pointer_groups!
|
|
4515
4565
|
|
|
4516
4566
|
# @api private
|
|
@@ -4531,12 +4581,13 @@ module Parse
|
|
|
4531
4581
|
effective_sort = sort_choice || default_sort
|
|
4532
4582
|
if effective_sort
|
|
4533
4583
|
direction = effective_sort.end_with?("_desc") ? -1 : 1
|
|
4534
|
-
key
|
|
4584
|
+
key = effective_sort.start_with?("value") ? "value" : "_id"
|
|
4535
4585
|
pipeline << { "$sort" => { key => direction } }
|
|
4536
4586
|
end
|
|
4537
4587
|
pipeline << { "$limit" => cap + 1 }
|
|
4538
4588
|
pipeline
|
|
4539
4589
|
end
|
|
4590
|
+
|
|
4540
4591
|
module_function :append_sort_limit!
|
|
4541
4592
|
|
|
4542
4593
|
# @api private
|
|
@@ -4556,6 +4607,7 @@ module Parse
|
|
|
4556
4607
|
"to the aggregate tool (modified as needed) for full pipeline control.",
|
|
4557
4608
|
}
|
|
4558
4609
|
end
|
|
4610
|
+
|
|
4559
4611
|
module_function :dry_run_envelope
|
|
4560
4612
|
|
|
4561
4613
|
# @api private
|
|
@@ -4564,12 +4616,13 @@ module Parse
|
|
|
4564
4616
|
return pairs if sort_choice.nil?
|
|
4565
4617
|
case sort_choice
|
|
4566
4618
|
when "value_desc" then pairs.sort_by { |_, v| -sort_key_numeric(v) }
|
|
4567
|
-
when "value_asc"
|
|
4568
|
-
when "key_desc"
|
|
4569
|
-
when "key_asc"
|
|
4619
|
+
when "value_asc" then pairs.sort_by { |_, v| sort_key_numeric(v) }
|
|
4620
|
+
when "key_desc" then pairs.sort_by { |k, _| sort_key_for(k) }.reverse
|
|
4621
|
+
when "key_asc" then pairs.sort_by { |k, _| sort_key_for(k) }
|
|
4570
4622
|
else pairs
|
|
4571
4623
|
end
|
|
4572
4624
|
end
|
|
4625
|
+
|
|
4573
4626
|
module_function :sort_groups
|
|
4574
4627
|
|
|
4575
4628
|
# @api private
|
|
@@ -4577,24 +4630,26 @@ module Parse
|
|
|
4577
4630
|
# last (regardless of direction; callers reverse for desc).
|
|
4578
4631
|
def sort_key_for(value)
|
|
4579
4632
|
case value
|
|
4580
|
-
when nil
|
|
4581
|
-
when Numeric
|
|
4582
|
-
when String
|
|
4583
|
-
else
|
|
4633
|
+
when nil then [1, ""]
|
|
4634
|
+
when Numeric then [0, value]
|
|
4635
|
+
when String then [0, value]
|
|
4636
|
+
else [0, value.to_s]
|
|
4584
4637
|
end
|
|
4585
4638
|
end
|
|
4639
|
+
|
|
4586
4640
|
module_function :sort_key_for
|
|
4587
4641
|
|
|
4588
4642
|
# @api private
|
|
4589
4643
|
def sort_key_numeric(value)
|
|
4590
4644
|
case value
|
|
4591
4645
|
when Numeric then value
|
|
4592
|
-
when nil
|
|
4646
|
+
when nil then 0
|
|
4593
4647
|
else
|
|
4594
4648
|
n = Float(value) rescue 0
|
|
4595
4649
|
n
|
|
4596
4650
|
end
|
|
4597
4651
|
end
|
|
4652
|
+
|
|
4598
4653
|
module_function :sort_key_numeric
|
|
4599
4654
|
|
|
4600
4655
|
# @api private
|
|
@@ -4604,6 +4659,7 @@ module Parse
|
|
|
4604
4659
|
def normalize_group_key(key)
|
|
4605
4660
|
key.nil? ? "null" : key
|
|
4606
4661
|
end
|
|
4662
|
+
|
|
4607
4663
|
module_function :normalize_group_key
|
|
4608
4664
|
|
|
4609
4665
|
# ============================================================
|
|
@@ -4680,36 +4736,36 @@ module Parse
|
|
|
4680
4736
|
end
|
|
4681
4737
|
|
|
4682
4738
|
available_rows = rows.size
|
|
4683
|
-
truncated
|
|
4684
|
-
rows
|
|
4739
|
+
truncated = available_rows > effective_cap
|
|
4740
|
+
rows = rows.first(effective_cap) if truncated
|
|
4685
4741
|
|
|
4686
|
-
column_specs
|
|
4687
|
-
headers
|
|
4742
|
+
column_specs = normalize_export_columns(columns, rows.first)
|
|
4743
|
+
headers = column_specs.map { |spec| spec[:header] }
|
|
4688
4744
|
extracted_rows = rows.map do |row|
|
|
4689
4745
|
column_specs.map { |spec| stringify_export_value(extract_export_value(row, spec[:path])) }
|
|
4690
4746
|
end
|
|
4691
4747
|
|
|
4692
4748
|
output = case format_s
|
|
4693
|
-
when "csv"
|
|
4749
|
+
when "csv" then format_export_csv(headers, extracted_rows)
|
|
4694
4750
|
when "markdown" then format_export_markdown(headers, extracted_rows)
|
|
4695
|
-
when "table"
|
|
4751
|
+
when "table" then format_export_text_table(headers, extracted_rows)
|
|
4696
4752
|
end
|
|
4697
4753
|
|
|
4698
4754
|
result = {
|
|
4699
4755
|
class_name: class_name,
|
|
4700
|
-
format:
|
|
4701
|
-
headers:
|
|
4702
|
-
row_count:
|
|
4703
|
-
output:
|
|
4756
|
+
format: format_s,
|
|
4757
|
+
headers: headers,
|
|
4758
|
+
row_count: extracted_rows.size,
|
|
4759
|
+
output: output,
|
|
4704
4760
|
}
|
|
4705
4761
|
if truncated
|
|
4706
|
-
result[:truncated]
|
|
4762
|
+
result[:truncated] = true
|
|
4707
4763
|
result[:available_rows] = available_rows
|
|
4708
|
-
result[:row_cap]
|
|
4709
|
-
result[:hint]
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4764
|
+
result[:row_cap] = effective_cap
|
|
4765
|
+
result[:hint] = "Output truncated at row_cap=#{effective_cap} of #{available_rows} available rows. " \
|
|
4766
|
+
"Narrow with where:/pipeline filters, or set row_cap: explicitly (max #{MAX_EXPORT_ROW_CAP}). " \
|
|
4767
|
+
"For full exports of larger sets use the operator-facing rake mcp:tool[export_data,...] " \
|
|
4768
|
+
"directly rather than reading the rows back through the LLM."
|
|
4713
4769
|
end
|
|
4714
4770
|
result
|
|
4715
4771
|
end
|
|
@@ -4720,7 +4776,7 @@ module Parse
|
|
|
4720
4776
|
# query_class returns a ResultFormatter-wrapped hash; we want the raw rows.
|
|
4721
4777
|
query = {}
|
|
4722
4778
|
query[:limit] = [limit || Agent::DEFAULT_LIMIT, Agent::MAX_LIMIT].min
|
|
4723
|
-
query[:skip]
|
|
4779
|
+
query[:skip] = skip if skip && skip > 0
|
|
4724
4780
|
query[:order] = order if order
|
|
4725
4781
|
|
|
4726
4782
|
# NEW-TOOLS-5: validate keys: against identifier regex before
|
|
@@ -4731,8 +4787,7 @@ module Parse
|
|
|
4731
4787
|
validated_keys = validate_keys!(keys)
|
|
4732
4788
|
allowlist = MetadataRegistry.field_allowlist(class_name)
|
|
4733
4789
|
caller_keys = validated_keys&.any? ? validated_keys : nil
|
|
4734
|
-
effective_keys =
|
|
4735
|
-
if allowlist && allowlist.any?
|
|
4790
|
+
effective_keys = if allowlist && allowlist.any?
|
|
4736
4791
|
permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
|
|
4737
4792
|
caller_keys ? (caller_keys & permitted) : allowlist.map(&:to_s)
|
|
4738
4793
|
else
|
|
@@ -4783,6 +4838,7 @@ module Parse
|
|
|
4783
4838
|
|
|
4784
4839
|
redact_hidden_classes!(rows, agent: agent)
|
|
4785
4840
|
end
|
|
4841
|
+
|
|
4786
4842
|
module_function :export_via_query
|
|
4787
4843
|
|
|
4788
4844
|
# @api private
|
|
@@ -4818,7 +4874,7 @@ module Parse
|
|
|
4818
4874
|
translated = Parse::Query.new(class_name).send(
|
|
4819
4875
|
:translate_pipeline_for_direct_mongodb, effective_pipeline,
|
|
4820
4876
|
)
|
|
4821
|
-
raw_rows = Parse::MongoDB.aggregate(class_name, translated, **agent
|
|
4877
|
+
raw_rows = Parse::MongoDB.aggregate(class_name, translated, **mongo_direct_auth_kwargs(agent))
|
|
4822
4878
|
rows = raw_rows.map { |raw| Parse::MongoDB.convert_aggregation_document(raw) }
|
|
4823
4879
|
else
|
|
4824
4880
|
response = agent.client.aggregate_pipeline(class_name, effective_pipeline, **agent.request_opts)
|
|
@@ -4829,6 +4885,7 @@ module Parse
|
|
|
4829
4885
|
|
|
4830
4886
|
redact_hidden_classes!(rows, agent: agent)
|
|
4831
4887
|
end
|
|
4888
|
+
|
|
4832
4889
|
module_function :export_via_aggregate
|
|
4833
4890
|
|
|
4834
4891
|
# @api private
|
|
@@ -4882,6 +4939,7 @@ module Parse
|
|
|
4882
4939
|
end
|
|
4883
4940
|
end
|
|
4884
4941
|
end
|
|
4942
|
+
|
|
4885
4943
|
module_function :normalize_export_columns
|
|
4886
4944
|
|
|
4887
4945
|
# @api private
|
|
@@ -4925,6 +4983,7 @@ module Parse
|
|
|
4925
4983
|
end
|
|
4926
4984
|
s
|
|
4927
4985
|
end
|
|
4986
|
+
|
|
4928
4987
|
module_function :validate_export_column_path!
|
|
4929
4988
|
|
|
4930
4989
|
# @api private
|
|
@@ -4936,6 +4995,7 @@ module Parse
|
|
|
4936
4995
|
{ path: k.to_s, header: k.to_s }
|
|
4937
4996
|
end
|
|
4938
4997
|
end
|
|
4998
|
+
|
|
4939
4999
|
module_function :infer_export_columns_from
|
|
4940
5000
|
|
|
4941
5001
|
# @api private
|
|
@@ -4952,19 +5012,21 @@ module Parse
|
|
|
4952
5012
|
end
|
|
4953
5013
|
end
|
|
4954
5014
|
end
|
|
5015
|
+
|
|
4955
5016
|
module_function :extract_export_value
|
|
4956
5017
|
|
|
4957
5018
|
# @api private
|
|
4958
5019
|
def stringify_export_value(value)
|
|
4959
5020
|
case value
|
|
4960
|
-
when nil
|
|
4961
|
-
when String
|
|
4962
|
-
when Hash, Array
|
|
5021
|
+
when nil then ""
|
|
5022
|
+
when String then value
|
|
5023
|
+
when Hash, Array then value.to_json
|
|
4963
5024
|
when Time, DateTime then value.iso8601
|
|
4964
|
-
when Date
|
|
4965
|
-
else
|
|
5025
|
+
when Date then value.to_s
|
|
5026
|
+
else value.to_s
|
|
4966
5027
|
end
|
|
4967
5028
|
end
|
|
5029
|
+
|
|
4968
5030
|
module_function :stringify_export_value
|
|
4969
5031
|
|
|
4970
5032
|
# @api private
|
|
@@ -4975,6 +5037,7 @@ module Parse
|
|
|
4975
5037
|
rows.each { |r| csv << r }
|
|
4976
5038
|
end
|
|
4977
5039
|
end
|
|
5040
|
+
|
|
4978
5041
|
module_function :format_export_csv
|
|
4979
5042
|
|
|
4980
5043
|
# @api private
|
|
@@ -4986,6 +5049,7 @@ module Parse
|
|
|
4986
5049
|
rows.each { |r| lines << "| #{r.map { |c| c.to_s.gsub(/\r?\n/, " ").gsub(/([\\|])/, '\\\\\1') }.join(" | ")} |" }
|
|
4987
5050
|
lines.join("\n")
|
|
4988
5051
|
end
|
|
5052
|
+
|
|
4989
5053
|
module_function :format_export_markdown
|
|
4990
5054
|
|
|
4991
5055
|
# @api private
|
|
@@ -5000,6 +5064,7 @@ module Parse
|
|
|
5000
5064
|
}
|
|
5001
5065
|
([sep, fmt.call(headers), sep] + rows.map(&fmt) + [sep]).join("\n")
|
|
5002
5066
|
end
|
|
5067
|
+
|
|
5003
5068
|
module_function :format_export_text_table
|
|
5004
5069
|
|
|
5005
5070
|
# Explain a query's execution plan
|
|
@@ -5148,11 +5213,10 @@ module Parse
|
|
|
5148
5213
|
# remains responsible for any internal queries it runs —
|
|
5149
5214
|
# this gate is the BOUNDARY check at the method-name level.
|
|
5150
5215
|
if agent.respond_to?(:acl_permission_strings)
|
|
5151
|
-
clp_op =
|
|
5152
|
-
|
|
5153
|
-
when :
|
|
5154
|
-
|
|
5155
|
-
else :find
|
|
5216
|
+
clp_op = case required_perm
|
|
5217
|
+
when :admin then :delete
|
|
5218
|
+
when :write then :update
|
|
5219
|
+
else :find
|
|
5156
5220
|
end
|
|
5157
5221
|
perms = agent.acl_permission_strings
|
|
5158
5222
|
unless Parse::CLPScope.permits?(class_name.to_s, clp_op, perms)
|
|
@@ -5257,22 +5321,22 @@ module Parse
|
|
|
5257
5321
|
end
|
|
5258
5322
|
end
|
|
5259
5323
|
return {
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5324
|
+
class_name: class_name,
|
|
5325
|
+
method: method_name,
|
|
5326
|
+
object_id: object_id,
|
|
5327
|
+
dry_run: true,
|
|
5328
|
+
supports_real_dry_run: false,
|
|
5329
|
+
would_call: {
|
|
5330
|
+
class: class_name,
|
|
5331
|
+
method: method_name,
|
|
5332
|
+
type: method_info[:type]&.to_s,
|
|
5333
|
+
object_id: object_id,
|
|
5334
|
+
args: preview_args,
|
|
5335
|
+
},
|
|
5336
|
+
note: "The method '#{class_name}.#{method_name}' did not declare supports_dry_run: true, so no method-side preview is available. " \
|
|
5337
|
+
"This response confirms the call would pass the permission/args/object gates the agent enforces; the method body was NOT invoked. " \
|
|
5338
|
+
"Remove dry_run to execute the operation for real.",
|
|
5339
|
+
}
|
|
5276
5340
|
end
|
|
5277
5341
|
|
|
5278
5342
|
# If the method didn't declare dry-run support and the caller
|
|
@@ -5308,8 +5372,7 @@ module Parse
|
|
|
5308
5372
|
# Master agents (no token) run unbound by design; acl_user/acl_role
|
|
5309
5373
|
# write/admin instance methods were already refused above.
|
|
5310
5374
|
token = agent.respond_to?(:session_token) ? agent.session_token : nil
|
|
5311
|
-
result =
|
|
5312
|
-
if token.is_a?(String) && !token.strip.empty?
|
|
5375
|
+
result = if token.is_a?(String) && !token.strip.empty?
|
|
5313
5376
|
Parse.with_session(token) { invoke.call }
|
|
5314
5377
|
else
|
|
5315
5378
|
invoke.call
|
|
@@ -5554,6 +5617,7 @@ module Parse
|
|
|
5554
5617
|
s
|
|
5555
5618
|
end
|
|
5556
5619
|
end
|
|
5620
|
+
|
|
5557
5621
|
module_function :validate_keys!
|
|
5558
5622
|
|
|
5559
5623
|
# Validate an include array (pointer fields to resolve) before forwarding
|
|
@@ -5658,6 +5722,7 @@ module Parse
|
|
|
5658
5722
|
acc[k] = v if allowed.include?(ks)
|
|
5659
5723
|
end
|
|
5660
5724
|
end
|
|
5725
|
+
|
|
5661
5726
|
module_function :project_object_to_allowlist
|
|
5662
5727
|
|
|
5663
5728
|
# Stamp each row hash with an SDK-added `_source` provenance
|
|
@@ -5682,13 +5747,14 @@ module Parse
|
|
|
5682
5747
|
next if row.key?("_source") || row.key?(:_source)
|
|
5683
5748
|
oid = row[id_key] || row[id_key.to_sym]
|
|
5684
5749
|
row["_source"] = {
|
|
5685
|
-
"class"
|
|
5686
|
-
"tool"
|
|
5750
|
+
"class" => class_name.to_s,
|
|
5751
|
+
"tool" => tool.to_s,
|
|
5687
5752
|
"object_id" => oid,
|
|
5688
5753
|
}
|
|
5689
5754
|
end
|
|
5690
5755
|
rows
|
|
5691
5756
|
end
|
|
5757
|
+
|
|
5692
5758
|
module_function :stamp_source!
|
|
5693
5759
|
|
|
5694
5760
|
# ============================================================
|
|
@@ -5743,7 +5809,7 @@ module Parse
|
|
|
5743
5809
|
# half can shadow the other.
|
|
5744
5810
|
effective_filter = compose_atlas_filter(
|
|
5745
5811
|
filter, class_name, agent: agent,
|
|
5746
|
-
|
|
5812
|
+
apply_canonical_filter: apply_canonical_filter,
|
|
5747
5813
|
)
|
|
5748
5814
|
|
|
5749
5815
|
opts = { limit: limit }.merge(auth)
|
|
@@ -5759,7 +5825,7 @@ module Parse
|
|
|
5759
5825
|
|
|
5760
5826
|
# @api private
|
|
5761
5827
|
def atlas_autocomplete(agent, class_name:, query:, field:, limit: nil, fuzzy: nil,
|
|
5762
|
-
|
|
5828
|
+
apply_canonical_filter: true, **_kwargs)
|
|
5763
5829
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
5764
5830
|
unless query.is_a?(String) && !query.strip.empty?
|
|
5765
5831
|
raise Parse::Agent::ValidationError, "query must be a non-empty string"
|
|
@@ -5777,7 +5843,7 @@ module Parse
|
|
|
5777
5843
|
# a $match stage AFTER the ACL $match (see atlas_search.rb:385-388).
|
|
5778
5844
|
effective_filter = compose_atlas_filter(
|
|
5779
5845
|
nil, class_name, agent: agent,
|
|
5780
|
-
|
|
5846
|
+
apply_canonical_filter: apply_canonical_filter,
|
|
5781
5847
|
)
|
|
5782
5848
|
|
|
5783
5849
|
opts = { limit: limit }.merge(auth)
|
|
@@ -5792,7 +5858,7 @@ module Parse
|
|
|
5792
5858
|
|
|
5793
5859
|
# @api private
|
|
5794
5860
|
def atlas_faceted_search(agent, class_name:, facets:, query: "", limit: nil,
|
|
5795
|
-
|
|
5861
|
+
apply_canonical_filter: true, **_kwargs)
|
|
5796
5862
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
5797
5863
|
# Faceted Atlas Search cannot ACL-filter $searchMeta bucket
|
|
5798
5864
|
# counts (see Parse::AtlasSearch::FacetedSearchNotACLSafe), so
|
|
@@ -5825,7 +5891,7 @@ module Parse
|
|
|
5825
5891
|
unless active_filters.empty?
|
|
5826
5892
|
raise Parse::Agent::AccessDenied.new(
|
|
5827
5893
|
class_name,
|
|
5828
|
-
"atlas_faceted_search cannot enforce #{active_filters.join(
|
|
5894
|
+
"atlas_faceted_search cannot enforce #{active_filters.join(" / ")} on " \
|
|
5829
5895
|
"$searchMeta bucket counts (the matched documents are not in the output " \
|
|
5830
5896
|
"stream). Use atlas_text_search (which applies these filters via $match) " \
|
|
5831
5897
|
"or pass apply_canonical_filter: false (and arrange your agent without a " \
|
|
@@ -5848,6 +5914,10 @@ module Parse
|
|
|
5848
5914
|
result = Parse::AtlasSearch.faceted_search(
|
|
5849
5915
|
class_name, query.to_s, facets,
|
|
5850
5916
|
limit: limit, master: true,
|
|
5917
|
+
# Master mode still has to name its application: the binding
|
|
5918
|
+
# guard compares the client, not the posture, and an unnamed
|
|
5919
|
+
# caller is refused once two applications are in play.
|
|
5920
|
+
**agent_client_kwarg(agent),
|
|
5851
5921
|
)
|
|
5852
5922
|
format_atlas_faceted_results(class_name, result)
|
|
5853
5923
|
end
|
|
@@ -5871,7 +5941,7 @@ module Parse
|
|
|
5871
5941
|
# compatibility with the helper signature (tests may pass it);
|
|
5872
5942
|
# it's no longer used in the body.
|
|
5873
5943
|
def atlas_auth_options!(agent, tool: nil)
|
|
5874
|
-
agent.
|
|
5944
|
+
agent.direct_auth_kwargs
|
|
5875
5945
|
end
|
|
5876
5946
|
|
|
5877
5947
|
# @api private
|
|
@@ -5935,7 +6005,7 @@ module Parse
|
|
|
5935
6005
|
# @param include [Array<String>, nil] pointer paths to $lookup.
|
|
5936
6006
|
# @return [Array<Hash>]
|
|
5937
6007
|
def execute_find_via_direct(agent, class_name, where: nil, limit: nil,
|
|
5938
|
-
|
|
6008
|
+
skip: 0, order: nil, keys: nil, include: nil)
|
|
5939
6009
|
q = Parse::Query.new(class_name)
|
|
5940
6010
|
q.limit(limit) if limit && limit > 0
|
|
5941
6011
|
q.skip(skip) if skip && skip > 0
|
|
@@ -5968,9 +6038,10 @@ module Parse
|
|
|
5968
6038
|
pipeline = [translated_match] + pipeline
|
|
5969
6039
|
end
|
|
5970
6040
|
|
|
5971
|
-
raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **agent
|
|
6041
|
+
raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **mongo_direct_auth_kwargs(agent))
|
|
5972
6042
|
raw_rows.map { |raw| Parse::MongoDB.convert_aggregation_document(raw) }
|
|
5973
6043
|
end
|
|
6044
|
+
|
|
5974
6045
|
module_function :execute_find_via_direct
|
|
5975
6046
|
|
|
5976
6047
|
# @api private
|
|
@@ -5991,8 +6062,7 @@ module Parse
|
|
|
5991
6062
|
# found") via {#assert_record_in_tenant_scope!}, matching get_object.
|
|
5992
6063
|
def fetch_call_method_receiver(agent, klass, class_name, object_id)
|
|
5993
6064
|
scope = resolve_tenant_scope!(agent, class_name)
|
|
5994
|
-
result =
|
|
5995
|
-
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
6065
|
+
result = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
5996
6066
|
where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
|
|
5997
6067
|
rows = execute_find_via_direct(agent, class_name, where: where_id, limit: 1)
|
|
5998
6068
|
rows && rows.first
|
|
@@ -6008,6 +6078,7 @@ module Parse
|
|
|
6008
6078
|
assert_record_in_tenant_scope!(result, scope, class_name)
|
|
6009
6079
|
(klass || Parse::Object).build(result, class_name)
|
|
6010
6080
|
end
|
|
6081
|
+
|
|
6011
6082
|
module_function :fetch_call_method_receiver
|
|
6012
6083
|
|
|
6013
6084
|
# @api private
|
|
@@ -6024,14 +6095,26 @@ module Parse
|
|
|
6024
6095
|
pipeline << translated_match
|
|
6025
6096
|
end
|
|
6026
6097
|
pipeline << { "$count" => "count" }
|
|
6027
|
-
raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **agent
|
|
6098
|
+
raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **mongo_direct_auth_kwargs(agent))
|
|
6028
6099
|
return 0 if raw_rows.empty?
|
|
6029
6100
|
raw_rows.first["count"] || 0
|
|
6030
6101
|
end
|
|
6102
|
+
|
|
6031
6103
|
module_function :execute_count_via_direct
|
|
6032
6104
|
|
|
6105
|
+
# @api private
|
|
6106
|
+
# `{ client: agent.client }`, or empty when the agent cannot name one.
|
|
6107
|
+
def agent_client_kwarg(agent)
|
|
6108
|
+
return {} unless agent.respond_to?(:client)
|
|
6109
|
+
client = agent.client
|
|
6110
|
+
client ? { client: client } : {}
|
|
6111
|
+
rescue StandardError
|
|
6112
|
+
{}
|
|
6113
|
+
end
|
|
6114
|
+
|
|
6033
6115
|
def mongo_direct_auth_kwargs(agent)
|
|
6034
|
-
# Single point of truth: delegate to agent.
|
|
6116
|
+
# Single point of truth: delegate to agent.direct_auth_kwargs,
|
|
6117
|
+
# which is acl_scope_kwargs plus the agent's own client.
|
|
6035
6118
|
# The agent emits exactly one of {session_token:}, {acl_user:},
|
|
6036
6119
|
# {acl_role:}, or {master: true} based on construction. The old
|
|
6037
6120
|
# session_token-or-master pairing is preserved as the
|
|
@@ -6039,7 +6122,16 @@ module Parse
|
|
|
6039
6122
|
# acl_user/acl_role scopes also flow through correctly so
|
|
6040
6123
|
# ACLScope's `_rperm` $match runs on direct mongo aggregations
|
|
6041
6124
|
# regardless of which identity input the agent was constructed
|
|
6042
|
-
# with.
|
|
6125
|
+
# with. Prefers direct_auth_kwargs, which adds the agent's own client
|
|
6126
|
+
# so the read is resolved and checked against that application.
|
|
6127
|
+
#
|
|
6128
|
+
# Falls back for agent-shaped objects that predate it: agents are
|
|
6129
|
+
# duck-typed at this boundary (tests and host applications supply
|
|
6130
|
+
# stand-ins), and requiring a new method of all of them to gain the
|
|
6131
|
+
# client would break every one that has not been updated. Without a
|
|
6132
|
+
# client the read is simply an unidentified caller, which
|
|
6133
|
+
# Parse::MongoDB's guard already models.
|
|
6134
|
+
return agent.direct_auth_kwargs if agent.respond_to?(:direct_auth_kwargs)
|
|
6043
6135
|
agent.acl_scope_kwargs
|
|
6044
6136
|
end
|
|
6045
6137
|
|
|
@@ -6086,8 +6178,8 @@ module Parse
|
|
|
6086
6178
|
def compose_atlas_filter(caller_filter, class_name, agent:, apply_canonical_filter: true)
|
|
6087
6179
|
per_agent = agent && agent.respond_to?(:filter_for) ? agent.filter_for(class_name) : nil
|
|
6088
6180
|
canonical = apply_canonical_filter ?
|
|
6089
|
-
|
|
6090
|
-
|
|
6181
|
+
Parse::Agent::MetadataRegistry.canonical_filter(class_name) :
|
|
6182
|
+
nil
|
|
6091
6183
|
|
|
6092
6184
|
parts = []
|
|
6093
6185
|
parts << per_agent.dup if per_agent && !per_agent.empty?
|
|
@@ -6097,9 +6189,10 @@ module Parse
|
|
|
6097
6189
|
case parts.size
|
|
6098
6190
|
when 0 then nil
|
|
6099
6191
|
when 1 then parts.first
|
|
6100
|
-
else
|
|
6192
|
+
else { "$and" => parts }
|
|
6101
6193
|
end
|
|
6102
6194
|
end
|
|
6195
|
+
|
|
6103
6196
|
module_function :compose_atlas_filter
|
|
6104
6197
|
|
|
6105
6198
|
# @api private
|