@atscript/db-mongo 0.1.146 → 0.1.148

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.
@@ -0,0 +1,611 @@
1
+ let _atscript_db = require("@atscript/db");
2
+ let mongodb = require("mongodb");
3
+ let _atscript_db_agg = require("@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 ((0, _atscript_db.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 _atscript_db.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 (0, _atscript_db.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 mongodb.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 _atscript_db.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
+ ((0, _atscript_db.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 _atscript_db.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 (!(0, _atscript_db.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((0, _atscript_db.andFilters)(...preParts), { collation }) : void 0;
355
+ const match = postParts.length > 0 ? (0, _atscript_db.walkFilter)((0, _atscript_db.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 (!(0, _atscript_db.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
+ (0, _atscript_db_agg.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
+ Object.defineProperty(exports, "REL_FILTER_TEMP_PREFIX", {
535
+ enumerable: true,
536
+ get: function() {
537
+ return REL_FILTER_TEMP_PREFIX;
538
+ }
539
+ });
540
+ Object.defineProperty(exports, "buildAccumulator", {
541
+ enumerable: true,
542
+ get: function() {
543
+ return buildAccumulator;
544
+ }
545
+ });
546
+ Object.defineProperty(exports, "buildMongoFilter", {
547
+ enumerable: true,
548
+ get: function() {
549
+ return buildMongoFilter;
550
+ }
551
+ });
552
+ Object.defineProperty(exports, "buildMongoQuery", {
553
+ enumerable: true,
554
+ get: function() {
555
+ return buildMongoQuery;
556
+ }
557
+ });
558
+ Object.defineProperty(exports, "collationOfAdapter", {
559
+ enumerable: true,
560
+ get: function() {
561
+ return collationOfAdapter;
562
+ }
563
+ });
564
+ Object.defineProperty(exports, "correlate", {
565
+ enumerable: true,
566
+ get: function() {
567
+ return correlate;
568
+ }
569
+ });
570
+ Object.defineProperty(exports, "distinctCountExpr", {
571
+ enumerable: true,
572
+ get: function() {
573
+ return distinctCountExpr;
574
+ }
575
+ });
576
+ Object.defineProperty(exports, "exprToMongo", {
577
+ enumerable: true,
578
+ get: function() {
579
+ return exprToMongo;
580
+ }
581
+ });
582
+ Object.defineProperty(exports, "mongoFilterStages", {
583
+ enumerable: true,
584
+ get: function() {
585
+ return mongoFilterStages;
586
+ }
587
+ });
588
+ Object.defineProperty(exports, "notNullExpr", {
589
+ enumerable: true,
590
+ get: function() {
591
+ return notNullExpr;
592
+ }
593
+ });
594
+ Object.defineProperty(exports, "orNull", {
595
+ enumerable: true,
596
+ get: function() {
597
+ return orNull;
598
+ }
599
+ });
600
+ Object.defineProperty(exports, "planStages", {
601
+ enumerable: true,
602
+ get: function() {
603
+ return planStages;
604
+ }
605
+ });
606
+ Object.defineProperty(exports, "queryNodeToExpr", {
607
+ enumerable: true,
608
+ get: function() {
609
+ return queryNodeToExpr;
610
+ }
611
+ });