@simtlix/simfinity-js 3.13.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGGREGATION_EXAMPLE.md +3 -1
- package/README.md +25 -0
- package/package.json +3 -3
- package/src/index.js +1 -0
- package/src/mongo/adapter.js +7 -4
- package/src/mongo/integrity.js +2 -2
- package/src/mongo/queries.js +226 -21
- package/src/mongo/transactions.js +9 -2
- package/types/index.d.ts +42 -21
package/AGGREGATION_EXAMPLE.md
CHANGED
|
@@ -510,6 +510,8 @@ query {
|
|
|
510
510
|
}
|
|
511
511
|
```
|
|
512
512
|
|
|
513
|
+
In Example 9, `episodes` is in J because `totalRevenue` crosses it. Its join preserves one row per episode, including one null row when none exists, so `seriesCount` counts those rows rather than distinct series. A collection used only by a filter becomes an `ANY` frame and does not multiply facts; beyond a joined prefix it tests the joined alias through an anchor. Explicit `ANY`/`ALL`/`NONE` and relation-filter `quantifier` always stay frames. See [aggregate collection semantics](../../docs/guide/queries.md#aggregate-records).
|
|
514
|
+
|
|
513
515
|
## Field Path Resolution
|
|
514
516
|
|
|
515
517
|
The `groupId` and `path` parameters support:
|
|
@@ -528,7 +530,7 @@ The `groupId` and `path` parameters support:
|
|
|
528
530
|
The aggregation queries are translated to MongoDB aggregation pipelines:
|
|
529
531
|
|
|
530
532
|
1. **$lookup**: Used for non-embedded related entities
|
|
531
|
-
2. **$unwind**:
|
|
533
|
+
2. **$unwind**: Expands collection prefixes used by `groupId` or facts (J), preserving their joined-row cardinality; filter-only collection frames use correlated `$lookup` existence probes without unwinding root rows
|
|
532
534
|
3. **$match**: Applied for filtering (before grouping)
|
|
533
535
|
4. **$group**: Groups by the specified field with aggregation operations
|
|
534
536
|
5. **$project**: Formats the final output with groupId and facts fields
|
package/README.md
CHANGED
|
@@ -42,6 +42,10 @@ Text filters compile to escaped regular expressions, so a value never becomes re
|
|
|
42
42
|
|
|
43
43
|
The adapter also declares `queryFeatures.referenceEmptiness`, so these operators cross single references and end at references and collections, such as `director IS_EMPTY`, `director.name IS_EMPTY` or `seasons IS_EMPTY`, and an embedded object with a reference or collection member follows the member rule. A crossing joins the target with `$lookup` and `$unwind`, and the target's `id` right after it is its record identity, which is empty exactly when the reference is. A reference or collection is a frame: one `$lookup` that keeps at most one record id, so it never repeats a document, matched with `"alias.0": { $exists: false }` and its `$nor`; a collection frame reads the child's connection field and also tests its local key for null, because `$lookup` matches a missing local key with every child whose connection field is null. The joins and frames run before the `$match`, so they also filter counts, aggregates and batched collection reads, and they probe the `_id` index or the index on the connection field, which generated models create and a supplied model needs to declare. A client's condition reads each target through its `find` scope, inside the `$lookup` pipeline, so a record the scope hides reads as missing, also on the way to a later hop. Conditions that middleware and scopes add, and `buildQuery()` called directly, read stored data, so a dangling reference, to a record that no longer exists, is empty. A related type without a model fails with `INVALID_FILTER_PATH` (400), as for the other operators, and supplied models on another database or connection and discriminator targets are not supported, as for every relation path. A collection inside an embedded type, which only a supplied model can declare, is read through the embedded object's `_id`, as relation paths read it. The [query scope guide](https://simtlix.github.io/simfinity.js/guide/query-scope.html) describes client and trusted conditions, and the [middleware guide](https://simtlix.github.io/simfinity.js/guide/middleware.html) how to edit client filters without detaching them.
|
|
44
44
|
|
|
45
|
+
The adapter declares `queryFeatures.quantifiers: true`. Ordinary filter paths through collections compile as correlated `ANY` frames: `reviews.rating GTE 4` keeps each parent once, so pagination counts roots and filters no longer multiply aggregate rows. Conditions in the same group that share a collection prefix must match the same child; separate groups have independent frames. Explicit `ANY`, `ALL` and `NONE` groups and `quantifier` field filters accept paths relative to the target element, including nested frames and references. `ALL` holds when every visible element matches and is true for an empty set; `ALL` without conditions is always true, including inside `OR`. Client target `find` scopes run inside each frame before its conditions, and `ALL` negates only the conditions; trusted conditions from middleware, scopes and direct `buildQuery()` calls read stored data. A reference inside a client frame reads its target through the scoped join in that sub-pipeline.
|
|
46
|
+
|
|
47
|
+
A quantified frame uses one `$lookup` with the child scope, inner joins and the condition, followed by `$limit: 1` and `$project: { _id: 1 }`, tested by the presence or absence of `"__sf_qN.0"`. Collection frames correlated through a reference or embedded `_id` start with a non-null check on the child's connection field; legacy aggregate collection joins whose local key is not the root `_id` (a collection reached through a reference or an embedded object) use the same guard, avoiding orphan matches through missing parents. Root-anchored aggregate collection joins keep their form. Reference frames need no guard. `groupId` and fact paths preserve their collection joins and row cardinality, while other aggregate filters use frames anchored on the relevant joined row. Sort paths through a collection reject with `INVALID_FILTER_PATH` (400), also for counted pages; sorts through references remain available. Existing own-field and reference predicates, embedded-list predicates and 2b emptiness forms retain their pipelines. Generated and batched collection reads use the same frame pipelines unchanged inside `$unionWith`. These concise lookups require MongoDB 5.0 or later; see the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) for correlation, scopes, depth and compatibility rules.
|
|
48
|
+
|
|
45
49
|
The facade registers generated models on your application's default Mongoose instance and does not change global Mongoose options such as `strictQuery`. Supplied models may use another connection, such as one from `mongoose.createConnection()`: generated mutations and `saveObject()` use the model's connection, and, in the default `referentialIntegrity: 'off'` mode, custom mutations use the default connection unless it is unused, never opened and without compiled models, also on its `useDb()` descendants, and the registered models share one MongoDB client; with transactional reference integrity they use the first protected model's connection. Named views need no session: they run the generated list path. Custom queries registered with `registerCustomQuery()` receive no session; a callback that needs a transaction opens it with the `transaction()` of the Mongoose connection whose models it reads. `createMongoAdapter().castId` accepts only an ObjectId or its 24-character hexadecimal string and throws `NOT_VALID_ID` (400) otherwise; create new identifiers with `new mongoose.Types.ObjectId()`. Owned transactions retry transient failures after a short random delay, and a write conflict that outlasts the retries fails with `TRANSACTION_RETRY_EXCEEDED` (409).
|
|
46
50
|
|
|
47
51
|
Mongoose 8.24.2 or later within version 8 is required to exclude the update-casting prototype-pollution vulnerability. Existing applications should update Mongoose and their dependency lockfile, then run `npm audit`; upgrading Simfinity does not refresh every transitive dependency already locked by the application.
|
|
@@ -91,3 +95,24 @@ For tooling that locates the package, use `require.resolve('@simtlix/simfinity-j
|
|
|
91
95
|
- [MCP integration](https://simtlix.github.io/simfinity.js/guide/mcp.html)
|
|
92
96
|
- [Complete library documentation](https://github.com/simtlix/simfinity.js#readme)
|
|
93
97
|
- [Aggregation examples](./AGGREGATION_EXAMPLE.md)
|
|
98
|
+
|
|
99
|
+
Controller `onSaveCommitted`, `onUpdateCommitted`, `onDeleteCommitted` and `onTransitionCommitted` callbacks run serially after the owning transaction confirms commit and attempts cleanup. `configureAfterCommit({ onError })` configures reporting per runtime; callback failures do not fail the committed write. Borrowed lifecycle writes join an adapter-owned attempt; externally owned native sessions do not provide observed commit notifications. Existing hooks remain transactional. See [the contract, payloads and outbox requirements](https://simtlix.github.io/simfinity.js/guide/controllers.html#after-commit-callbacks).
|
|
100
|
+
|
|
101
|
+
## Client metadata
|
|
102
|
+
|
|
103
|
+
Since 3.15.0, install the optional `@simtlix/simfinity-descriptor` package,
|
|
104
|
+
aligned with your Simfinity version. It exports `buildClientDescriptor(schema)`
|
|
105
|
+
and `createClientDescriptorHandler(schema, { authorize: 'public' })`. The builder
|
|
106
|
+
describes the exact `createSchema()` result; the explicitly mounted handler can
|
|
107
|
+
instead use an application access function. These functions are not facade/core
|
|
108
|
+
exports. The descriptor does not evaluate dynamic permissions or serialize
|
|
109
|
+
arbitrary validators.
|
|
110
|
+
|
|
111
|
+
Legacy introspection stays enabled by default in 3.x. To opt out, import
|
|
112
|
+
`configureIntrospection` from `@simtlix/simfinity-core/introspection` in a separate
|
|
113
|
+
bootstrap module and call it with `{ fieldExtensions: false }` **before importing
|
|
114
|
+
the runtime or facades**. That direct import requires core as a direct dependency
|
|
115
|
+
pinned to the facade's exact version, including under pnpm or Yarn PnP. A late
|
|
116
|
+
call fails without deleting installed metadata. Migrate clients selecting
|
|
117
|
+
`__Field.extensions` before opting out. See the [descriptor reference](https://simtlix.github.io/simfinity.js/reference/client-descriptor.html)
|
|
118
|
+
for the format, protected HTTP access and composition limits.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.15.0",
|
|
4
4
|
"description": "MongoDB/Mongoose adapter and compatibility facade for Simfinity GraphQL APIs",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"owner": "simtlix"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@simtlix/simfinity-core": "3.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.15.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.15.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const runtime = createRuntime(mongoAdapter);
|
|
|
15
15
|
|
|
16
16
|
export const configureQueryLimits = runtime.configureQueryLimits;
|
|
17
17
|
export const configureMutationLimits = runtime.configureMutationLimits;
|
|
18
|
+
export const configureAfterCommit = runtime.configureAfterCommit;
|
|
18
19
|
export const connect = runtime.connect;
|
|
19
20
|
export const addNoEndpointType = runtime.addNoEndpointType;
|
|
20
21
|
export const createSchema = runtime.createSchema;
|
package/src/mongo/adapter.js
CHANGED
|
@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
|
|
|
2
2
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
3
3
|
import { MAX_RECORD_VERSION, isProtectedType, isVersionField, readStoredVersion, retainStoredVersion } from '@simtlix/simfinity-core/internal/record-version';
|
|
4
4
|
import { getListShape, normalizeConnectionField } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
5
|
+
import { createTransactionCallbacks } from '@simtlix/simfinity-core/internal/transaction-callbacks';
|
|
5
6
|
|
|
6
7
|
import { createMongoModel } from './models.js';
|
|
7
8
|
import { MONGO_QUERY_OPERATORS, createMongoQueries } from './queries.js';
|
|
@@ -16,7 +17,7 @@ const withSession = (query, session) => (session ? query.session(session) : quer
|
|
|
16
17
|
const WRITE_FEATURES = Object.freeze({ optimisticConcurrency: true });
|
|
17
18
|
// Every filter operator the query compiler supports, and emptiness through references and collections (#108 part
|
|
18
19
|
// 2b); the runtime rejects any other operator.
|
|
19
|
-
const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true });
|
|
20
|
+
const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true, quantifiers: true });
|
|
20
21
|
|
|
21
22
|
const assertVersionModel = ({ gqltype, model }) => {
|
|
22
23
|
if (!model || !isProtectedType(gqltype)) return;
|
|
@@ -255,7 +256,8 @@ const nestedPathOf = (nested) => {
|
|
|
255
256
|
};
|
|
256
257
|
|
|
257
258
|
export const createMongoAdapter = (options) => {
|
|
258
|
-
const
|
|
259
|
+
const callbacks = createTransactionCallbacks();
|
|
260
|
+
const integrity = createMongoIntegrity(options, callbacks);
|
|
259
261
|
const collectionBatching = readCollectionBatching(options);
|
|
260
262
|
let queries;
|
|
261
263
|
let getRegistrations = () => [];
|
|
@@ -303,8 +305,8 @@ export const createMongoAdapter = (options) => {
|
|
|
303
305
|
|
|
304
306
|
// A supplied session or model is used as is; only an owned session without a model looks up
|
|
305
307
|
// the registrations.
|
|
306
|
-
const withOwnedTransaction = (session, body, Model,
|
|
307
|
-
session, body, (Model || session) ? Model : sessionModel(),
|
|
308
|
+
const withOwnedTransaction = (session, body, Model, transactionOptions) => withMongoTransaction(
|
|
309
|
+
session, body, (Model || session) ? Model : sessionModel(), transactionOptions, callbacks,
|
|
308
310
|
);
|
|
309
311
|
|
|
310
312
|
const versionedRead = (Model, query, many = false) => getRegistrations().some(
|
|
@@ -364,6 +366,7 @@ export const createMongoAdapter = (options) => {
|
|
|
364
366
|
castId: castObjectId,
|
|
365
367
|
initialize: integrity.initialize,
|
|
366
368
|
withTransaction: integrity.enabled ? integrity.withTransaction : withOwnedTransaction,
|
|
369
|
+
enqueueAfterCommit: callbacks.enqueue,
|
|
367
370
|
newRecord(Model, data, session) {
|
|
368
371
|
const record = new Model(data);
|
|
369
372
|
record.$session(session);
|
package/src/mongo/integrity.js
CHANGED
|
@@ -59,7 +59,7 @@ const valuesAt = (value, path) => {
|
|
|
59
59
|
// createMongoAdapter's options: the adapter reads the collection batching ones.
|
|
60
60
|
const ADAPTER_OPTIONS = ['referentialIntegrity', 'batchedCollections', 'collectionBatchSize'];
|
|
61
61
|
|
|
62
|
-
export const createMongoIntegrity = (options = {}) => {
|
|
62
|
+
export const createMongoIntegrity = (options = {}, callbacks) => {
|
|
63
63
|
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
64
64
|
|| Object.keys(options).some((key) => !ADAPTER_OPTIONS.includes(key))) {
|
|
65
65
|
throw invalid('Expected Mongo adapter options among referentialIntegrity, batchedCollections and collectionBatchSize');
|
|
@@ -316,7 +316,7 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
316
316
|
withTransaction(session, body, Model) {
|
|
317
317
|
assertReady();
|
|
318
318
|
if (session) assertSession(session);
|
|
319
|
-
return withMongoTransaction(session, body, Model || models.values().next().value, transactionOptions);
|
|
319
|
+
return withMongoTransaction(session, body, Model || models.values().next().value, transactionOptions, callbacks);
|
|
320
320
|
},
|
|
321
321
|
async saveRecord(Model, record, session) {
|
|
322
322
|
assertWrite(Model, session);
|
package/src/mongo/queries.js
CHANGED
|
@@ -10,7 +10,8 @@ import mongoose from 'mongoose';
|
|
|
10
10
|
|
|
11
11
|
import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
|
|
12
12
|
import {
|
|
13
|
-
TEXT_QUERY_OPERATORS, describeEmptiness, effectiveScalarName, emptinessScopePaths,
|
|
13
|
+
TEXT_QUERY_OPERATORS, aggregateJoinPrefixes, crossingKeys, describeEmptiness, effectiveScalarName, emptinessScopePaths,
|
|
14
|
+
frameCollectionPaths, isEmptinessOperator, validateQuantifiedGroups,
|
|
14
15
|
textQueryOperator, validateTextFilterValue,
|
|
15
16
|
} from '@simtlix/simfinity-core/internal/query-operators';
|
|
16
17
|
import {
|
|
@@ -43,6 +44,8 @@ export const MONGO_QUERY_OPERATORS = Object.freeze([
|
|
|
43
44
|
export const createMongoQueries = ({ getModel, getRegistrations }) => {
|
|
44
45
|
// One alias per relation path for each query build, keyed by the build's `included` accumulator.
|
|
45
46
|
const lookupAliases = new WeakMap();
|
|
47
|
+
// Only actual reference joins reached through embedded lists multiply a body's candidates.
|
|
48
|
+
const multiplyingBuilds = new WeakSet();
|
|
46
49
|
|
|
47
50
|
const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
|
|
48
51
|
let aliases = lookupAliases.get(aggregationsIncluded);
|
|
@@ -62,7 +65,7 @@ const relationBuild = (aggregationsIncluded) => {
|
|
|
62
65
|
let build = relationBuilds.get(aggregationsIncluded);
|
|
63
66
|
if (!build) {
|
|
64
67
|
build = {
|
|
65
|
-
aliases: new Map(), counts: { join: 0, frame: 0 }, scopes: new WeakMap(),
|
|
68
|
+
aliases: new Map(), counts: { join: 0, frame: 0, scope: 0, serial: 0 }, scopes: new WeakMap(), serials: new WeakMap(),
|
|
66
69
|
};
|
|
67
70
|
relationBuilds.set(aggregationsIncluded, build);
|
|
68
71
|
}
|
|
@@ -92,6 +95,7 @@ const buildRelationLookup = ({
|
|
|
92
95
|
{
|
|
93
96
|
$lookup: {
|
|
94
97
|
from: collectionName, foreignField, localField, as: alias,
|
|
98
|
+
...(foreignField !== '_id' && localField !== '_id' ? { pipeline: [{ $match: { [foreignField]: { $ne: null } } }] } : {}),
|
|
95
99
|
},
|
|
96
100
|
},
|
|
97
101
|
{ $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
|
|
@@ -195,13 +199,14 @@ const identifierLeaf = (model, schemaPath, gqltype) => {
|
|
|
195
199
|
};
|
|
196
200
|
|
|
197
201
|
// Resolve GraphQL paths and storage paths together; supplied models own ID casting.
|
|
198
|
-
const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
202
|
+
const resolveQueryPath = (gqltype, path, aggregationsIncluded, origin) => {
|
|
199
203
|
assertValidFilterPath(path);
|
|
200
204
|
const parts = path.split('.');
|
|
201
205
|
let currentType = gqltype;
|
|
202
|
-
let currentModel = getModel(gqltype);
|
|
203
|
-
let mongoPath = '';
|
|
204
|
-
let schemaPath = '';
|
|
206
|
+
let currentModel = origin?.model ?? getModel(gqltype);
|
|
207
|
+
let mongoPath = origin?.parent.key ?? '';
|
|
208
|
+
let schemaPath = origin?.parent.schemaPath ?? '';
|
|
209
|
+
let vector = false;
|
|
205
210
|
const aggregateClauses = {};
|
|
206
211
|
|
|
207
212
|
for (const [index, part] of parts.entries()) {
|
|
@@ -218,6 +223,7 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
218
223
|
if (fieldType instanceof GraphQLObjectType) {
|
|
219
224
|
if (index === parts.length - 1) throw filterError(`Query path must end in a scalar field: ${path}`, 'INVALID_FILTER_PATH');
|
|
220
225
|
if (!relation || relation.embedded) {
|
|
226
|
+
vector ||= !!getListShape(field.type);
|
|
221
227
|
mongoPath = mongoPath ? `${mongoPath}.${part}` : part;
|
|
222
228
|
schemaPath = schemaPath ? `${schemaPath}.${part}` : part;
|
|
223
229
|
} else {
|
|
@@ -229,6 +235,8 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
229
235
|
? normalizeConnectionField(fieldType, relation.connectionField || part).storageFieldName
|
|
230
236
|
: getFieldStorageName(part, field);
|
|
231
237
|
const localLeaf = isList ? '_id' : connField;
|
|
238
|
+
if (vector) multiplyingBuilds.add(aggregationsIncluded);
|
|
239
|
+
vector = false;
|
|
232
240
|
const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
|
|
233
241
|
aggregateClauses[alias] = buildRelationLookup({
|
|
234
242
|
collectionName: relatedModel.collection.collectionName,
|
|
@@ -394,7 +402,7 @@ const compileTargetScope = async (build, scope, targetType) => {
|
|
|
394
402
|
const stages = [];
|
|
395
403
|
const match = await collectFiltersAndLookups(scope, targetType, stages, {}, 'find');
|
|
396
404
|
if (match) stages.push(match);
|
|
397
|
-
const result = stages.length ? { token: `s${build.
|
|
405
|
+
const result = stages.length ? { token: `s${build.counts.scope++}`, stages } : null;
|
|
398
406
|
if (!build.scopes.has(scope)) build.scopes.set(scope, new Map());
|
|
399
407
|
build.scopes.get(scope).set(targetType, result);
|
|
400
408
|
return result;
|
|
@@ -491,6 +499,179 @@ const buildEmptinessClause = async (gqltype, path, operator, value, construct, l
|
|
|
491
499
|
return form(ctx, shape, parent, type, segments.at(-1));
|
|
492
500
|
};
|
|
493
501
|
|
|
502
|
+
// A build keeps one rewrite context; aggregate J paths must survive every logical-group pass.
|
|
503
|
+
const rewriteContext = (gqltype, operation = 'find', aggregation) => {
|
|
504
|
+
const ctx = { gqltype, operation, quantifiers: true, referenceEmptiness: true, operators: new Set(MONGO_QUERY_OPERATORS), base: '', inFrame: false };
|
|
505
|
+
// Ordinary root markers reject at the existing post-value guards, preserving native order.
|
|
506
|
+
ctx.deferOrdinaryRootMarkers = true;
|
|
507
|
+
ctx.J = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, ctx) : new Set();
|
|
508
|
+
return ctx;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Cross references in the containing pipeline. Collection steps occur here only for an aggregate
|
|
512
|
+
// anchor: all ordinary collection crossings have already become frames in the shared rewrite.
|
|
513
|
+
const crossFramePath = async (gqltype, segments, scopes, build, lookups, included) => {
|
|
514
|
+
let type = gqltype;
|
|
515
|
+
let parent = { key: '', schemaPath: '', path: '' };
|
|
516
|
+
let model = getModel(type);
|
|
517
|
+
const chain = [];
|
|
518
|
+
let scoped = false;
|
|
519
|
+
let vector = false;
|
|
520
|
+
for (const name of segments) {
|
|
521
|
+
const field = type.getFields()[name];
|
|
522
|
+
const targetType = getNamedType(field.type);
|
|
523
|
+
const path = joinPath(parent.path, name);
|
|
524
|
+
const relation = field.extensions?.relation;
|
|
525
|
+
if (!relation || relation.embedded) {
|
|
526
|
+
vector ||= !!getListShape(field.type);
|
|
527
|
+
parent = { key: joinPath(parent.key, name), schemaPath: joinPath(parent.schemaPath, name), path };
|
|
528
|
+
} else {
|
|
529
|
+
const target = getModel(targetType);
|
|
530
|
+
if (!target) throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
|
|
531
|
+
const collection = !!getListShape(field.type);
|
|
532
|
+
const foreignField = collection ? normalizeConnectionField(targetType, relation.connectionField || name).storageFieldName : '_id';
|
|
533
|
+
const localField = joinPath(parent.key, collection ? '_id' : getFieldStorageName(name, field));
|
|
534
|
+
const scope = scopes && Object.hasOwn(scopes, path) ? await compileTargetScope(build, scopes[path], targetType) : null;
|
|
535
|
+
if (vector && scope) throw new SimfinityError('A reference inside an embedded list cannot apply a target scope', 'FORBIDDEN_FILTER_PATH', 403);
|
|
536
|
+
chain.push(scope ? scope.token : '-');
|
|
537
|
+
scoped ||= !!scope;
|
|
538
|
+
let alias;
|
|
539
|
+
if (!scoped) {
|
|
540
|
+
alias = allocateLookupAlias(included, path);
|
|
541
|
+
lookups[alias] = buildRelationLookup({ collectionName: target.collection.collectionName, localField, foreignField, alias });
|
|
542
|
+
} else {
|
|
543
|
+
alias = allocateRelationAlias(build, 'join', `${path} ${chain.join(',')}`);
|
|
544
|
+
// A vector with a null target scope keeps every target. The chain alias isolates it
|
|
545
|
+
// from a trusted join while its local key preserves the earlier restricted reference.
|
|
546
|
+
lookups[alias] = vector ? buildRelationLookup({ collectionName: target.collection.collectionName, localField, foreignField, alias }) : { stages: [
|
|
547
|
+
{ $lookup: { from: target.collection.collectionName, localField, foreignField, pipeline: [...(scope?.stages ?? []), { $limit: 1 }], as: alias } },
|
|
548
|
+
{ $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
|
|
549
|
+
] };
|
|
550
|
+
}
|
|
551
|
+
if (vector) multiplyingBuilds.add(included);
|
|
552
|
+
vector = false;
|
|
553
|
+
parent = { key: alias, schemaPath: '', path };
|
|
554
|
+
model = target;
|
|
555
|
+
}
|
|
556
|
+
type = targetType;
|
|
557
|
+
}
|
|
558
|
+
return { type, parent, model, chain };
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// A non-emptiness marker is legal only inside a frame, on a condition crossing references. Its
|
|
562
|
+
// keys are relative to the element, just like 2b emptiness keys in the same sub-build.
|
|
563
|
+
const resolveConditionPath = async (gqltype, path, construct, included, ctx) => {
|
|
564
|
+
if (!hasScopeMarker(construct)) return resolveQueryPath(gqltype, path, included);
|
|
565
|
+
// buildQueryTerms rejects the marker immediately after buildMatchesClause; group conditions
|
|
566
|
+
// also retain their raw-condition guard. No accepted path parses or casts a value twice.
|
|
567
|
+
if (!ctx?.inFrame && ctx?.deferOrdinaryRootMarkers === true) return resolveQueryPath(gqltype, path, included);
|
|
568
|
+
if (!ctx?.inFrame) throw detachedFilter(`${construct.operator ?? 'EQ'} on ${joinPath(ctx?.base, path)} carries a client scope marker, but is not an emptiness condition`);
|
|
569
|
+
const paths = crossingKeys(path.split('.'), { gqltype });
|
|
570
|
+
const scopes = clientScopes(construct, `${construct.operator ?? 'EQ'} on ${joinPath(ctx.base, path)}`, paths);
|
|
571
|
+
const aggregateClauses = {};
|
|
572
|
+
const crossed = await crossFramePath(gqltype, path.split('.').slice(0, -1), scopes, relationBuild(included), aggregateClauses, included);
|
|
573
|
+
const resolved = resolveQueryPath(crossed.type, path.split('.').at(-1), included, crossed);
|
|
574
|
+
return { ...resolved, aggregateClauses: { ...aggregateClauses, ...resolved.aggregateClauses } };
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const compileFrame = async (ctx, quantifier, group) => {
|
|
578
|
+
const build = relationBuild(ctx.included);
|
|
579
|
+
const anchor = group.__implicit === true ? group.__anchor ?? [] : [];
|
|
580
|
+
const segments = [...anchor, ...group.path.split('.')];
|
|
581
|
+
const paths = [];
|
|
582
|
+
let type = ctx.gqltype;
|
|
583
|
+
let vector = false;
|
|
584
|
+
let multiplyingPrefix = false;
|
|
585
|
+
for (const [index, name] of segments.entries()) {
|
|
586
|
+
const field = type.getFields()[name];
|
|
587
|
+
if (field.extensions?.relation && !field.extensions.relation.embedded) {
|
|
588
|
+
if (index < segments.length - 1 && vector) multiplyingPrefix = true;
|
|
589
|
+
vector = false;
|
|
590
|
+
if (!getModel(getNamedType(field.type))) throw filterError(`Related model is not available for ${joinPath(ctx.base, segments.join('.'))}`, 'INVALID_FILTER_PATH');
|
|
591
|
+
if (!anchor.length || index >= anchor.length || getListShape(field.type)) paths.push(segments.slice(0, index + 1).join('.'));
|
|
592
|
+
} else {
|
|
593
|
+
vector ||= !!getListShape(field.type);
|
|
594
|
+
}
|
|
595
|
+
type = getNamedType(field.type);
|
|
596
|
+
}
|
|
597
|
+
const scopes = clientScopes(group, `${quantifier} on ${joinPath(ctx.base, segments.join('.'))}`, paths);
|
|
598
|
+
if (anchor.length && scopes) {
|
|
599
|
+
for (const path of paths.filter((key) => anchor.join('.').startsWith(key))) {
|
|
600
|
+
if (scopes[path] !== null) throw detachedFilter('Aggregate anchors require null J scopes and legacy reference joins');
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const emptyAll = quantifier === 'ALL' && !['conditions', 'ANY', 'ALL', 'NONE', 'AND', 'OR'].some((key) => group[key]?.length);
|
|
604
|
+
// A vector prefix belongs to this predicate, not to the containing result set. A
|
|
605
|
+
// self-correlated witness keeps its joins local without discarding any target. Aggregate
|
|
606
|
+
// anchors retain their intentional expansion. NONE tests the positive ANY witness; ALL
|
|
607
|
+
// looks for a prefix whose complete ALL test fails, including vacuous empty prefixes.
|
|
608
|
+
if (multiplyingPrefix && !anchor.length && !ctx.prefixIsolated && !emptyAll) {
|
|
609
|
+
const model = getModel(ctx.gqltype);
|
|
610
|
+
if (!model) throw filterError(`Related model is not available for ${ctx.gqltype.name}`, 'INVALID_FILTER_PATH');
|
|
611
|
+
if (!build.serials.has(group)) build.serials.set(group, build.counts.serial++);
|
|
612
|
+
const alias = allocateRelationAlias(build, 'frame', `prefix:${build.serials.get(group)}`);
|
|
613
|
+
const stages = [];
|
|
614
|
+
const included = {};
|
|
615
|
+
relationBuilds.set(included, { ...relationBuild(included), counts: build.counts, scopes: build.scopes });
|
|
616
|
+
const positive = await compileFrame({ ...ctx, stages, included, prefixIsolated: true }, quantifier === 'NONE' ? 'ANY' : quantifier, group);
|
|
617
|
+
if (quantifier === 'ALL' && !Object.keys(positive).length) return {};
|
|
618
|
+
ctx.stages.push({ $lookup: {
|
|
619
|
+
from: model.collection.collectionName, localField: '_id', foreignField: '_id',
|
|
620
|
+
pipeline: [...stages, { $match: quantifier === 'ALL' ? { $nor: [positive] } : positive }, { $limit: 1 }, { $project: { _id: 1 } }],
|
|
621
|
+
as: alias,
|
|
622
|
+
} });
|
|
623
|
+
ctx.included[alias] = true;
|
|
624
|
+
return { [`${alias}.0`]: { $exists: quantifier === 'ANY' } };
|
|
625
|
+
}
|
|
626
|
+
const lookups = {};
|
|
627
|
+
const crossed = await crossFramePath(ctx.gqltype, segments.slice(0, -1), anchor.length ? null : scopes, build, lookups, ctx.included);
|
|
628
|
+
const name = segments.at(-1);
|
|
629
|
+
const field = crossed.type.getFields()[name];
|
|
630
|
+
const targetType = getNamedType(field.type);
|
|
631
|
+
const path = segments.join('.');
|
|
632
|
+
const scope = scopes ? await compileTargetScope(build, scopes[path], targetType) : null;
|
|
633
|
+
if (emptyAll) return {};
|
|
634
|
+
// Allocate before compiling the body: descendants follow their parent's serial even though
|
|
635
|
+
// their lookups live inside its pipeline. No quantified frame shares a 2b emptiness frame.
|
|
636
|
+
if (!build.serials.has(group)) build.serials.set(group, build.counts.serial++);
|
|
637
|
+
const alias = allocateRelationAlias(build, 'frame', `group:${build.serials.get(group)} ${path} ${[...crossed.chain, scope ? scope.token : '-'].join(',')}`);
|
|
638
|
+
const stages = [];
|
|
639
|
+
const included = {};
|
|
640
|
+
relationBuilds.set(included, { ...relationBuild(included), counts: build.counts, scopes: build.scopes });
|
|
641
|
+
const childCtx = { ...rewriteContext(targetType), base: joinPath(ctx.base, path), inFrame: true };
|
|
642
|
+
const body = await buildFilterGroupMatch(group, targetType, stages, included, 0, childCtx);
|
|
643
|
+
if (quantifier === 'ALL' && body === null) return {};
|
|
644
|
+
// ALL negates one truth value for the actual child. With vector joins, negating
|
|
645
|
+
// individual expanded rows would incorrectly turn a bad sibling target into a failing
|
|
646
|
+
// child. Keep the complete positive body together, with its scope admission outside.
|
|
647
|
+
let bodyStages = stages;
|
|
648
|
+
let bodyMatch = quantifier === 'ALL' ? { $nor: [body] } : body;
|
|
649
|
+
if (quantifier === 'ALL' && multiplyingBuilds.has(included)) {
|
|
650
|
+
const witness = allocateRelationAlias(build, 'frame', `body:${build.serials.get(group)}`);
|
|
651
|
+
bodyStages = [{ $lookup: {
|
|
652
|
+
from: getModel(targetType).collection.collectionName, localField: '_id', foreignField: '_id',
|
|
653
|
+
pipeline: [...stages, { $match: body }, { $limit: 1 }, { $project: { _id: 1 } }],
|
|
654
|
+
as: witness,
|
|
655
|
+
} }];
|
|
656
|
+
bodyMatch = { [`${witness}.0`]: { $exists: false } };
|
|
657
|
+
}
|
|
658
|
+
const collection = !!getListShape(field.type);
|
|
659
|
+
const localField = joinPath(crossed.parent.key, collection ? '_id' : getFieldStorageName(name, field));
|
|
660
|
+
const foreignField = collection ? normalizeConnectionField(targetType, field.extensions.relation.connectionField || name).storageFieldName : '_id';
|
|
661
|
+
lookups[alias] = { stages: [{ $lookup: {
|
|
662
|
+
from: getModel(targetType).collection.collectionName, localField, foreignField,
|
|
663
|
+
pipeline: [
|
|
664
|
+
...(collection && localField !== '_id' ? [{ $match: { [foreignField]: { $ne: null } } }] : []),
|
|
665
|
+
...(scope?.stages ?? []), ...bodyStages,
|
|
666
|
+
...(body === null ? [] : [{ $match: bodyMatch }]),
|
|
667
|
+
{ $limit: 1 }, { $project: { _id: 1 } },
|
|
668
|
+
],
|
|
669
|
+
as: alias,
|
|
670
|
+
} }] };
|
|
671
|
+
appendLookups(ctx.stages, ctx.included, lookups);
|
|
672
|
+
return { [`${alias}.0`]: { $exists: quantifier === 'ANY' } };
|
|
673
|
+
};
|
|
674
|
+
|
|
494
675
|
const castFilterValue = (value, resolved) => {
|
|
495
676
|
if (value === null) return null;
|
|
496
677
|
if (value === undefined || Array.isArray(value)
|
|
@@ -556,7 +737,7 @@ const buildMatchesClause = (resolved, operator, value, path) => {
|
|
|
556
737
|
return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced, path) };
|
|
557
738
|
};
|
|
558
739
|
|
|
559
|
-
const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
|
|
740
|
+
const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded, ctx) => {
|
|
560
741
|
const aggregateClauses = {};
|
|
561
742
|
const matchesClauses = [];
|
|
562
743
|
if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
|
|
@@ -606,10 +787,10 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
|
|
|
606
787
|
}
|
|
607
788
|
if (isObject) assertValidFilterPath(term.path);
|
|
608
789
|
const path = isObject ? `${fieldName}.${term.path}` : fieldName;
|
|
609
|
-
const resolved =
|
|
790
|
+
const resolved = await resolveConditionPath(gqltype, path, isObject ? term : null, aggregationsIncluded, ctx);
|
|
610
791
|
Object.assign(aggregateClauses, resolved.aggregateClauses);
|
|
611
792
|
matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value, path));
|
|
612
|
-
if (isObject && hasScopeMarker(term)) {
|
|
793
|
+
if (isObject && hasScopeMarker(term) && !ctx?.inFrame) {
|
|
613
794
|
throw detachedFilter(`${term.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
|
|
614
795
|
}
|
|
615
796
|
}
|
|
@@ -625,7 +806,15 @@ const validateLogicalLists = (group, keys = ['AND', 'OR', 'conditions']) => {
|
|
|
625
806
|
}
|
|
626
807
|
};
|
|
627
808
|
|
|
628
|
-
const buildFilterGroupMatch = async (
|
|
809
|
+
const buildFilterGroupMatch = async (rawFilterGroup, gqltype, aggregateClauses, aggregationsIncluded, depth = 0, rewriteCtx) => {
|
|
810
|
+
let filterGroup = rawFilterGroup;
|
|
811
|
+
let ctx = rewriteCtx;
|
|
812
|
+
if (!ctx) {
|
|
813
|
+
ctx = rewriteContext(gqltype);
|
|
814
|
+
const input = { AND: [filterGroup] };
|
|
815
|
+
validateQuantifiedGroups(input, ctx);
|
|
816
|
+
filterGroup = frameCollectionPaths(input, ctx).AND[0];
|
|
817
|
+
}
|
|
629
818
|
if (depth > MAX_FILTER_GROUP_DEPTH) {
|
|
630
819
|
throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
|
|
631
820
|
}
|
|
@@ -661,15 +850,15 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
661
850
|
throw new SimfinityError(`Filter on object field "${condition.field}" requires a path`, 'MISSING_FILTER_PATH', 400);
|
|
662
851
|
}
|
|
663
852
|
filterInput = {
|
|
664
|
-
terms: [{ path: condition.path, operator: condition.operator, value: condition.value }],
|
|
853
|
+
terms: [{ ...rawCondition, field: undefined, path: condition.path, operator: condition.operator, value: condition.value }],
|
|
665
854
|
};
|
|
666
855
|
} else {
|
|
667
856
|
if (condition.path != null && condition.path !== '') throw filterError(`Cannot traverse scalar field ${condition.field}`, 'INVALID_FILTER_PATH');
|
|
668
857
|
filterInput = { operator: condition.operator, value: condition.value };
|
|
669
858
|
}
|
|
670
859
|
|
|
671
|
-
const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
|
|
672
|
-
if (hasScopeMarker(rawCondition)) {
|
|
860
|
+
const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded, ctx);
|
|
861
|
+
if (hasScopeMarker(rawCondition) && !ctx.inFrame) {
|
|
673
862
|
throw detachedFilter(`${condition.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
|
|
674
863
|
}
|
|
675
864
|
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
@@ -681,9 +870,13 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
681
870
|
}
|
|
682
871
|
}
|
|
683
872
|
|
|
873
|
+
for (const quantifier of ['ANY', 'ALL', 'NONE']) {
|
|
874
|
+
for (const group of filterGroup[quantifier] ?? []) parts.push(await compileFrame({ ...ctx, stages: aggregateClauses, included: aggregationsIncluded }, quantifier, group));
|
|
875
|
+
}
|
|
876
|
+
|
|
684
877
|
if (filterGroup.AND?.length > 0) {
|
|
685
878
|
for (const subGroup of filterGroup.AND) {
|
|
686
|
-
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
|
|
879
|
+
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + (subGroup.__implicit === true ? 0 : 1), ctx);
|
|
687
880
|
if (subMatch) parts.push(subMatch);
|
|
688
881
|
}
|
|
689
882
|
}
|
|
@@ -691,7 +884,7 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
691
884
|
if (filterGroup.OR?.length > 0) {
|
|
692
885
|
const orParts = [];
|
|
693
886
|
for (const subGroup of filterGroup.OR) {
|
|
694
|
-
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
|
|
887
|
+
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + (subGroup.__implicit === true ? 0 : 1), ctx);
|
|
695
888
|
if (subMatch) orParts.push(subMatch);
|
|
696
889
|
}
|
|
697
890
|
if (orParts.length === 1) {
|
|
@@ -710,7 +903,10 @@ const FIND_CONTROL_KEYS = new Set(['pagination', 'sort', 'AND', 'OR']);
|
|
|
710
903
|
// `aggregation` is the expression argument only on aggregate queries; elsewhere it names a field.
|
|
711
904
|
const AGGREGATE_CONTROL_KEYS = new Set([...FIND_CONTROL_KEYS, 'aggregation']);
|
|
712
905
|
|
|
713
|
-
const collectFiltersAndLookups = async (
|
|
906
|
+
const collectFiltersAndLookups = async (rawInput, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find', aggregation) => {
|
|
907
|
+
const ctx = rewriteContext(gqltype, operation, aggregation);
|
|
908
|
+
validateQuantifiedGroups(rawInput, ctx);
|
|
909
|
+
const input = frameCollectionPaths(rawInput, ctx);
|
|
714
910
|
// Top-level arguments are field filters plus AND/OR groups; `conditions` is a key only inside groups.
|
|
715
911
|
validateLogicalLists(input, ['AND', 'OR']);
|
|
716
912
|
const controlKeys = operation === 'aggregate' ? AGGREGATE_CONTROL_KEYS : FIND_CONTROL_KEYS;
|
|
@@ -722,7 +918,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
722
918
|
for (const [key, filterField] of Object.entries(input)) {
|
|
723
919
|
if (controlKeys.has(key)) continue;
|
|
724
920
|
const qlField = fields[key];
|
|
725
|
-
const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
|
|
921
|
+
const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded, ctx);
|
|
726
922
|
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
727
923
|
for (const matchClause of Object.values(result.matchesClauses)) {
|
|
728
924
|
for (const [matchKey, match] of Object.entries(matchClause)) {
|
|
@@ -742,7 +938,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
742
938
|
|
|
743
939
|
if (input.AND?.length > 0) {
|
|
744
940
|
for (const group of input.AND) {
|
|
745
|
-
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
|
|
941
|
+
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded, 0, ctx);
|
|
746
942
|
if (groupMatch) topLevelAndParts.push(groupMatch);
|
|
747
943
|
}
|
|
748
944
|
}
|
|
@@ -750,7 +946,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
750
946
|
if (input.OR?.length > 0) {
|
|
751
947
|
const orParts = [];
|
|
752
948
|
for (const group of input.OR) {
|
|
753
|
-
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
|
|
949
|
+
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded, 0, ctx);
|
|
754
950
|
if (groupMatch) orParts.push(groupMatch);
|
|
755
951
|
}
|
|
756
952
|
if (orParts.length === 1) {
|
|
@@ -777,6 +973,15 @@ const validateSortTerms = (sort) => {
|
|
|
777
973
|
const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
|
|
778
974
|
const sortExpressions = {};
|
|
779
975
|
for (const sort of sortTerms) {
|
|
976
|
+
let type = gqltype;
|
|
977
|
+
for (const name of sort.field.split('.')) {
|
|
978
|
+
const field = type.getFields?.()[name];
|
|
979
|
+
if (!field) break;
|
|
980
|
+
if (field.extensions?.relation && !field.extensions.relation.embedded && getListShape(field.type)) {
|
|
981
|
+
throw filterError(`Sort path ${sort.field} crosses the collection ${type.name}.${name}; a record has no single value there`, 'INVALID_FILTER_PATH');
|
|
982
|
+
}
|
|
983
|
+
type = getNamedType(field.type);
|
|
984
|
+
}
|
|
780
985
|
const resolved = resolveQueryPath(gqltype, sort.field, aggregationsIncluded);
|
|
781
986
|
appendLookups(aggregateClauses, aggregationsIncluded, resolved.aggregateClauses);
|
|
782
987
|
sortExpressions[resolved.mongoPath] = sort.order === 'ASC' ? 1 : -1;
|
|
@@ -817,7 +1022,7 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
|
817
1022
|
const aggregationsIncluded = {};
|
|
818
1023
|
const paging = paginationStages(input.pagination, false);
|
|
819
1024
|
|
|
820
|
-
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate');
|
|
1025
|
+
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate', aggregationExpression);
|
|
821
1026
|
if (matchStage) aggregateClauses.push(matchStage);
|
|
822
1027
|
|
|
823
1028
|
const { groupId, facts } = aggregationExpression ?? {};
|
|
@@ -46,7 +46,7 @@ const endOwnedSession = async (session, failed) => {
|
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
export const withMongoTransaction = async (session, body, model, transactionOptions) => {
|
|
49
|
+
export const withMongoTransaction = async (session, body, model, transactionOptions, callbacks) => {
|
|
50
50
|
const connection = model?.db || mongoose.connection;
|
|
51
51
|
if (session) {
|
|
52
52
|
if (!session.inTransaction()) {
|
|
@@ -61,15 +61,21 @@ export const withMongoTransaction = async (session, body, model, transactionOpti
|
|
|
61
61
|
const mySession = connection === mongoose.connection
|
|
62
62
|
? await mongoose.startSession() : await connection.startSession();
|
|
63
63
|
let failed = false;
|
|
64
|
+
let confirmed;
|
|
64
65
|
try {
|
|
65
66
|
for (let attempt = 0; ; attempt++) {
|
|
66
67
|
if (transactionOptions) await mySession.startTransaction(transactionOptions);
|
|
67
68
|
else await mySession.startTransaction();
|
|
69
|
+
const effects = callbacks?.begin(mySession);
|
|
68
70
|
try {
|
|
69
71
|
const result = await body(mySession);
|
|
72
|
+
effects?.seal();
|
|
70
73
|
await commitWithRetry(mySession);
|
|
74
|
+
effects?.confirm();
|
|
75
|
+
confirmed = effects;
|
|
71
76
|
return result;
|
|
72
77
|
} catch (error) {
|
|
78
|
+
effects?.discard();
|
|
73
79
|
if (error?.errorLabels?.includes('UnknownTransactionCommitResult')) {
|
|
74
80
|
throw error;
|
|
75
81
|
}
|
|
@@ -96,6 +102,7 @@ export const withMongoTransaction = async (session, body, model, transactionOpti
|
|
|
96
102
|
failed = true;
|
|
97
103
|
throw error;
|
|
98
104
|
} finally {
|
|
99
|
-
|
|
105
|
+
// Cleanup can reject after commit. The confirmed effects still run, outside every retry catch.
|
|
106
|
+
try { await endOwnedSession(mySession, failed); } finally { await confirmed?.drain(); }
|
|
100
107
|
}
|
|
101
108
|
};
|
package/types/index.d.ts
CHANGED
|
@@ -13,10 +13,13 @@ import type {
|
|
|
13
13
|
GraphQLOutputType,
|
|
14
14
|
} from 'graphql';
|
|
15
15
|
import type {
|
|
16
|
+
AfterCommitOptions,
|
|
17
|
+
CommittedControllerCallbacks,
|
|
16
18
|
CreateSchemaOptions,
|
|
17
19
|
DatabaseAdapter,
|
|
18
20
|
MutationLimitsOptions,
|
|
19
21
|
QueryFeatures,
|
|
22
|
+
QueryLimitsOptions,
|
|
20
23
|
RuntimeRegistration,
|
|
21
24
|
SimfinityError,
|
|
22
25
|
ViewOptions,
|
|
@@ -35,6 +38,9 @@ export {
|
|
|
35
38
|
validators,
|
|
36
39
|
} from '@simtlix/simfinity-core';
|
|
37
40
|
export type {
|
|
41
|
+
AfterCommitErrorDetails,
|
|
42
|
+
AfterCommitOptions,
|
|
43
|
+
CommittedControllerCallbacks,
|
|
38
44
|
AuthPluginOptions,
|
|
39
45
|
AuthRule,
|
|
40
46
|
AuthRuleFunction,
|
|
@@ -50,6 +56,12 @@ export type {
|
|
|
50
56
|
MutationLimitsOptions,
|
|
51
57
|
PermissionSchema,
|
|
52
58
|
PolicyExpression,
|
|
59
|
+
QLFilterCondition,
|
|
60
|
+
QLFilterGroup,
|
|
61
|
+
QLQuantifiedGroup,
|
|
62
|
+
QLTypeFilterExpression,
|
|
63
|
+
QueryLimitsOptions,
|
|
64
|
+
QueryQuantifier,
|
|
53
65
|
ScopeFunction,
|
|
54
66
|
ScopeOperation,
|
|
55
67
|
ScopeParams,
|
|
@@ -103,7 +115,7 @@ export interface MongoAdapter extends DatabaseAdapter {
|
|
|
103
115
|
/** Frozen declaration of atomic expectedVersion writes; wrappers must forward every argument. */
|
|
104
116
|
readonly writeFeatures: Readonly<{ optimisticConcurrency: true }>;
|
|
105
117
|
/**
|
|
106
|
-
* Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true`. The literal text
|
|
118
|
+
* Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true` and `quantifiers: true`. The literal text
|
|
107
119
|
* operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
|
|
108
120
|
* `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z`, and `IEQ` with both, with
|
|
109
121
|
* the `i` option for the case-insensitive ones. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
|
|
@@ -148,8 +160,8 @@ export function getRegistrations(): RuntimeRegistration[];
|
|
|
148
160
|
* Core schema-building API (src/index.js)
|
|
149
161
|
* ========================================================================== */
|
|
150
162
|
|
|
151
|
-
/** Lifecycle hooks run before commit
|
|
152
|
-
export interface EntityController {
|
|
163
|
+
/** Lifecycle hooks run before commit; the inherited committed callbacks run after confirmed commit. */
|
|
164
|
+
export interface EntityController extends CommittedControllerCallbacks {
|
|
153
165
|
onSaving?(doc: any, args: any, session: any, context: any): void | Promise<void>;
|
|
154
166
|
onSaved?(result: any, args: any, session: any, context: any): void | Promise<void>;
|
|
155
167
|
onUpdating?(id: any, args: any, session: any, context: any): void | Promise<void>;
|
|
@@ -284,24 +296,16 @@ export function use(
|
|
|
284
296
|
/** Globally prevent Mongoose collection creation for generated models. */
|
|
285
297
|
export function preventCreatingCollection(prevent: boolean): void;
|
|
286
298
|
|
|
287
|
-
/** Process-wide limits for generated list and paginated aggregate queries. */
|
|
288
|
-
export interface QueryLimitsOptions {
|
|
289
|
-
/** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
|
|
290
|
-
maxPageSize?: number;
|
|
291
|
-
/**
|
|
292
|
-
* Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
|
|
293
|
-
* client's reference and collection emptiness may need in one list, aggregate or collection-field
|
|
294
|
-
* call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
|
|
295
|
-
*/
|
|
296
|
-
maxReferenceFrames?: number;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
* limits; an omitted option
|
|
300
|
+
* Process-wide limits for generated list and paginated aggregate queries. Configure at startup.
|
|
301
|
+
* Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps all limits; an omitted option
|
|
302
|
+
* takes its default again.
|
|
302
303
|
*/
|
|
303
304
|
export function configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
304
305
|
|
|
306
|
+
/** Configure the singleton runtime's best-effort reporting of after-commit callback failures. */
|
|
307
|
+
export function configureAfterCommit(options?: AfterCommitOptions): void;
|
|
308
|
+
|
|
305
309
|
/**
|
|
306
310
|
* Process-wide limit on nested added/updated/deleted entries per generated add, update or
|
|
307
311
|
* state-action mutation; unlimited by default, restored by calling it without options.
|
|
@@ -352,10 +356,13 @@ export function getType(
|
|
|
352
356
|
* Query building (src/index.js)
|
|
353
357
|
* ========================================================================== */
|
|
354
358
|
|
|
355
|
-
/** A QLFilterGroup input value
|
|
359
|
+
/** A QLFilterGroup input value: conditions, logical groups and element-relative quantified groups. */
|
|
356
360
|
export interface FilterGroupInput {
|
|
357
361
|
AND?: FilterGroupInput[];
|
|
358
362
|
OR?: FilterGroupInput[];
|
|
363
|
+
ANY?: QuantifiedGroupInput[];
|
|
364
|
+
ALL?: QuantifiedGroupInput[];
|
|
365
|
+
NONE?: QuantifiedGroupInput[];
|
|
359
366
|
conditions?: Array<{
|
|
360
367
|
field: string;
|
|
361
368
|
operator?: string;
|
|
@@ -367,6 +374,11 @@ export interface FilterGroupInput {
|
|
|
367
374
|
[key: string]: any;
|
|
368
375
|
}
|
|
369
376
|
|
|
377
|
+
/** A reference or collection frame; its body uses paths relative to the target element. */
|
|
378
|
+
export interface QuantifiedGroupInput extends FilterGroupInput {
|
|
379
|
+
path: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
370
382
|
/**
|
|
371
383
|
* Translate list-query arguments (filters, AND/OR, sort, pagination) into a MongoDB aggregation
|
|
372
384
|
* pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
|
|
@@ -403,16 +415,25 @@ export interface FilterGroupInput {
|
|
|
403
415
|
* that a scope hides reads as missing. A crossing or frame after a scoped crossing stays in that chain,
|
|
404
416
|
* also when its own scope is null. The aliases are unset at the end of find pipelines. A target type
|
|
405
417
|
* without a model fails with `INVALID_FILTER_PATH` (400), and a marker that is not filled for every
|
|
406
|
-
* path the construct reads, or that sits on a construct that reads
|
|
418
|
+
* path the construct reads, or that sits on a construct that reads no relation or on an ordinary
|
|
419
|
+
* condition outside a quantified frame, with
|
|
407
420
|
* `DETACHED_CLIENT_FILTER` (500), after the 400 checks. Supplied models on another database or
|
|
408
421
|
* connection and discriminator targets are not supported here, as for the other relation paths.
|
|
409
422
|
*
|
|
423
|
+
* Ordinary paths through collections compile as correlated `ANY` frames. Conditions in one group
|
|
424
|
+
* sharing a collection must hold on the same child, and each parent appears once. Explicit `ANY`,
|
|
425
|
+
* `ALL` and `NONE` groups and `quantifier` field filters accept element-relative conditions; `ALL`
|
|
426
|
+
* alone is true, including an OR branch. Target find scopes precede frame conditions; trusted
|
|
427
|
+
* constructs read stored data. References inside a client frame use its element-relative scope
|
|
428
|
+
* keys. Off-root collection correlation keys get an inner non-null connection guard. Aggregate
|
|
429
|
+
* groupId/fact collection prefixes keep legacy joins, and other filters can anchor frames on them.
|
|
430
|
+
* Collection sorts reject with `INVALID_FILTER_PATH`, including count mode.
|
|
431
|
+
*
|
|
410
432
|
* The checks, and their codes, are those of `createQueryPlan` in the same order: `MISSING_FILTER_PATH`
|
|
411
433
|
* for an emptiness operator on an object filter without terms or beside terms, `INVALID_FILTER_PATH`
|
|
412
434
|
* for a value inside an embedded list or a collection and for an interface or union field or member,
|
|
413
435
|
* `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through single embedded members,
|
|
414
|
-
* and `INVALID_FILTER_VALUE` for a value that is not omitted or null.
|
|
415
|
-
* pipelines.
|
|
436
|
+
* and `INVALID_FILTER_VALUE` for a value that is not omitted or null.
|
|
416
437
|
*/
|
|
417
438
|
export function buildQuery(
|
|
418
439
|
input: Record<string, any>,
|