@atscript/db-mongo 0.1.146 → 0.1.147
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/dist/agg.cjs +20 -17
- package/dist/agg.d.cts +7 -4
- package/dist/agg.d.mts +7 -4
- package/dist/agg.mjs +20 -17
- package/dist/index.cjs +472 -177
- package/dist/index.d.cts +90 -22
- package/dist/index.d.mts +90 -22
- package/dist/index.mjs +469 -179
- package/dist/mongo-accumulator-BJcQs2OV.cjs +605 -0
- package/dist/mongo-accumulator-Y_xVYo3C.mjs +534 -0
- package/dist/mongo-filter-CGd9ryOD.d.cts +123 -0
- package/dist/mongo-filter-CGd9ryOD.d.mts +123 -0
- package/package.json +9 -9
- package/dist/mongo-accumulator-BalE9LkU.cjs +0 -265
- package/dist/mongo-accumulator-Dc0w4t0l.mjs +0 -236
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { DbError, andFilters, containsRelationFilter, isFieldRef, isResolvedRelationFilter, walkFilter } from "@atscript/db";
|
|
2
|
+
import { BSONRegExp } from "mongodb";
|
|
3
|
+
import { assertAggregateFn } from "@atscript/db/agg";
|
|
4
|
+
//#region src/lib/lookup-join.ts
|
|
5
|
+
/**
|
|
6
|
+
* The correlation of a pipeline `$lookup`: `let` binds each outer field to a
|
|
7
|
+
* variable (`<prefix>k<i>`), and the first sub-pipeline stages match the
|
|
8
|
+
* inner field against it with SQL `=` semantics — a NULL or missing key on
|
|
9
|
+
* either side never relates (aggregation `$eq(null, null)` is true, and a
|
|
10
|
+
* missing `let` field would equal a missing inner field):
|
|
11
|
+
*
|
|
12
|
+
* 1. `$match: { $expr: { $eq: [inner, $$var] } }` (an `$and` of `$eq`s for a
|
|
13
|
+
* composite key) — kept bare so the lookup can use an index on the inner
|
|
14
|
+
* field(s); anything else inside that `$expr` turns every lookup into a
|
|
15
|
+
* collection scan;
|
|
16
|
+
* 2. `$match: { <inner>: { $ne: null } }` per key part — the NULL guard, as a
|
|
17
|
+
* query-level stage. A NULL / missing outer key (`$ifNull` folds "missing"
|
|
18
|
+
* into `null`) only `$eq`s inner NULL / missing values, which this stage
|
|
19
|
+
* drops.
|
|
20
|
+
*/
|
|
21
|
+
function correlate(prefix, pairs) {
|
|
22
|
+
if (pairs.length === 0) throw new Error("A $lookup correlation needs at least one key pair");
|
|
23
|
+
const vars = {};
|
|
24
|
+
const eqs = [];
|
|
25
|
+
const guard = {};
|
|
26
|
+
for (const [i, pair] of pairs.entries()) {
|
|
27
|
+
const name = `${prefix}k${i}`;
|
|
28
|
+
vars[name] = { $ifNull: [`$${pair.outer}`, null] };
|
|
29
|
+
eqs.push({ $eq: [`$${pair.inner}`, `$$${name}`] });
|
|
30
|
+
guard[pair.inner] = { $ne: null };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
let: vars,
|
|
34
|
+
stages: [{ $match: { $expr: eqs.length === 1 ? eqs[0] : { $and: eqs } } }, { $match: guard }]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/lib/mongo-view-expr.ts
|
|
39
|
+
/**
|
|
40
|
+
* `x IS NOT NULL` in SQL terms — true when `x` is neither null nor missing.
|
|
41
|
+
* Aggregation order puts a missing value below null and every other value
|
|
42
|
+
* above it, so one comparison covers both (`{ $ne: [x, null] }` alone would
|
|
43
|
+
* be true for a MISSING field).
|
|
44
|
+
* @since 0.1.136
|
|
45
|
+
*/
|
|
46
|
+
function notNullExpr(x) {
|
|
47
|
+
return { $gt: [x, null] };
|
|
48
|
+
}
|
|
49
|
+
/** `x IS NULL` in SQL terms — true when `x` is null or missing (see {@link notNullExpr}). @since 0.1.136 */
|
|
50
|
+
function isNullExpr(x) {
|
|
51
|
+
return { $lte: [x, null] };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `x`, with a missing value read as null — for a projected column (a missing
|
|
55
|
+
* key would drop it from the row) and a `$group` key (missing and null would
|
|
56
|
+
* form two groups). Only where a value may be missing: the wrapper hides the
|
|
57
|
+
* path from `$match` / `$sort` pushdown.
|
|
58
|
+
* @since 0.1.136
|
|
59
|
+
*/
|
|
60
|
+
function orNull(x) {
|
|
61
|
+
return { $ifNull: [x, null] };
|
|
62
|
+
}
|
|
63
|
+
/** A literal inside `$expr` — `$`-prefixed strings would otherwise read as field paths. */
|
|
64
|
+
function literal(value) {
|
|
65
|
+
return typeof value === "string" && value.startsWith("$") ? { $literal: value } : value;
|
|
66
|
+
}
|
|
67
|
+
const COMPARISONS = {
|
|
68
|
+
$eq: "$eq",
|
|
69
|
+
$ne: "$ne",
|
|
70
|
+
$gt: "$gt",
|
|
71
|
+
$gte: "$gte",
|
|
72
|
+
$lt: "$lt",
|
|
73
|
+
$lte: "$lte"
|
|
74
|
+
};
|
|
75
|
+
/** `cond` AND-guarded so that every field operand is non-null (SQL: NULL operand → not true). */
|
|
76
|
+
function guarded(operands, cond) {
|
|
77
|
+
return { $and: [...operands.map((o) => notNullExpr(o)), cond] };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Field-to-field comparison `x <op> y` (`$eq` … `$lte`): both operands must
|
|
81
|
+
* be non-null (SQL `NULL = NULL` is UNKNOWN) — view predicates and
|
|
82
|
+
* `buildMongoFilter`'s field operands.
|
|
83
|
+
* @since 0.1.137
|
|
84
|
+
*/
|
|
85
|
+
function fieldCompareExpr(op, x, y) {
|
|
86
|
+
return guarded([x, y], { [op]: [x, y] });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Translates a view predicate (join condition, conditional-aggregate filter)
|
|
90
|
+
* to an aggregation expression for `$match: { $expr }` / `$cond`, matching
|
|
91
|
+
* SQL's three-valued logic wherever a NULL operand makes SQL's comparison
|
|
92
|
+
* UNKNOWN (treated as false):
|
|
93
|
+
*
|
|
94
|
+
* - `<`, `<=`, `>`, `>=`, `!= <literal>`, field `=` field, field `!=` field
|
|
95
|
+
* and `not in` are guarded with "every field operand is not null";
|
|
96
|
+
* - `= null` / `not exists` → null-or-missing; `!= null` / `exists` → neither;
|
|
97
|
+
* - `= <literal>` and `in (…)` compare directly (a null operand never equals
|
|
98
|
+
* a non-null literal); an empty `not in` is true;
|
|
99
|
+
* - `and` / `or` / `not` map directly — so `not (x > 1)` is TRUE for a null
|
|
100
|
+
* `x` here while SQL yields UNKNOWN (documented divergence);
|
|
101
|
+
* - `matches` is rejected (`$regexMatch` needs MongoDB 4.2).
|
|
102
|
+
* @since 0.1.136
|
|
103
|
+
*/
|
|
104
|
+
function queryNodeToExpr(node, pathOf) {
|
|
105
|
+
if ("$and" in node) return { $and: node.$and.map((n) => queryNodeToExpr(n, pathOf)) };
|
|
106
|
+
if ("$or" in node) return { $or: node.$or.map((n) => queryNodeToExpr(n, pathOf)) };
|
|
107
|
+
if ("$not" in node) return { $not: [queryNodeToExpr(node.$not, pathOf)] };
|
|
108
|
+
const comp = node;
|
|
109
|
+
const x = pathOf(comp.left);
|
|
110
|
+
switch (comp.op) {
|
|
111
|
+
case "$exists": return comp.right === false ? isNullExpr(x) : notNullExpr(x);
|
|
112
|
+
case "$in": return { $in: [x, (Array.isArray(comp.right) ? comp.right : [comp.right]).map((v) => literal(v))] };
|
|
113
|
+
case "$nin": {
|
|
114
|
+
const values = Array.isArray(comp.right) ? comp.right : [comp.right];
|
|
115
|
+
if (values.length === 0) return { $literal: true };
|
|
116
|
+
return guarded([x], { $not: [{ $in: [x, values.map((v) => literal(v))] }] });
|
|
117
|
+
}
|
|
118
|
+
case "$regex": throw new Error("matches is not supported in view predicates");
|
|
119
|
+
default:
|
|
120
|
+
}
|
|
121
|
+
const op = COMPARISONS[comp.op];
|
|
122
|
+
if (!op) throw new Error(`Operator "${comp.op}" is not supported in view predicates`);
|
|
123
|
+
if (isFieldRef(comp.right)) return fieldCompareExpr(op, x, pathOf(comp.right));
|
|
124
|
+
if (comp.right === null || comp.right === void 0) {
|
|
125
|
+
if (op === "$eq") return isNullExpr(x);
|
|
126
|
+
if (op === "$ne") return notNullExpr(x);
|
|
127
|
+
return { $literal: false };
|
|
128
|
+
}
|
|
129
|
+
const value = literal(comp.right);
|
|
130
|
+
if (op === "$eq") return { $eq: [x, value] };
|
|
131
|
+
return guarded([x], { [op]: [x, value] });
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A computed view column (`@db.compute`) as an aggregation expression:
|
|
135
|
+
* `$add` / `$subtract` / `$multiply`; `/` → `$divide` guarded so a zero
|
|
136
|
+
* divisor yields null (`$divide` by 0 is an error in MongoDB); unary minus →
|
|
137
|
+
* `$multiply` by -1; `coalesce` → nested two-argument `$ifNull` (the
|
|
138
|
+
* multi-argument form needs MongoDB 5.0). A null or missing operand yields
|
|
139
|
+
* null, like SQL. Evaluated in double like the SQL adapters (MongoDB 4.0+):
|
|
140
|
+
* literals render `{ $toDouble: n }` and `operand` — which renders a field
|
|
141
|
+
* leaf (a view path) — must return a double too (`$toDouble` over a plain
|
|
142
|
+
* column; a computed operand is one already). Without the casts int/long
|
|
143
|
+
* arithmetic would stay exact past 2^53 where SQL rounds.
|
|
144
|
+
* @since 0.1.147
|
|
145
|
+
*/
|
|
146
|
+
function exprToMongo(node, operand) {
|
|
147
|
+
if (typeof node === "number") return { $toDouble: node };
|
|
148
|
+
if ("field" in node) return operand(node.field);
|
|
149
|
+
const args = node.args.map((arg) => exprToMongo(arg, operand));
|
|
150
|
+
switch (node.op) {
|
|
151
|
+
case "+": return { $add: args };
|
|
152
|
+
case "-": return { $subtract: args };
|
|
153
|
+
case "*": return { $multiply: args };
|
|
154
|
+
case "/": return { $cond: [
|
|
155
|
+
{ $eq: [args[1], 0] },
|
|
156
|
+
null,
|
|
157
|
+
{ $divide: args }
|
|
158
|
+
] };
|
|
159
|
+
case "neg": return { $multiply: [-1, args[0]] };
|
|
160
|
+
default: return args.reduceRight((rest, arg) => ({ $ifNull: [arg, rest] }));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/lib/mongo-filter.ts
|
|
165
|
+
const EMPTY = {};
|
|
166
|
+
function parseRegexString(value) {
|
|
167
|
+
if (value instanceof RegExp) return {
|
|
168
|
+
pattern: value.source,
|
|
169
|
+
flags: value.flags
|
|
170
|
+
};
|
|
171
|
+
const str = String(value);
|
|
172
|
+
const match = str.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
173
|
+
if (match) return {
|
|
174
|
+
pattern: match[1],
|
|
175
|
+
flags: match[2]
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
pattern: str,
|
|
179
|
+
flags: ""
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Earth radius in meters used by MongoDB's `$centerSphere` radians conversion
|
|
184
|
+
* (Mongo documents dividing by 6378.1 km).
|
|
185
|
+
*/
|
|
186
|
+
const EARTH_RADIUS_M = 6378100;
|
|
187
|
+
const mongoVisitor = {
|
|
188
|
+
comparison(field, op, value) {
|
|
189
|
+
if (op === "$eq") return { [field]: value };
|
|
190
|
+
if (op === "$exists") return value ? { [field]: { $ne: null } } : { [field]: null };
|
|
191
|
+
if (op === "$regex") {
|
|
192
|
+
const { pattern, flags } = parseRegexString(value);
|
|
193
|
+
return flags ? { [field]: {
|
|
194
|
+
$regex: pattern,
|
|
195
|
+
$options: flags
|
|
196
|
+
} } : { [field]: { $regex: pattern } };
|
|
197
|
+
}
|
|
198
|
+
if (op === "$geoWithin") {
|
|
199
|
+
const { center, radius } = value;
|
|
200
|
+
return { [field]: { $geoWithin: { $centerSphere: [center, radius / EARTH_RADIUS_M] } } };
|
|
201
|
+
}
|
|
202
|
+
return { [field]: { [op]: value } };
|
|
203
|
+
},
|
|
204
|
+
and(children) {
|
|
205
|
+
if (children.length === 0) return EMPTY;
|
|
206
|
+
if (children.length === 1) return children[0];
|
|
207
|
+
return { $and: children };
|
|
208
|
+
},
|
|
209
|
+
or(children) {
|
|
210
|
+
if (children.length === 0) return { _impossible: true };
|
|
211
|
+
if (children.length === 1) return children[0];
|
|
212
|
+
return { $or: children };
|
|
213
|
+
},
|
|
214
|
+
not(child) {
|
|
215
|
+
return { $nor: [child] };
|
|
216
|
+
},
|
|
217
|
+
relation(field, op) {
|
|
218
|
+
throw new DbError("REL_FILTER_NOT_SUPPORTED", [{
|
|
219
|
+
path: field,
|
|
220
|
+
message: `Relational predicate "${op}" on "${field}" reached a plain MongoDB filter — it needs an aggregation pipeline (buildMongoQuery)`
|
|
221
|
+
}]);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
/** A `{ $field: path }` comparison operand (a field-to-field comparison). */
|
|
225
|
+
function isFieldOperand(value) {
|
|
226
|
+
return value !== null && typeof value === "object" && typeof value.$field === "string";
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* {@link mongoVisitor} plus field-to-field comparisons (`{ $field }` operands →
|
|
230
|
+
* `$expr`), null-guarded like SQL ({@link fieldCompareExpr}).
|
|
231
|
+
*/
|
|
232
|
+
const fieldOperandVisitor = {
|
|
233
|
+
...mongoVisitor,
|
|
234
|
+
comparison(field, op, value) {
|
|
235
|
+
return isFieldOperand(value) ? { $expr: fieldCompareExpr(op, `$${field}`, `$${value.$field}`) } : mongoVisitor.comparison(field, op, value);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Translates a generic {@link FilterExpr} into a MongoDB-compatible
|
|
240
|
+
* {@link Filter} document.
|
|
241
|
+
*
|
|
242
|
+
* MongoDB's query language is nearly identical to the `FilterExpr` structure,
|
|
243
|
+
* so this is largely a structural pass-through via the `walkFilter` visitor.
|
|
244
|
+
* `fieldOperands` (view predicates only — `translateQueryTree` output) turns
|
|
245
|
+
* `{ $field: path }` operands into field-to-field `$expr` comparisons; a
|
|
246
|
+
* request filter never gets that reading.
|
|
247
|
+
*
|
|
248
|
+
* Predicate-free filters only: a relational predicate (`$some` / `$none`)
|
|
249
|
+
* throws `REL_FILTER_NOT_SUPPORTED` — render those with
|
|
250
|
+
* {@link buildMongoQuery} / {@link mongoFilterStages}.
|
|
251
|
+
*/
|
|
252
|
+
function buildMongoFilter(filter, { fieldOperands = false, collation } = {}) {
|
|
253
|
+
if (!filter || Object.keys(filter).length === 0) return EMPTY;
|
|
254
|
+
return walkFilter(filter, fieldOperands ? fieldOperandVisitor : collation ? collatedVisitor(collation) : mongoVisitor) ?? EMPTY;
|
|
255
|
+
}
|
|
256
|
+
/** Regex metacharacters (PCRE) escaped in an exact-match pattern. */
|
|
257
|
+
const REGEX_SPECIAL = /[\\^$.|?*+()[\]{}]/g;
|
|
258
|
+
/** A case-insensitive regex matching exactly `value` (`\z`: `$` would also match before a final newline). */
|
|
259
|
+
function exactNocase(value) {
|
|
260
|
+
return new BSONRegExp(`^${value.replace(REGEX_SPECIAL, "\\$&").replaceAll("\0", "\\x00")}\\z`, "i");
|
|
261
|
+
}
|
|
262
|
+
/** Operators whose result never depends on a string collation. */
|
|
263
|
+
const COLLATION_FREE_OPS = new Set([
|
|
264
|
+
"$regex",
|
|
265
|
+
"$exists",
|
|
266
|
+
"$geoWithin"
|
|
267
|
+
]);
|
|
268
|
+
const isStringOrHasString = (value) => typeof value === "string" || Array.isArray(value) && value.some((v) => typeof v === "string");
|
|
269
|
+
/**
|
|
270
|
+
* A comparison on a field with a non-binary collation, rendered without a
|
|
271
|
+
* query-level collation (`undefined`: the value is not collation-sensitive —
|
|
272
|
+
* render it as usual).
|
|
273
|
+
*/
|
|
274
|
+
function collatedComparison(field, op, value, collation) {
|
|
275
|
+
if (COLLATION_FREE_OPS.has(op) || !isStringOrHasString(value)) return;
|
|
276
|
+
if (collation === "nocase") {
|
|
277
|
+
if (op === "$eq") return { [field]: exactNocase(value) };
|
|
278
|
+
if (op === "$ne") return { [field]: { $not: exactNocase(value) } };
|
|
279
|
+
if ((op === "$in" || op === "$nin") && Array.isArray(value)) return { [field]: { [op]: value.map((v) => typeof v === "string" ? exactNocase(v) : v) } };
|
|
280
|
+
}
|
|
281
|
+
throw new DbError("REL_FILTER_NOT_SUPPORTED", [{
|
|
282
|
+
path: field,
|
|
283
|
+
message: `"${op}" on the ${collation} field "${field}" cannot be combined with relational predicates on MongoDB (a pipeline with $lookup stages cannot apply a per-field collation) — only $eq / $ne / $in / $nin / $regex / $exists on a 'nocase' field are supported there`
|
|
284
|
+
}]);
|
|
285
|
+
}
|
|
286
|
+
/** {@link mongoVisitor} with `collation`-aware comparisons ({@link collatedComparison}). */
|
|
287
|
+
function collatedVisitor(collation) {
|
|
288
|
+
return {
|
|
289
|
+
...mongoVisitor,
|
|
290
|
+
comparison(field, op, value) {
|
|
291
|
+
const collate = collation(field);
|
|
292
|
+
return (collate && collate !== "binary" ? collatedComparison(field, op, value, collate) : void 0) ?? mongoVisitor.comparison(field, op, value);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The per-field collation of a table, read from its (Mongo) adapter's
|
|
298
|
+
* `fieldCollation` — what a relational-predicate pipeline renders `'nocase'`
|
|
299
|
+
* fields with ({@link TMongoFilterOptions.collation}).
|
|
300
|
+
*/
|
|
301
|
+
function collationOfAdapter(adapter) {
|
|
302
|
+
const source = adapter;
|
|
303
|
+
return typeof source?.fieldCollation === "function" ? (field) => source.fieldCollation(field) : void 0;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Prefix of the temporary fields predicate lookups write (`<prefix><n>`) —
|
|
307
|
+
* long and specific so it cannot plausibly collide with a stored field; the
|
|
308
|
+
* fields are dropped with `$unset` before any row leaves the pipeline.
|
|
309
|
+
*
|
|
310
|
+
* @since 0.1.147
|
|
311
|
+
*/
|
|
312
|
+
const REL_FILTER_TEMP_PREFIX = "__atscript_rf_";
|
|
313
|
+
/** Splits a filter's top-level conjuncts (keys, `$and` members) by whether they hold a predicate. */
|
|
314
|
+
function splitConjuncts(filter, pre, post) {
|
|
315
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
316
|
+
if (key === "$and" && Array.isArray(value)) {
|
|
317
|
+
for (const child of value) splitConjuncts(child, pre, post);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const part = { [key]: value };
|
|
321
|
+
(containsRelationFilter(part) ? post : pre).push(part);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function isEmptyFilter(filter) {
|
|
325
|
+
return !filter || Object.keys(filter).length === 0;
|
|
326
|
+
}
|
|
327
|
+
function malformed(field, message) {
|
|
328
|
+
return new DbError("REL_FILTER_NOT_SUPPORTED", [{
|
|
329
|
+
path: field,
|
|
330
|
+
message
|
|
331
|
+
}]);
|
|
332
|
+
}
|
|
333
|
+
/** A per-query renderer: one temp-field / variable counter shared by every nesting level. */
|
|
334
|
+
function createPredicateRenderer() {
|
|
335
|
+
let seq = 0;
|
|
336
|
+
/** `collation`: the per-field collation of the table `filter` reads. */
|
|
337
|
+
const level = (filter, collation) => {
|
|
338
|
+
const preParts = [];
|
|
339
|
+
const postParts = [];
|
|
340
|
+
if (filter) splitConjuncts(filter, preParts, postParts);
|
|
341
|
+
const lookups = [];
|
|
342
|
+
const temp = [];
|
|
343
|
+
const visitor = {
|
|
344
|
+
...collation ? collatedVisitor(collation) : mongoVisitor,
|
|
345
|
+
relation(field, op, operand) {
|
|
346
|
+
if (!isResolvedRelationFilter(operand)) throw malformed(field, `Relational predicate "${op}" on "${field}" was not resolved by the table — query through the table API`);
|
|
347
|
+
const n = seq++;
|
|
348
|
+
const as = `${REL_FILTER_TEMP_PREFIX}${n}`;
|
|
349
|
+
lookups.push(lookupOf(operand, field, as, `rf${n}`));
|
|
350
|
+
temp.push(as);
|
|
351
|
+
return op === "$some" ? { [as]: { $ne: [] } } : { [as]: { $size: 0 } };
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const pre = preParts.length > 0 ? buildMongoFilter(andFilters(...preParts), { collation }) : void 0;
|
|
355
|
+
const match = postParts.length > 0 ? walkFilter(andFilters(...postParts), visitor) ?? EMPTY : EMPTY;
|
|
356
|
+
return {
|
|
357
|
+
pre: isEmptyFilter(pre) ? void 0 : pre,
|
|
358
|
+
lookups,
|
|
359
|
+
match,
|
|
360
|
+
temp
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* Stages narrowing a lookup's documents to `filter` (nested predicates
|
|
365
|
+
* included), compared with the looked-up table's own collation. No
|
|
366
|
+
* `$unset`: the lookup projects `_id` only.
|
|
367
|
+
*/
|
|
368
|
+
const filterStages = (filter, adapter) => stagesOf(level(filter, collationOfAdapter(adapter)), false);
|
|
369
|
+
const lookupOf = (node, field, as, vars) => {
|
|
370
|
+
const exists = [{ $limit: 1 }, { $project: { _id: 1 } }];
|
|
371
|
+
if (node.kind === "via") return viaLookupOf(node, field, as, vars, exists);
|
|
372
|
+
if (node.pairs.length === 0) throw malformed(field, `Relational predicate on "${field}" has no join key pairs`);
|
|
373
|
+
const join = correlate(vars, node.pairs.map((p) => ({
|
|
374
|
+
outer: p.source,
|
|
375
|
+
inner: p.target
|
|
376
|
+
})));
|
|
377
|
+
return { $lookup: {
|
|
378
|
+
from: node.target.name,
|
|
379
|
+
let: join.let,
|
|
380
|
+
pipeline: [
|
|
381
|
+
...join.stages,
|
|
382
|
+
...filterStages(node.filter, node.target.adapter),
|
|
383
|
+
...exists
|
|
384
|
+
],
|
|
385
|
+
as
|
|
386
|
+
} };
|
|
387
|
+
};
|
|
388
|
+
const viaLookupOf = (node, field, as, vars, exists) => {
|
|
389
|
+
const junction = node.junction;
|
|
390
|
+
if (!junction || junction.toSource.length === 0 || junction.toTarget.length === 0) throw malformed(field, `Relational predicate on the via relation "${field}" has no junction correlation`);
|
|
391
|
+
const n = seq++;
|
|
392
|
+
const targetAs = `${REL_FILTER_TEMP_PREFIX}${n}`;
|
|
393
|
+
const toSource = correlate(vars, junction.toSource.map((p) => ({
|
|
394
|
+
outer: p.source,
|
|
395
|
+
inner: p.junction
|
|
396
|
+
})));
|
|
397
|
+
const toTarget = correlate(`rf${n}`, junction.toTarget.map((p) => ({
|
|
398
|
+
outer: p.junction,
|
|
399
|
+
inner: p.target
|
|
400
|
+
})));
|
|
401
|
+
return { $lookup: {
|
|
402
|
+
from: junction.name,
|
|
403
|
+
let: toSource.let,
|
|
404
|
+
pipeline: [
|
|
405
|
+
...toSource.stages,
|
|
406
|
+
...filterStages(junction.filter, junction.adapter),
|
|
407
|
+
{ $lookup: {
|
|
408
|
+
from: node.target.name,
|
|
409
|
+
let: toTarget.let,
|
|
410
|
+
pipeline: [
|
|
411
|
+
...toTarget.stages,
|
|
412
|
+
...filterStages(node.filter, node.target.adapter),
|
|
413
|
+
...exists
|
|
414
|
+
],
|
|
415
|
+
as: targetAs
|
|
416
|
+
} },
|
|
417
|
+
{ $match: { [targetAs]: { $ne: [] } } },
|
|
418
|
+
...exists
|
|
419
|
+
],
|
|
420
|
+
as
|
|
421
|
+
} };
|
|
422
|
+
};
|
|
423
|
+
return level;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Renders a translated filter that may hold relational predicates
|
|
427
|
+
* (`{ nav: { $some | $none: ResolvedRelationFilter } }`) for an aggregation
|
|
428
|
+
* pipeline. Each predicate becomes a correlated `$lookup` into the related
|
|
429
|
+
* collection (`let` = the source key fields; a bare `$expr` `$eq` per key
|
|
430
|
+
* part — index-friendly — then a `{ <key>: { $ne: null } }` guard; the
|
|
431
|
+
* inner filter — nested predicates as lookups inside it — then
|
|
432
|
+
* `$limit: 1`); `via` looks up the junction, and inside it the target. The
|
|
433
|
+
* predicate itself reads the lookup's temporary array field
|
|
434
|
+
* ({@link REL_FILTER_TEMP_PREFIX}`<n>`). Top-level conjuncts without
|
|
435
|
+
* predicates go to `pre` so the lookups run only for documents that survive
|
|
436
|
+
* them.
|
|
437
|
+
*
|
|
438
|
+
* Run the pipeline WITHOUT an operation-wide `collation`: it would apply to
|
|
439
|
+
* the join keys and the related tables' fields too. Each related table's
|
|
440
|
+
* `'nocase'` fields are compared case-insensitively explicitly (see
|
|
441
|
+
* {@link TMongoFilterOptions.collation}, which does the same for the source).
|
|
442
|
+
*
|
|
443
|
+
* A predicate-free filter yields `{ pre, lookups: [], match: {}, temp: [] }`.
|
|
444
|
+
*
|
|
445
|
+
* @since 0.1.147
|
|
446
|
+
*/
|
|
447
|
+
function buildMongoQuery(filter, options = {}) {
|
|
448
|
+
return createPredicateRenderer()(filter, options.collation);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The pipeline stages that apply `filter`: `[{ $match }]` for a
|
|
452
|
+
* predicate-free filter (exactly what the reads always emitted — `options`
|
|
453
|
+
* is ignored then), else `$match(pre)` → predicate `$lookup`s → `$match` →
|
|
454
|
+
* `$unset` of the temporary fields ({@link buildMongoQuery}).
|
|
455
|
+
*
|
|
456
|
+
* @since 0.1.147
|
|
457
|
+
*/
|
|
458
|
+
function mongoFilterStages(filter, options = {}) {
|
|
459
|
+
if (!containsRelationFilter(filter)) return [{ $match: buildMongoFilter(filter) }];
|
|
460
|
+
return planStages(buildMongoQuery(filter, options));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The stages of a {@link TMongoFilterPlan}, in order: `$match(pre)`, the
|
|
464
|
+
* lookups, `$match(match)`, `$unset` of the temporary fields.
|
|
465
|
+
*
|
|
466
|
+
* @since 0.1.147
|
|
467
|
+
*/
|
|
468
|
+
function planStages(plan) {
|
|
469
|
+
return stagesOf(plan, true);
|
|
470
|
+
}
|
|
471
|
+
function stagesOf(plan, unset) {
|
|
472
|
+
const stages = [];
|
|
473
|
+
if (plan.pre) stages.push({ $match: plan.pre });
|
|
474
|
+
stages.push(...plan.lookups);
|
|
475
|
+
if (!isEmptyFilter(plan.match)) stages.push({ $match: plan.match });
|
|
476
|
+
if (unset && plan.temp.length > 0) stages.push({ $unset: plan.temp });
|
|
477
|
+
return stages;
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
480
|
+
//#region src/lib/mongo-accumulator.ts
|
|
481
|
+
/**
|
|
482
|
+
* The `$group` accumulator of one aggregate — shared by grouped queries
|
|
483
|
+
* (`agg.ts`) and managed views (`mongo-view-pipeline.ts`), so both count and
|
|
484
|
+
* sum alike.
|
|
485
|
+
*
|
|
486
|
+
* - `count(*)` → `{ $sum: 1 }`; `count(f)` counts values that are neither
|
|
487
|
+
* null nor missing (SQL `COUNT(f)`);
|
|
488
|
+
* - `sum` / `avg` / `min` / `max` → `$sum` / `$avg` / `$min` / `$max` (all
|
|
489
|
+
* skip null / missing values);
|
|
490
|
+
* - `countDistinct(f)` → `{ $addToSet: { $ifNull: [f, "$$REMOVE"] } }`, a
|
|
491
|
+
* SET of the non-null values (`$$REMOVE` adds nothing) the caller turns
|
|
492
|
+
* into its size with {@link distinctCountExpr}.
|
|
493
|
+
*
|
|
494
|
+
* `where` (a conditional aggregate's row predicate, an aggregation
|
|
495
|
+
* expression) swaps the source for `{ $cond: [where, src, null] }` — the
|
|
496
|
+
* rejected rows contribute a null, which every accumulator skips — and makes
|
|
497
|
+
* the counts `{ $sum: { $cond: [where (and not null), 1, 0] } }`.
|
|
498
|
+
*
|
|
499
|
+
* @param fn - The aggregate function (re-asserted: `INVALID_QUERY` when unknown).
|
|
500
|
+
* @param src - The source operand (`"$path"`), or `"*"` for `count(*)`.
|
|
501
|
+
* @param where - Row predicate of a conditional aggregate.
|
|
502
|
+
* @param path - Error path of the re-assertion.
|
|
503
|
+
*/
|
|
504
|
+
function buildAccumulator(fn, src, where, path) {
|
|
505
|
+
assertAggregateFn(fn, path);
|
|
506
|
+
if (fn === "count") {
|
|
507
|
+
if (src === "*") return { $sum: where ? { $cond: [
|
|
508
|
+
where,
|
|
509
|
+
1,
|
|
510
|
+
0
|
|
511
|
+
] } : 1 };
|
|
512
|
+
return { $sum: { $cond: [
|
|
513
|
+
where ? { $and: [where, notNullExpr(src)] } : notNullExpr(src),
|
|
514
|
+
1,
|
|
515
|
+
0
|
|
516
|
+
] } };
|
|
517
|
+
}
|
|
518
|
+
const value = where ? { $cond: [
|
|
519
|
+
where,
|
|
520
|
+
src,
|
|
521
|
+
null
|
|
522
|
+
] } : src;
|
|
523
|
+
return fn === "countDistinct" ? { $addToSet: { $ifNull: [value, "$$REMOVE"] } } : { [`$${fn}`]: value };
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The size of a `countDistinct` set (`"$alias"`) — the value the accumulator
|
|
527
|
+
* stands for, projected right after `$group` so later stages (`$having`,
|
|
528
|
+
* `$sort`) see a number.
|
|
529
|
+
*/
|
|
530
|
+
function distinctCountExpr(set) {
|
|
531
|
+
return { $size: set };
|
|
532
|
+
}
|
|
533
|
+
//#endregion
|
|
534
|
+
export { buildMongoQuery as a, planStages as c, queryNodeToExpr as d, correlate as f, buildMongoFilter as i, exprToMongo as l, distinctCountExpr as n, collationOfAdapter as o, REL_FILTER_TEMP_PREFIX as r, mongoFilterStages as s, buildAccumulator as t, orNull as u };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { FilterExpr, TDbCollation } from "@atscript/db";
|
|
2
|
+
import { Document, Filter } from "mongodb";
|
|
3
|
+
|
|
4
|
+
//#region src/lib/mongo-filter.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Translates a generic {@link FilterExpr} into a MongoDB-compatible
|
|
7
|
+
* {@link Filter} document.
|
|
8
|
+
*
|
|
9
|
+
* MongoDB's query language is nearly identical to the `FilterExpr` structure,
|
|
10
|
+
* so this is largely a structural pass-through via the `walkFilter` visitor.
|
|
11
|
+
* `fieldOperands` (view predicates only — `translateQueryTree` output) turns
|
|
12
|
+
* `{ $field: path }` operands into field-to-field `$expr` comparisons; a
|
|
13
|
+
* request filter never gets that reading.
|
|
14
|
+
*
|
|
15
|
+
* Predicate-free filters only: a relational predicate (`$some` / `$none`)
|
|
16
|
+
* throws `REL_FILTER_NOT_SUPPORTED` — render those with
|
|
17
|
+
* {@link buildMongoQuery} / {@link mongoFilterStages}.
|
|
18
|
+
*/
|
|
19
|
+
declare function buildMongoFilter(filter: FilterExpr, {
|
|
20
|
+
fieldOperands,
|
|
21
|
+
collation
|
|
22
|
+
}?: {
|
|
23
|
+
fieldOperands?: boolean;
|
|
24
|
+
collation?: TMongoFieldCollation;
|
|
25
|
+
}): Filter<any>;
|
|
26
|
+
/**
|
|
27
|
+
* A table's per-field collation (`@db.column.collate`), keyed by PHYSICAL
|
|
28
|
+
* field path — `undefined` / `'binary'` compare byte-wise. See
|
|
29
|
+
* {@link TMongoFilterOptions.collation}.
|
|
30
|
+
*
|
|
31
|
+
* @since 0.1.147
|
|
32
|
+
*/
|
|
33
|
+
type TMongoFieldCollation = (field: string) => TDbCollation | undefined;
|
|
34
|
+
/** Options of {@link buildMongoQuery} / {@link mongoFilterStages}. @since 0.1.147 */
|
|
35
|
+
interface TMongoFilterOptions {
|
|
36
|
+
/**
|
|
37
|
+
* The source table's per-field collation. A pipeline with relational
|
|
38
|
+
* predicates runs WITHOUT an operation-wide `collation` (it would govern
|
|
39
|
+
* every `$lookup` too — join keys and the related table's fields), so a
|
|
40
|
+
* `'nocase'` field's comparisons are rendered explicitly instead: `$eq` /
|
|
41
|
+
* `$ne` / `$in` / `$nin` on string values become anchored, escaped
|
|
42
|
+
* case-insensitive regular expressions. Range operators on a string value
|
|
43
|
+
* and every string comparison on a `'unicode'` field cannot be rendered
|
|
44
|
+
* faithfully and throw `REL_FILTER_NOT_SUPPORTED`.
|
|
45
|
+
*
|
|
46
|
+
* Omitted: the source's own fields compare byte-wise. Predicate operands
|
|
47
|
+
* always use the RELATED table's collation (read from its adapter).
|
|
48
|
+
*/
|
|
49
|
+
collation?: TMongoFieldCollation;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The per-field collation of a table, read from its (Mongo) adapter's
|
|
53
|
+
* `fieldCollation` — what a relational-predicate pipeline renders `'nocase'`
|
|
54
|
+
* fields with ({@link TMongoFilterOptions.collation}).
|
|
55
|
+
*/
|
|
56
|
+
declare function collationOfAdapter(adapter: unknown): TMongoFieldCollation | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Prefix of the temporary fields predicate lookups write (`<prefix><n>`) —
|
|
59
|
+
* long and specific so it cannot plausibly collide with a stored field; the
|
|
60
|
+
* fields are dropped with `$unset` before any row leaves the pipeline.
|
|
61
|
+
*
|
|
62
|
+
* @since 0.1.147
|
|
63
|
+
*/
|
|
64
|
+
declare const REL_FILTER_TEMP_PREFIX = "__atscript_rf_";
|
|
65
|
+
/**
|
|
66
|
+
* A filter rendered for an aggregation pipeline — what
|
|
67
|
+
* {@link buildMongoQuery} returns. Stage order: `$match: pre` (when set),
|
|
68
|
+
* the `lookups`, `$match: match` (when non-empty), `$unset: temp`
|
|
69
|
+
* ({@link mongoFilterStages} assembles them).
|
|
70
|
+
*
|
|
71
|
+
* @since 0.1.147
|
|
72
|
+
*/
|
|
73
|
+
interface TMongoFilterPlan {
|
|
74
|
+
/** The predicate-free top-level conjuncts — matched BEFORE the lookups, so they run only for surviving documents. */
|
|
75
|
+
pre?: Filter<any>;
|
|
76
|
+
/** One `$lookup` per relational predicate, each writing a temporary array field (at most one element). */
|
|
77
|
+
lookups: Document[];
|
|
78
|
+
/** The rest of the filter, predicates read as `{ <temp>: { $ne: [] } }` (`$some`) / `{ <temp>: { $size: 0 } }` (`$none`). */
|
|
79
|
+
match: Filter<any>;
|
|
80
|
+
/** The temporary fields the lookups add ({@link REL_FILTER_TEMP_PREFIX}`<n>`, dropped with `$unset`). */
|
|
81
|
+
temp: string[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Renders a translated filter that may hold relational predicates
|
|
85
|
+
* (`{ nav: { $some | $none: ResolvedRelationFilter } }`) for an aggregation
|
|
86
|
+
* pipeline. Each predicate becomes a correlated `$lookup` into the related
|
|
87
|
+
* collection (`let` = the source key fields; a bare `$expr` `$eq` per key
|
|
88
|
+
* part — index-friendly — then a `{ <key>: { $ne: null } }` guard; the
|
|
89
|
+
* inner filter — nested predicates as lookups inside it — then
|
|
90
|
+
* `$limit: 1`); `via` looks up the junction, and inside it the target. The
|
|
91
|
+
* predicate itself reads the lookup's temporary array field
|
|
92
|
+
* ({@link REL_FILTER_TEMP_PREFIX}`<n>`). Top-level conjuncts without
|
|
93
|
+
* predicates go to `pre` so the lookups run only for documents that survive
|
|
94
|
+
* them.
|
|
95
|
+
*
|
|
96
|
+
* Run the pipeline WITHOUT an operation-wide `collation`: it would apply to
|
|
97
|
+
* the join keys and the related tables' fields too. Each related table's
|
|
98
|
+
* `'nocase'` fields are compared case-insensitively explicitly (see
|
|
99
|
+
* {@link TMongoFilterOptions.collation}, which does the same for the source).
|
|
100
|
+
*
|
|
101
|
+
* A predicate-free filter yields `{ pre, lookups: [], match: {}, temp: [] }`.
|
|
102
|
+
*
|
|
103
|
+
* @since 0.1.147
|
|
104
|
+
*/
|
|
105
|
+
declare function buildMongoQuery(filter: FilterExpr | undefined, options?: TMongoFilterOptions): TMongoFilterPlan;
|
|
106
|
+
/**
|
|
107
|
+
* The pipeline stages that apply `filter`: `[{ $match }]` for a
|
|
108
|
+
* predicate-free filter (exactly what the reads always emitted — `options`
|
|
109
|
+
* is ignored then), else `$match(pre)` → predicate `$lookup`s → `$match` →
|
|
110
|
+
* `$unset` of the temporary fields ({@link buildMongoQuery}).
|
|
111
|
+
*
|
|
112
|
+
* @since 0.1.147
|
|
113
|
+
*/
|
|
114
|
+
declare function mongoFilterStages(filter: FilterExpr | undefined, options?: TMongoFilterOptions): Document[];
|
|
115
|
+
/**
|
|
116
|
+
* The stages of a {@link TMongoFilterPlan}, in order: `$match(pre)`, the
|
|
117
|
+
* lookups, `$match(match)`, `$unset` of the temporary fields.
|
|
118
|
+
*
|
|
119
|
+
* @since 0.1.147
|
|
120
|
+
*/
|
|
121
|
+
declare function planStages(plan: TMongoFilterPlan): Document[];
|
|
122
|
+
//#endregion
|
|
123
|
+
export { buildMongoFilter as a, mongoFilterStages as c, TMongoFilterPlan as i, planStages as l, TMongoFieldCollation as n, buildMongoQuery as o, TMongoFilterOptions as r, collationOfAdapter as s, REL_FILTER_TEMP_PREFIX as t };
|