@simtlix/simfinity-js 3.4.1 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGGREGATION_CHANGES_SUMMARY.md +3 -2
- package/README.md +9 -1
- package/package.json +31 -11
- package/src/mongo/adapter.js +21 -0
- package/src/mongo/integrity.js +26 -4
- package/src/mongo/models.js +19 -18
- package/src/mongo/queries.js +62 -100
|
@@ -55,8 +55,9 @@ const QLAggregationOperation = new GraphQLEnumType({
|
|
|
55
55
|
|
|
56
56
|
#### New Functions
|
|
57
57
|
|
|
58
|
-
##### buildFieldPath(gqltype, fieldPath)
|
|
59
|
-
-
|
|
58
|
+
##### buildFieldPath(gqltype, fieldPath) (historical, removed)
|
|
59
|
+
- Removed: groupId and fact paths are now resolved by the same validated resolver as filters, must end in a scalar field, and relation lookups use reserved `__sf_lN` aliases.
|
|
60
|
+
- Resolved a field path string (e.g., "category" or "country.name") to MongoDB field paths
|
|
60
61
|
- Handles:
|
|
61
62
|
- Direct scalar fields
|
|
62
63
|
- Embedded object fields
|
package/README.md
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
MongoDB/Mongoose adapter and compatibility facade for Simfinity. This package generates GraphQL queries, mutations, relationships and models from `GraphQLObjectType` definitions using the shared Simfinity runtime.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.
|
|
6
|
+
npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.24.2
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
`graphql` and `mongoose` are peer dependencies: the facade uses your application's copies, and npm reports an out-of-range version as a peer conflict instead of installing a second copy. The MCP transports (`createMCPServer`, `startStdioMCPServer`, `createHTTPMCPHandler`) need the optional peer `@modelcontextprotocol/sdk@^1.13.0`; install it only when you use them. `generateMCPTools` works without it. The package does not install `graphql-middleware`.
|
|
10
|
+
|
|
9
11
|
```javascript
|
|
10
12
|
import mongoose from 'mongoose';
|
|
11
13
|
import * as simfinity from '@simtlix/simfinity-js';
|
|
@@ -18,6 +20,10 @@ const schema = simfinity.createSchema();
|
|
|
18
20
|
|
|
19
21
|
Use a MongoDB replica set or sharded cluster for transactional mutations. The library requires Node.js >=18.18.0 and GraphQL 16.
|
|
20
22
|
|
|
23
|
+
Generated single-reference reads can be batched within a request. Models with any `find` or `findOne` pre/post query hook retain individual `findOne` reads, including hooks shared by both operations, to preserve access restrictions and other middleware behavior. See the [relationship contract](https://simtlix.github.io/simfinity.js/guide/relationships.html).
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
21
27
|
## Optional transactional reference integrity
|
|
22
28
|
|
|
23
29
|
Choose the mode once when creating an adapter; the default remains `'off'`:
|
|
@@ -40,6 +46,8 @@ Owned transactions use snapshot reads and majority commits. Supplied sessions ne
|
|
|
40
46
|
|
|
41
47
|
The source now lives in `packages/mongodb` in the monorepo. Its public npm name remains `@simtlix/simfinity-js`; existing imports, including paths under `@simtlix/simfinity-js/src/`, retain their layout. Shared helpers, error identities and MCP compatibility exports are preserved. Internal Simfinity dependencies use the same exact release version.
|
|
42
48
|
|
|
49
|
+
For tooling that locates the package, use `require.resolve('@simtlix/simfinity-js/package.json')` or the package root without a trailing slash. Directory specifiers ending in `/` were not valid ESM imports and are not supported by the export map, including in `require.resolve`; use explicit filenames such as `src/auth/index.js`.
|
|
50
|
+
|
|
43
51
|
- [MongoDB quick start](https://simtlix.github.io/simfinity.js/guide/getting-started.html)
|
|
44
52
|
- [API reference](https://simtlix.github.io/simfinity.js/reference/api.html)
|
|
45
53
|
- [Database comparison](https://simtlix.github.io/simfinity.js/guide/databases.html)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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",
|
|
@@ -16,21 +16,21 @@
|
|
|
16
16
|
"directory": "packages/mongodb"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
19
20
|
"graphql": "^16.11.0",
|
|
20
|
-
"mongoose": "^8.
|
|
21
|
+
"mongoose": "^8.24.2"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"@modelcontextprotocol/sdk": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
21
27
|
},
|
|
22
28
|
"config": {
|
|
23
29
|
"owner": "simtlix"
|
|
24
30
|
},
|
|
25
|
-
"optionalDependencies": {
|
|
26
|
-
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
27
|
-
"graphql": "^16.11.0",
|
|
28
|
-
"graphql-middleware": "^6.1.35",
|
|
29
|
-
"mongoose": "^8.16.2"
|
|
30
|
-
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@simtlix/simfinity-core": "3.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.5.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.5.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
|
@@ -39,5 +39,25 @@
|
|
|
39
39
|
"LICENSE",
|
|
40
40
|
"AGGREGATION_EXAMPLE.md",
|
|
41
41
|
"AGGREGATION_CHANGES_SUMMARY.md"
|
|
42
|
-
]
|
|
42
|
+
],
|
|
43
|
+
"keywords": [
|
|
44
|
+
"simfinity",
|
|
45
|
+
"graphql",
|
|
46
|
+
"mongodb",
|
|
47
|
+
"mongoose",
|
|
48
|
+
"crud",
|
|
49
|
+
"nodejs"
|
|
50
|
+
],
|
|
51
|
+
"exports": {
|
|
52
|
+
".": {
|
|
53
|
+
"types": "./types/index.d.ts",
|
|
54
|
+
"default": "./src/index.js"
|
|
55
|
+
},
|
|
56
|
+
"./src": "./src/index.js",
|
|
57
|
+
"./src/auth": "./src/auth/index.js",
|
|
58
|
+
"./src/*.js": "./src/*.js",
|
|
59
|
+
"./src/*": "./src/*.js",
|
|
60
|
+
"./package": "./package.json",
|
|
61
|
+
"./*": "./*"
|
|
62
|
+
}
|
|
43
63
|
}
|
package/src/mongo/adapter.js
CHANGED
|
@@ -10,6 +10,19 @@ mongoose.set('strictQuery', false);
|
|
|
10
10
|
|
|
11
11
|
const withSession = (query, session) => (session ? query.session(session) : query);
|
|
12
12
|
|
|
13
|
+
// Even a shared hook can branch on `this.op` or the filter shape. Preserve findOne semantics
|
|
14
|
+
// whenever either query operation has hooks instead of inferring equivalence from function identity.
|
|
15
|
+
const hasNoFindHooks = (hooks) => {
|
|
16
|
+
if (!(hooks?._pres instanceof Map) || !(hooks._posts instanceof Map)) return false;
|
|
17
|
+
return ['find', 'findOne'].every((operation) => !hooks._pres.get(operation)?.length
|
|
18
|
+
&& !hooks._posts.get(operation)?.length);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// getById reads through `findOne` and getByIds through `find`. Mongoose copies the schema's query
|
|
22
|
+
// hooks to the model when compiling it, so both registries must allow the batched read.
|
|
23
|
+
const batchesFindOne = (Model) => hasNoFindHooks(Model.schema?.s?.hooks)
|
|
24
|
+
&& (Model.Query?.prototype?._queryMiddleware == null || hasNoFindHooks(Model.Query.prototype._queryMiddleware));
|
|
25
|
+
|
|
13
26
|
export const createMongoAdapter = (options) => {
|
|
14
27
|
const integrity = createMongoIntegrity(options);
|
|
15
28
|
let queries;
|
|
@@ -82,6 +95,14 @@ export const createMongoAdapter = (options) => {
|
|
|
82
95
|
if (plain) query = query.lean();
|
|
83
96
|
return query;
|
|
84
97
|
},
|
|
98
|
+
getByIds(Model, ids) {
|
|
99
|
+
integrity.assertReady();
|
|
100
|
+
// When `find` middleware could return other records, fail before reading, so the runtime
|
|
101
|
+
// reads each ID once with getById.
|
|
102
|
+
if (!batchesFindOne(Model)) throw new Error(`${Model.modelName} query middleware requires reads by ID`);
|
|
103
|
+
// Trusted, so that the `sanitizeFilter` option does not wrap the operator in `$eq`.
|
|
104
|
+
return Model.find({ _id: mongoose.trusted({ $in: ids }) });
|
|
105
|
+
},
|
|
85
106
|
prepareUpdate(set, unset) {
|
|
86
107
|
return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
|
|
87
108
|
},
|
package/src/mongo/integrity.js
CHANGED
|
@@ -3,6 +3,9 @@ import { describeModels, SimfinityError } from '@simtlix/simfinity-core';
|
|
|
3
3
|
import { withMongoTransaction } from './transactions.js';
|
|
4
4
|
|
|
5
5
|
const LOCK_FIELD = '_simfinityReferenceLock';
|
|
6
|
+
// Startup audit bounds: distinct target ids checked per query and ids remembered per target.
|
|
7
|
+
const AUDIT_BATCH_SIZE = 1000;
|
|
8
|
+
const AUDIT_VERIFIED_LIMIT = 100000;
|
|
6
9
|
const transactionOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } };
|
|
7
10
|
const violation = () => new SimfinityError('Reference constraint violated', 'REFERENCE_CONSTRAINT_VIOLATION', 409);
|
|
8
11
|
const invalid = (message) => new SimfinityError(message, 'INVALID_MONGO_INTEGRITY_CONFIGURATION', 400);
|
|
@@ -165,21 +168,40 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
165
168
|
await Model.init();
|
|
166
169
|
}
|
|
167
170
|
const session = await connection.startSession();
|
|
171
|
+
// Distinct target ids awaiting one batched existence query, and ids already
|
|
172
|
+
// found in this snapshot, per target model.
|
|
173
|
+
const pending = new Map();
|
|
174
|
+
const verified = new Map();
|
|
175
|
+
const verify = async (target) => {
|
|
176
|
+
const ids = pending.get(target);
|
|
177
|
+
pending.delete(target);
|
|
178
|
+
const found = new Set((await models.get(target).collection.find(
|
|
179
|
+
{ _id: { $in: [...ids.values()] } }, { session, projection: { _id: 1 }, batchSize: ids.size + 1 },
|
|
180
|
+
).toArray()).map(({ _id }) => _id.toHexString()));
|
|
181
|
+
for (const key of ids.keys()) if (!found.has(key)) throw violation();
|
|
182
|
+
if ((verified.get(target)?.size || 0) + ids.size > AUDIT_VERIFIED_LIMIT) verified.delete(target);
|
|
183
|
+
if (!verified.has(target)) verified.set(target, new Set());
|
|
184
|
+
for (const key of ids.keys()) verified.get(target).add(key);
|
|
185
|
+
};
|
|
168
186
|
try {
|
|
169
187
|
session.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
|
|
170
188
|
for (const Model of models.values()) {
|
|
171
|
-
const
|
|
189
|
+
const projection = { _id: 1, [LOCK_FIELD]: 1 };
|
|
190
|
+
for (const { path } of outgoing.get(Model)) projection[path.join('.')] = 1;
|
|
191
|
+
const cursor = Model.collection.find({}, { session, projection });
|
|
172
192
|
try {
|
|
173
193
|
for await (const record of cursor) {
|
|
174
194
|
if (record[LOCK_FIELD] != null && record[LOCK_FIELD]?._bsontype !== 'ObjectId') {
|
|
175
195
|
throw invalid(`Existing ${LOCK_FIELD} data is incompatible with reference integrity`);
|
|
176
196
|
}
|
|
177
197
|
for (const { target, id } of modelReferences(Model, record)) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
const key = id.toHexString();
|
|
199
|
+
if (verified.get(target)?.has(key)) continue;
|
|
200
|
+
if (!pending.has(target)) pending.set(target, new Map());
|
|
201
|
+
if (pending.get(target).set(key, id).size >= AUDIT_BATCH_SIZE) await verify(target);
|
|
181
202
|
}
|
|
182
203
|
}
|
|
204
|
+
for (const target of [...pending.keys()]) await verify(target);
|
|
183
205
|
} finally { await cursor.close(); }
|
|
184
206
|
}
|
|
185
207
|
// Read-only startup audit. Mutation attempts use independently owned or supplied sessions.
|
package/src/mongo/models.js
CHANGED
|
@@ -24,32 +24,30 @@ const getListItemType = (type) => {
|
|
|
24
24
|
return unwrapNonNull(listType.ofType);
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
&& isCustomValidatedScalar(fieldType.ofType)
|
|
37
|
-
&& fieldType.ofType.baseScalarType === target;
|
|
27
|
+
// Follow validated-scalar chains to their storage scalar; a cyclic chain stops where it repeats.
|
|
28
|
+
export const resolveStorageScalar = (type) => {
|
|
29
|
+
let base = type;
|
|
30
|
+
const visited = new Set();
|
|
31
|
+
while (base instanceof GraphQLScalarType && base.baseScalarType && !visited.has(base)) {
|
|
32
|
+
visited.add(base);
|
|
33
|
+
base = base.baseScalarType;
|
|
34
|
+
}
|
|
35
|
+
return base;
|
|
38
36
|
};
|
|
39
37
|
|
|
38
|
+
const matchesScalar = (fieldType, target) => resolveStorageScalar(unwrapNonNull(fieldType)) === target;
|
|
39
|
+
|
|
40
40
|
const getEffectiveTypeName = (type) => (
|
|
41
|
-
type instanceof GraphQLScalarType
|
|
41
|
+
type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
|
|
42
42
|
);
|
|
43
43
|
|
|
44
44
|
const isGraphQLisoDate = (typeName) => (
|
|
45
45
|
typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
|
|
46
46
|
);
|
|
47
47
|
|
|
48
|
-
const listItemMatchesScalar = (listType, target) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|| (isCustomValidatedScalar(ofType) && ofType.baseScalarType === target);
|
|
52
|
-
};
|
|
48
|
+
const listItemMatchesScalar = (listType, target) => (
|
|
49
|
+
resolveStorageScalar(getListItemType(listType)) === target
|
|
50
|
+
);
|
|
53
51
|
|
|
54
52
|
const withUnique = (fieldEntry, mongoType) => (fieldEntry.extensions && fieldEntry.extensions.unique
|
|
55
53
|
? { type: mongoType, unique: true }
|
|
@@ -154,7 +152,10 @@ export const createMongoModel = (gqlType, onModelCreated, { createCollection = t
|
|
|
154
152
|
onModelCreated(model);
|
|
155
153
|
}
|
|
156
154
|
if (createCollection) {
|
|
157
|
-
|
|
155
|
+
// Mongoose already ignores NamespaceExists; report other failures instead of crashing the process.
|
|
156
|
+
Promise.resolve(model.createCollection()).catch((error) => {
|
|
157
|
+
console.warn(`Simfinity could not create MongoDB collection ${model.collection.collectionName}: ${error?.message ?? error}`);
|
|
158
|
+
});
|
|
158
159
|
}
|
|
159
160
|
return model;
|
|
160
161
|
};
|
package/src/mongo/queries.js
CHANGED
|
@@ -10,6 +10,8 @@ import mongoose from 'mongoose';
|
|
|
10
10
|
|
|
11
11
|
import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
|
|
12
12
|
|
|
13
|
+
import { resolveStorageScalar } from './models.js';
|
|
14
|
+
|
|
13
15
|
const isNonNullOfType = (fieldEntryType, graphQLType) => (
|
|
14
16
|
fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
|
|
15
17
|
);
|
|
@@ -47,14 +49,39 @@ const getConnectionStorageName = (childType, declaredFieldName) => {
|
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
const getEffectiveTypeName = (type) => (
|
|
50
|
-
type instanceof GraphQLScalarType
|
|
52
|
+
type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
|
|
51
53
|
);
|
|
52
54
|
|
|
53
55
|
const isGraphQLisoDate = (typeName) => (
|
|
54
56
|
typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
|
|
55
57
|
);
|
|
56
58
|
|
|
59
|
+
// GraphQL reserves `__` names, so these aliases never replace a stored field.
|
|
60
|
+
const LOOKUP_ALIAS_PREFIX = '__sf_l';
|
|
61
|
+
|
|
57
62
|
export const createMongoQueries = ({ getModel, getRegistrations }) => {
|
|
63
|
+
// One alias per relation path for each query build, keyed by the build's `included` accumulator.
|
|
64
|
+
const lookupAliases = new WeakMap();
|
|
65
|
+
|
|
66
|
+
const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
|
|
67
|
+
let aliases = lookupAliases.get(aggregationsIncluded);
|
|
68
|
+
if (!aliases) {
|
|
69
|
+
aliases = new Map();
|
|
70
|
+
lookupAliases.set(aggregationsIncluded, aliases);
|
|
71
|
+
}
|
|
72
|
+
if (!aliases.has(relationPath)) aliases.set(relationPath, `${LOOKUP_ALIAS_PREFIX}${aliases.size}`);
|
|
73
|
+
return aliases.get(relationPath);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const appendLookups = (aggregateClauses, aggregationsIncluded, lookups) => {
|
|
77
|
+
for (const [alias, { lookup, unwind }] of Object.entries(lookups)) {
|
|
78
|
+
if (!Object.hasOwn(aggregationsIncluded, alias)) {
|
|
79
|
+
aggregateClauses.push(lookup, unwind);
|
|
80
|
+
aggregationsIncluded[alias] = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
58
85
|
const buildRelationLookup = ({
|
|
59
86
|
collectionName, localField, foreignField, alias,
|
|
60
87
|
}) => ({
|
|
@@ -127,7 +154,7 @@ const queryNamedType = (type) => {
|
|
|
127
154
|
};
|
|
128
155
|
|
|
129
156
|
// Resolve GraphQL paths and storage paths together; supplied models own ID casting.
|
|
130
|
-
const resolveQueryPath = (gqltype, path) => {
|
|
157
|
+
const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
131
158
|
assertValidFilterPath(path);
|
|
132
159
|
const parts = path.split('.');
|
|
133
160
|
let currentType = gqltype;
|
|
@@ -153,7 +180,7 @@ const resolveQueryPath = (gqltype, path) => {
|
|
|
153
180
|
const declaredField = relation.connectionField || part;
|
|
154
181
|
const connField = isList ? getConnectionStorageName(fieldType, declaredField) : declaredField;
|
|
155
182
|
const localLeaf = isList ? '_id' : connField;
|
|
156
|
-
const alias =
|
|
183
|
+
const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
|
|
157
184
|
aggregateClauses[alias] = buildRelationLookup({
|
|
158
185
|
collectionName: relatedModel.collection.collectionName,
|
|
159
186
|
localField: mongoPath ? `${mongoPath}.${localLeaf}` : localLeaf,
|
|
@@ -209,7 +236,7 @@ const castFilterValue = (value, resolved) => {
|
|
|
209
236
|
const stored = resolved.storesStateName ? entry.name : entry.value;
|
|
210
237
|
return schemaType ? schemaType.cast(stored) : stored;
|
|
211
238
|
}
|
|
212
|
-
const parsed = (scalar
|
|
239
|
+
const parsed = resolveStorageScalar(scalar).parseValue(value);
|
|
213
240
|
return schemaType ? schemaType.cast(parsed) : parsed;
|
|
214
241
|
} catch {
|
|
215
242
|
throw filterError(`Invalid value for ${resolved.mongoPath}`);
|
|
@@ -239,7 +266,7 @@ const buildMatchesClause = (resolved, operator, value) => {
|
|
|
239
266
|
return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced) };
|
|
240
267
|
};
|
|
241
268
|
|
|
242
|
-
const buildQueryTerms = async (filterField, qlField, fieldName, gqltype) => {
|
|
269
|
+
const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
|
|
243
270
|
const aggregateClauses = {};
|
|
244
271
|
const matchesClauses = [];
|
|
245
272
|
if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
|
|
@@ -253,7 +280,7 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype) => {
|
|
|
253
280
|
for (const term of terms) {
|
|
254
281
|
if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
|
|
255
282
|
if (isObject) assertValidFilterPath(term.path);
|
|
256
|
-
const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName);
|
|
283
|
+
const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName, aggregationsIncluded);
|
|
257
284
|
Object.assign(aggregateClauses, resolved.aggregateClauses);
|
|
258
285
|
matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value));
|
|
259
286
|
}
|
|
@@ -303,15 +330,10 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
303
330
|
filterInput = { operator: condition.operator, value: condition.value };
|
|
304
331
|
}
|
|
305
332
|
|
|
306
|
-
const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype);
|
|
333
|
+
const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
|
|
307
334
|
if (!result) continue;
|
|
308
335
|
|
|
309
|
-
|
|
310
|
-
if (!aggregationsIncluded[prop]) {
|
|
311
|
-
aggregateClauses.push(aggregate.lookup, aggregate.unwind);
|
|
312
|
-
aggregationsIncluded[prop] = true;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
336
|
+
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
315
337
|
for (const matchClause of Object.values(result.matchesClauses)) {
|
|
316
338
|
for (const [matchKey, match] of Object.entries(matchClause)) {
|
|
317
339
|
parts.push({ [matchKey]: match });
|
|
@@ -357,15 +379,10 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
357
379
|
for (const [key, filterField] of Object.entries(input)) {
|
|
358
380
|
if (RESERVED_QUERY_KEYS.has(key)) continue;
|
|
359
381
|
const qlField = fields[key];
|
|
360
|
-
const result = await buildQueryTerms(filterField, qlField, key, gqltype);
|
|
382
|
+
const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
|
|
361
383
|
if (!result) continue;
|
|
362
384
|
|
|
363
|
-
|
|
364
|
-
if (!aggregationsIncluded[prop]) {
|
|
365
|
-
aggregateClauses.push(aggregate.lookup, aggregate.unwind);
|
|
366
|
-
aggregationsIncluded[prop] = true;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
385
|
+
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
369
386
|
for (const matchClause of Object.values(result.matchesClauses)) {
|
|
370
387
|
for (const [matchKey, match] of Object.entries(matchClause)) {
|
|
371
388
|
if (Object.hasOwn(flatMatchConditions, matchKey)) {
|
|
@@ -419,13 +436,8 @@ const validateSortTerms = (sort) => {
|
|
|
419
436
|
const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
|
|
420
437
|
const sortExpressions = {};
|
|
421
438
|
for (const sort of sortTerms) {
|
|
422
|
-
const resolved = resolveQueryPath(gqltype, sort.field);
|
|
423
|
-
|
|
424
|
-
if (!aggregationsIncluded[alias]) {
|
|
425
|
-
aggregateClauses.push(aggregate.lookup, aggregate.unwind);
|
|
426
|
-
aggregationsIncluded[alias] = true;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
439
|
+
const resolved = resolveQueryPath(gqltype, sort.field, aggregationsIncluded);
|
|
440
|
+
appendLookups(aggregateClauses, aggregationsIncluded, resolved.aggregateClauses);
|
|
429
441
|
sortExpressions[resolved.mongoPath] = sort.order === 'ASC' ? 1 : -1;
|
|
430
442
|
}
|
|
431
443
|
return { $sort: sortExpressions };
|
|
@@ -450,67 +462,14 @@ const buildQuery = async (input, gqltype, isCount) => {
|
|
|
450
462
|
|
|
451
463
|
aggregateClauses.push(...paging);
|
|
452
464
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const buildFieldPath = (gqltype, fieldPath) => {
|
|
457
|
-
assertValidFilterPath(fieldPath);
|
|
458
|
-
const pathParts = fieldPath.split('.');
|
|
459
|
-
const lookupPairs = [];
|
|
460
|
-
let currentPath = '';
|
|
461
|
-
let currentGQLType = gqltype;
|
|
462
|
-
|
|
463
|
-
for (const part of pathParts) {
|
|
464
|
-
const field = currentGQLType.getFields()[part];
|
|
465
|
-
if (!field) {
|
|
466
|
-
throw new Error(`Field ${part} not found in type ${currentGQLType.name}`);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const fieldType = unwrapListAndNonNull(field.type);
|
|
470
|
-
const relation = field.extensions?.relation;
|
|
471
|
-
|
|
472
|
-
if (fieldType instanceof GraphQLObjectType && relation && !relation.embedded) {
|
|
473
|
-
const { collectionName } = getModel(fieldType).collection;
|
|
474
|
-
const lookupAlias = currentPath ? `${currentPath}_${part}` : part;
|
|
475
|
-
const inverseList = isListType(field.type);
|
|
476
|
-
const declaredField = relation.connectionField || part;
|
|
477
|
-
const connField = inverseList
|
|
478
|
-
? getConnectionStorageName(fieldType, declaredField)
|
|
479
|
-
: declaredField;
|
|
480
|
-
const localField = inverseList
|
|
481
|
-
? (currentPath ? `${currentPath}._id` : '_id')
|
|
482
|
-
: (currentPath ? `${currentPath}.${connField}` : connField);
|
|
483
|
-
|
|
484
|
-
lookupPairs.push(buildRelationLookup({
|
|
485
|
-
collectionName,
|
|
486
|
-
localField,
|
|
487
|
-
foreignField: inverseList ? connField : '_id',
|
|
488
|
-
alias: lookupAlias,
|
|
489
|
-
}));
|
|
490
|
-
|
|
491
|
-
currentPath = lookupAlias;
|
|
492
|
-
currentGQLType = fieldType;
|
|
493
|
-
} else if (fieldType instanceof GraphQLObjectType && relation?.embedded) {
|
|
494
|
-
currentPath = currentPath ? `${currentPath}.${part}` : part;
|
|
495
|
-
currentGQLType = fieldType;
|
|
496
|
-
} else {
|
|
497
|
-
const leaf = part === 'id' ? '_id' : part;
|
|
498
|
-
currentPath = currentPath ? `${currentPath}.${leaf}` : leaf;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
465
|
+
// Joined documents only serve $match/$sort; returned rows keep their stored references.
|
|
466
|
+
const lookupAliasList = Object.keys(aggregationsIncluded);
|
|
467
|
+
if (lookupAliasList.length > 0) aggregateClauses.push({ $unset: lookupAliasList });
|
|
501
468
|
|
|
502
|
-
return
|
|
469
|
+
return aggregateClauses;
|
|
503
470
|
};
|
|
504
471
|
|
|
505
|
-
const
|
|
506
|
-
for (const { lookup, unwind } of lookupPairs) {
|
|
507
|
-
const alias = lookup.$lookup.as;
|
|
508
|
-
if (!aggregationsIncluded[alias]) {
|
|
509
|
-
aggregateClauses.push(lookup, unwind);
|
|
510
|
-
aggregationsIncluded[alias] = true;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
};
|
|
472
|
+
const RESERVED_FACT_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
|
514
473
|
|
|
515
474
|
const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
516
475
|
const aggregateClauses = [];
|
|
@@ -520,21 +479,25 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
|
520
479
|
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
|
|
521
480
|
if (matchStage) aggregateClauses.push(matchStage);
|
|
522
481
|
|
|
523
|
-
const { groupId, facts } = aggregationExpression;
|
|
524
|
-
|
|
525
|
-
|
|
482
|
+
const { groupId, facts } = aggregationExpression ?? {};
|
|
483
|
+
if (!groupId || !Array.isArray(facts) || facts.length === 0) throw filterError('Aggregation requires a groupId and facts');
|
|
484
|
+
const groupIdPath = resolveQueryPath(gqltype, groupId, aggregationsIncluded);
|
|
485
|
+
appendLookups(aggregateClauses, aggregationsIncluded, groupIdPath.aggregateClauses);
|
|
526
486
|
|
|
527
487
|
const groupStage = { $group: { _id: `$${groupIdPath.mongoPath}` } };
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
throw
|
|
488
|
+
const factNames = new Set();
|
|
489
|
+
|
|
490
|
+
// Positional accumulator names keep a fact named `_id` from replacing the group key.
|
|
491
|
+
facts.forEach((fact, index) => {
|
|
492
|
+
if (!Object.hasOwn(AGG_OP_TO_MONGO, fact?.operation)) throw filterError('Invalid aggregate operation');
|
|
493
|
+
if (typeof fact.factName !== 'string' || !PATH_SEGMENT_RE.test(fact.factName)
|
|
494
|
+
|| RESERVED_FACT_NAMES.has(fact.factName) || factNames.has(fact.factName)) {
|
|
495
|
+
throw filterError('Invalid or duplicate aggregation fact name');
|
|
536
496
|
}
|
|
537
|
-
|
|
497
|
+
factNames.add(fact.factName);
|
|
498
|
+
const factPath = resolveQueryPath(gqltype, fact.path, aggregationsIncluded);
|
|
499
|
+
appendLookups(aggregateClauses, aggregationsIncluded, factPath.aggregateClauses);
|
|
500
|
+
groupStage.$group[`fact_${index}`] = AGG_OP_TO_MONGO[fact.operation](factPath.mongoPath);
|
|
538
501
|
});
|
|
539
502
|
|
|
540
503
|
aggregateClauses.push(groupStage);
|
|
@@ -543,17 +506,16 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
|
543
506
|
$project: {
|
|
544
507
|
_id: 0,
|
|
545
508
|
groupId: '$_id',
|
|
546
|
-
facts: Object.fromEntries(facts.map((fact) => [fact.factName,
|
|
509
|
+
facts: Object.fromEntries(facts.map((fact, index) => [fact.factName, `$fact_${index}`])),
|
|
547
510
|
},
|
|
548
511
|
});
|
|
549
512
|
|
|
550
513
|
const sortTerms = input.sort ? validateSortTerms(input.sort) : null;
|
|
551
514
|
if (sortTerms) {
|
|
552
|
-
const factNames = facts.map((fact) => fact.factName);
|
|
553
515
|
const sortObject = {};
|
|
554
516
|
sortTerms.forEach((sortTerm) => {
|
|
555
517
|
const field = sortTerm.field || 'groupId';
|
|
556
|
-
const sortFieldPath = factNames.
|
|
518
|
+
const sortFieldPath = factNames.has(field) ? `facts.${field}` : 'groupId';
|
|
557
519
|
sortObject[sortFieldPath] = sortTerm.order === 'ASC' ? 1 : -1;
|
|
558
520
|
});
|
|
559
521
|
aggregateClauses.push({ $sort: sortObject });
|