@atscript/db-mongo 0.1.152 → 0.1.153
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 +6 -4
- package/dist/agg.mjs +6 -4
- package/dist/index.cjs +376 -224
- package/dist/index.d.cts +37 -1
- package/dist/index.d.mts +37 -1
- package/dist/index.mjs +377 -225
- package/dist/{mongo-accumulator-BQBMx4GC.cjs → mongo-accumulator-BW-RNFg0.cjs} +136 -34
- package/dist/{mongo-accumulator-DYhg8usT.mjs → mongo-accumulator-DMPFOscc.mjs} +119 -35
- package/package.json +9 -9
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as buildMongoQuery, c as planStages,
|
|
2
|
-
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, defaultFulltextIndex, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, getPath, isAtscriptDbView, isFieldRef, isPlainObject, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, searchTermInteger, tableNameOf, translateQueryTree, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
1
|
+
import { a as buildMongoQuery, c as planStages, f as sortStages, g as queryNodeToExpr, h as orNull, i as buildMongoFilter, l as correlate, n as distinctCountExpr, o as collationOfAdapter, p as exprToMongo, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator, u as hasNullsPlacement } from "./mongo-accumulator-DMPFOscc.mjs";
|
|
2
|
+
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, defaultFulltextIndex, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, getPath, isAtscriptDbView, isFieldRef, isPlainObject, isViewType, queryReadColumns, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, searchTermInteger, tableNameOf, translateQueryTree, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
3
3
|
import { MongoBulkWriteError, MongoClient, MongoServerError, ObjectId } from "mongodb";
|
|
4
4
|
import { resolveAggregateSearch } from "@atscript/db/agg";
|
|
5
5
|
//#region src/lib/path-utils.ts
|
|
@@ -394,6 +394,262 @@ function mongoIndexKey(type, name) {
|
|
|
394
394
|
return `${INDEX_PREFIX}${type}__${name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 117 - type.length - 2)}`;
|
|
395
395
|
}
|
|
396
396
|
//#endregion
|
|
397
|
+
//#region src/lib/mongo-view-pipeline.ts
|
|
398
|
+
/** Where a joined table's document lands (`$lookup.as`, then unwound). */
|
|
399
|
+
function joinedAs(table) {
|
|
400
|
+
return `${JOINED_PREFIX}${table}`;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Builds the aggregation pipeline of a managed MongoDB view (created with
|
|
404
|
+
* `viewOn: <entry collection>`):
|
|
405
|
+
*
|
|
406
|
+
* 1. per join, in declaration order: `$lookup` + `$unwind` (inner joins drop
|
|
407
|
+
* unmatched documents, left joins keep them — `preserveNullAndEmptyArrays`);
|
|
408
|
+
* 2. `@db.view.filter` → `$match`;
|
|
409
|
+
* 3. aggregates → `$group` (+ `$addFields` turning `countDistinct` sets into
|
|
410
|
+
* their sizes, + `@db.view.having` → `$match`) + `$project`, otherwise a
|
|
411
|
+
* flat `$project`. A conditional aggregate's predicate reads the same
|
|
412
|
+
* pipeline paths (SQL null semantics — `queryNodeToExpr`).
|
|
413
|
+
*
|
|
414
|
+
* All field paths are PHYSICAL document paths (`@db.column` renames), joined
|
|
415
|
+
* tables live under `__joined_<scope>` — the physical collection, or the
|
|
416
|
+
* `@db.alias` type name for an aliased join (`__joined_Manager`), whose
|
|
417
|
+
* `$lookup.from` stays the physical collection (since 0.1.141).
|
|
418
|
+
* `variant` (since 0.1.153) renders a pruned read variant of the view
|
|
419
|
+
* (`AtscriptDbView.readPlan`) instead of its full definition.
|
|
420
|
+
* The entry collection and a `$lookup.from` may be views. A column whose source may be missing
|
|
421
|
+
* (`nullable` mapping) reads as null; every other column is a plain path, so
|
|
422
|
+
* a `$match` / `$sort` on the view can still push down to an index.
|
|
423
|
+
* @since 0.1.136
|
|
424
|
+
*/
|
|
425
|
+
function buildViewPipeline(view, variant) {
|
|
426
|
+
const plan = variant?.plan ?? view.viewPlan;
|
|
427
|
+
const columns = variant?.columns ?? view.getViewColumnMappings();
|
|
428
|
+
const pipeline = [];
|
|
429
|
+
const prefixes = new Map([[plan.entryTable, ""]]);
|
|
430
|
+
const pathOf = (ref) => {
|
|
431
|
+
const { table, source } = view.resolveRefSource(ref);
|
|
432
|
+
const prefix = prefixes.get(table);
|
|
433
|
+
if (prefix === void 0) throw new Error(`View "${view.tableName}": "${table}" is not joined before it is referenced`);
|
|
434
|
+
return prefix + source.column;
|
|
435
|
+
};
|
|
436
|
+
for (const join of plan.joins) {
|
|
437
|
+
pipeline.push(buildLookup(view, join, prefixes));
|
|
438
|
+
pipeline.push({ $unwind: {
|
|
439
|
+
path: `$${joinedAs(join.scope)}`,
|
|
440
|
+
preserveNullAndEmptyArrays: join.kind === "left"
|
|
441
|
+
} });
|
|
442
|
+
prefixes.set(join.scope, `${joinedAs(join.scope)}.`);
|
|
443
|
+
}
|
|
444
|
+
if (plan.filter) pipeline.push({ $match: viewMatch(plan.filter, pathOf) });
|
|
445
|
+
/** A column's source field as an aggregation operand. */
|
|
446
|
+
const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
|
|
447
|
+
const colValue = (col) => col.nullable ? orNull(colSourceField(col)) : colSourceField(col);
|
|
448
|
+
const byPath = new Map(columns.map((c) => [c.viewPath, c]));
|
|
449
|
+
const rendered = /* @__PURE__ */ new Map();
|
|
450
|
+
const computedExpr = (col, leaf) => {
|
|
451
|
+
let out = rendered.get(col.viewPath);
|
|
452
|
+
if (out === void 0) {
|
|
453
|
+
out = exprToMongo(col.expr, (path) => {
|
|
454
|
+
const c = byPath.get(path);
|
|
455
|
+
return c.expr === void 0 ? { $toDouble: leaf(c) } : computedExpr(c, leaf);
|
|
456
|
+
});
|
|
457
|
+
rendered.set(col.viewPath, out);
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
};
|
|
461
|
+
if (columns.some((c) => c.aggFn)) {
|
|
462
|
+
const group = { _id: {} };
|
|
463
|
+
const project = { _id: 0 };
|
|
464
|
+
const distinctSizes = {};
|
|
465
|
+
const computed = {};
|
|
466
|
+
for (const col of columns) if (col.expr !== void 0) {
|
|
467
|
+
computed[col.viewColumn] = computedExpr(col, (c) => c.aggFn ? `$${c.viewColumn}` : `$_id.${c.viewColumn}`);
|
|
468
|
+
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
469
|
+
} else if (col.aggFn) {
|
|
470
|
+
const predicate = col.aggFilter ? queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
|
|
471
|
+
group[col.viewColumn] = buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
|
|
472
|
+
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
473
|
+
if (col.aggFn === "countDistinct") distinctSizes[col.viewColumn] = distinctCountExpr(`$${col.viewColumn}`);
|
|
474
|
+
} else {
|
|
475
|
+
group._id[col.viewColumn] = colValue(col);
|
|
476
|
+
project[col.viewColumn] = `$_id.${col.viewColumn}`;
|
|
477
|
+
}
|
|
478
|
+
pipeline.push({ $group: group });
|
|
479
|
+
if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
|
|
480
|
+
if (Object.keys(computed).length > 0) pipeline.push({ $addFields: computed });
|
|
481
|
+
if (plan.having) pipeline.push({ $match: havingMatch(plan.having, byPath) });
|
|
482
|
+
pipeline.push({ $project: project });
|
|
483
|
+
} else {
|
|
484
|
+
const project = { _id: 0 };
|
|
485
|
+
for (const col of columns) project[col.viewColumn] = col.expr === void 0 ? colValue(col) : computedExpr(col, colValue);
|
|
486
|
+
pipeline.push({ $project: project });
|
|
487
|
+
}
|
|
488
|
+
return pipeline;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* One join's `$lookup`.
|
|
492
|
+
*
|
|
493
|
+
* The simple `localField` / `foreignField` form is used whenever it is exact:
|
|
494
|
+
* a single `=` between a field of the joined table and a field of an outer
|
|
495
|
+
* table (the entry or an earlier join), with the joined field REQUIRED — the
|
|
496
|
+
* simple form matches a null / missing local value to null / missing foreign
|
|
497
|
+
* values, which a required foreign field can never hold. It is kept (rather
|
|
498
|
+
* than always using the pipeline form) because it can use an index on the
|
|
499
|
+
* foreign field on every MongoDB version; the pipeline form's `$expr` match
|
|
500
|
+
* can't before MongoDB 5.0. Everything else uses the pipeline form (`let` +
|
|
501
|
+
* `$match: { $expr }`, SQL null semantics).
|
|
502
|
+
*/
|
|
503
|
+
function buildLookup(view, join, outer) {
|
|
504
|
+
const scope = join.scope;
|
|
505
|
+
const as = joinedAs(scope);
|
|
506
|
+
const simple = join.first ? void 0 : simpleJoinFields(view, join, outer);
|
|
507
|
+
if (simple) return { $lookup: {
|
|
508
|
+
from: join.targetTable,
|
|
509
|
+
...simple,
|
|
510
|
+
as
|
|
511
|
+
} };
|
|
512
|
+
const letVars = {};
|
|
513
|
+
const varByPath = /* @__PURE__ */ new Map();
|
|
514
|
+
const pathOf = (ref) => {
|
|
515
|
+
const { table, source } = view.resolveRefSource(ref);
|
|
516
|
+
if (table === scope) return `$${source.column}`;
|
|
517
|
+
const prefix = outer.get(table);
|
|
518
|
+
if (prefix === void 0) throw new Error(`View "${view.tableName}": the join on "${scope}" references "${table}", which is not joined before it`);
|
|
519
|
+
const outerPath = prefix + source.column;
|
|
520
|
+
let varName = varByPath.get(outerPath);
|
|
521
|
+
if (!varName) {
|
|
522
|
+
varName = `v${varByPath.size}`;
|
|
523
|
+
varByPath.set(outerPath, varName);
|
|
524
|
+
letVars[varName] = `$${outerPath}`;
|
|
525
|
+
}
|
|
526
|
+
return `$$${varName}`;
|
|
527
|
+
};
|
|
528
|
+
const pipeline = [{ $match: { $expr: queryNodeToExpr(join.condition, pathOf) } }];
|
|
529
|
+
if (join.first) {
|
|
530
|
+
const sort = {};
|
|
531
|
+
for (const { ref, desc } of join.first.order) sort[view.resolveRefSource(ref).source.column] = desc ? -1 : 1;
|
|
532
|
+
pipeline.push({ $sort: sort }, { $limit: 1 });
|
|
533
|
+
}
|
|
534
|
+
return { $lookup: {
|
|
535
|
+
from: join.targetTable,
|
|
536
|
+
...varByPath.size > 0 ? { let: letVars } : {},
|
|
537
|
+
pipeline,
|
|
538
|
+
as
|
|
539
|
+
} };
|
|
540
|
+
}
|
|
541
|
+
/** `{ localField, foreignField }` when the simple `$lookup` form is exact, else `undefined`. */
|
|
542
|
+
function simpleJoinFields(view, join, outer) {
|
|
543
|
+
let node = join.condition;
|
|
544
|
+
if ("$and" in node) {
|
|
545
|
+
const children = node.$and;
|
|
546
|
+
if (children.length !== 1) return void 0;
|
|
547
|
+
node = children[0];
|
|
548
|
+
}
|
|
549
|
+
if ("$or" in node || "$not" in node || "$and" in node) return void 0;
|
|
550
|
+
const comp = node;
|
|
551
|
+
if (comp.op !== "$eq" || !isFieldRef(comp.right)) return void 0;
|
|
552
|
+
const scope = join.scope;
|
|
553
|
+
const left = view.resolveRefSource(comp.left);
|
|
554
|
+
const right = view.resolveRefSource(comp.right);
|
|
555
|
+
let target;
|
|
556
|
+
let local;
|
|
557
|
+
if (left.table === scope && right.table !== scope) {
|
|
558
|
+
target = left;
|
|
559
|
+
local = right;
|
|
560
|
+
} else if (right.table === scope && left.table !== scope) {
|
|
561
|
+
target = right;
|
|
562
|
+
local = left;
|
|
563
|
+
} else return;
|
|
564
|
+
const localPrefix = outer.get(local.table);
|
|
565
|
+
if (localPrefix === void 0) return void 0;
|
|
566
|
+
const foreign = target.source;
|
|
567
|
+
if (!(!foreign.optional && foreign.designType !== "union" && foreign.designType !== "unknown")) return void 0;
|
|
568
|
+
return {
|
|
569
|
+
localField: localPrefix + local.source.column,
|
|
570
|
+
foreignField: foreign.column
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* A view predicate (`@db.view.filter`, `@db.view.having`) as a `$match`
|
|
575
|
+
* over pipeline paths: the shared query translation (`translateQueryTree` →
|
|
576
|
+
* `buildMongoFilter`), so operators mean what they mean in queries (`exists`
|
|
577
|
+
* = holds a value, `matches` takes `/re/flags`); a field-to-field comparison
|
|
578
|
+
* becomes `$expr`.
|
|
579
|
+
*/
|
|
580
|
+
function viewMatch(node, pathOf) {
|
|
581
|
+
return buildMongoFilter(translateQueryTree(node, pathOf), { fieldOperands: true });
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* `@db.view.having` as a `$match` after `$group`: refs name view fields —
|
|
585
|
+
* aggregates are top-level, dimensions under `_id`.
|
|
586
|
+
*/
|
|
587
|
+
function havingMatch(node, colMap) {
|
|
588
|
+
return viewMatch(node, (ref) => {
|
|
589
|
+
const col = ref.type ? void 0 : colMap.get(ref.field);
|
|
590
|
+
if (!col) return ref.field;
|
|
591
|
+
return col.aggFn || col.expr !== void 0 ? col.viewColumn : `_id.${col.viewColumn}`;
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/lib/mongo-view-read.ts
|
|
596
|
+
/** Rendered prefixes per read variant (variants are memoised by their view). */
|
|
597
|
+
const prefixes = /* @__PURE__ */ new WeakMap();
|
|
598
|
+
/**
|
|
599
|
+
* The pruned read of `view` for `query`, or `undefined` to read the stored
|
|
600
|
+
* view: nothing droppable, an external view, or an entry that is itself a
|
|
601
|
+
* view (its collation and pipeline would be read twice).
|
|
602
|
+
*/
|
|
603
|
+
function mongoViewRead(view, query, kind) {
|
|
604
|
+
const variant = view.readPlan(queryReadColumns(query, kind));
|
|
605
|
+
if (!variant || isViewType(variant.plan.entryType())) return void 0;
|
|
606
|
+
let prefix = prefixes.get(variant);
|
|
607
|
+
if (!prefix) {
|
|
608
|
+
prefix = buildViewPipeline(view, variant);
|
|
609
|
+
prefixes.set(variant, prefix);
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
entry: variant.plan.entryTable,
|
|
613
|
+
prefix,
|
|
614
|
+
dropped: variant.droppedColumns
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function isPlainObject$1(value) {
|
|
618
|
+
if (value === null || typeof value !== "object") return false;
|
|
619
|
+
const proto = Object.getPrototypeOf(value);
|
|
620
|
+
return proto === Object.prototype || proto === null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Whether any stage may read one of the `dropped` view columns: a field path
|
|
624
|
+
* string (`"$col"`, `"col.sub"`, a `localField`), an object key naming it, or
|
|
625
|
+
* a whole-document variable (`$$ROOT` / `$$CURRENT`). Literals that happen to
|
|
626
|
+
* equal a column name match too — that only falls back to the stored view.
|
|
627
|
+
*
|
|
628
|
+
* MongoDB never rejects a missing field, so this scan — not an engine error —
|
|
629
|
+
* is what keeps a column reference the read-column collector did not report
|
|
630
|
+
* from silently reading `null`.
|
|
631
|
+
*/
|
|
632
|
+
function stagesReadAny(stages, dropped) {
|
|
633
|
+
if (dropped.length === 0) return false;
|
|
634
|
+
const names = (raw) => {
|
|
635
|
+
if (raw.startsWith("$$")) {
|
|
636
|
+
const variable = raw.slice(2).split(".")[0];
|
|
637
|
+
return variable === "ROOT" || variable === "CURRENT";
|
|
638
|
+
}
|
|
639
|
+
const path = raw.startsWith("$") ? raw.slice(1) : raw;
|
|
640
|
+
if (!path) return false;
|
|
641
|
+
return dropped.some((col) => path === col || path.startsWith(`${col}.`) || col.startsWith(`${path}.`));
|
|
642
|
+
};
|
|
643
|
+
const visit = (value) => {
|
|
644
|
+
if (typeof value === "string") return names(value);
|
|
645
|
+
if (Array.isArray(value)) return value.some(visit);
|
|
646
|
+
if (!isPlainObject$1(value)) return false;
|
|
647
|
+
for (const [key, child] of Object.entries(value)) if (names(key) || visit(child)) return true;
|
|
648
|
+
return false;
|
|
649
|
+
};
|
|
650
|
+
return visit(stages);
|
|
651
|
+
}
|
|
652
|
+
//#endregion
|
|
397
653
|
//#region src/lib/mongo-relations.ts
|
|
398
654
|
/** Join key of a row's primary key values (`fields` = the row's own names for them). */
|
|
399
655
|
function buildPKKey(fields, doc) {
|
|
@@ -618,21 +874,23 @@ function buildLookupInnerPipeline(target, withRel, relation, requiredFields) {
|
|
|
618
874
|
const flatRel = withRel;
|
|
619
875
|
const nested = withRel.controls || {};
|
|
620
876
|
const sort = nested.$sort || flatRel.$sort;
|
|
877
|
+
const nulls = nested.$nulls || flatRel.$nulls;
|
|
621
878
|
const limit = nested.$limit ?? flatRel.$limit;
|
|
622
879
|
const skip = nested.$skip ?? flatRel.$skip;
|
|
623
880
|
const select = nested.$select || flatRel.$select;
|
|
624
881
|
const statics = relationStaticFilter(relation, withRel.name);
|
|
625
882
|
const controls = {};
|
|
626
883
|
if (sort) controls.$sort = sort;
|
|
884
|
+
if (nulls) controls.$nulls = nulls;
|
|
627
885
|
if (select) controls.$select = select;
|
|
628
886
|
const translated = target._translateForAdapter({
|
|
629
887
|
filter: andFilters(statics.target, withRel.filter),
|
|
630
888
|
controls
|
|
631
889
|
});
|
|
632
|
-
const page =
|
|
633
|
-
if (translated.controls?.$sort) page.push({ $sort: translated.controls.$sort });
|
|
890
|
+
const { stages: page, cleanup } = sortStages(translated.controls);
|
|
634
891
|
if (skip) page.push({ $skip: skip });
|
|
635
892
|
if (limit !== null && limit !== void 0) page.push({ $limit: limit });
|
|
893
|
+
if (cleanup) page.push(cleanup);
|
|
636
894
|
const projection = translated.controls?.$select?.asProjection;
|
|
637
895
|
if (projection) {
|
|
638
896
|
const project = { ...projection };
|
|
@@ -1226,11 +1484,13 @@ async function runSearchPipeline(host, stage, query, label, { threshold, classic
|
|
|
1226
1484
|
pipeline.push({ $match: { _score: { $gte: threshold } } });
|
|
1227
1485
|
} else if (classicText) pipeline.push({ $addFields: { _score: { $meta: "textScore" } } });
|
|
1228
1486
|
pipeline.push(...filterStages ?? mongoFilterStages(query.filter, filterOptionsOf(host)));
|
|
1229
|
-
|
|
1487
|
+
const { stages: sorted, cleanup } = sortStages(controls);
|
|
1488
|
+
if (sorted.length > 0) pipeline.push(...sorted);
|
|
1230
1489
|
else if (classicText) pipeline.push({ $sort: { _score: -1 } });
|
|
1231
1490
|
if (controls.$skip) pipeline.push({ $skip: controls.$skip });
|
|
1232
1491
|
if (controls.$limit) pipeline.push({ $limit: controls.$limit });
|
|
1233
1492
|
else pipeline.push({ $limit: 1e3 });
|
|
1493
|
+
if (cleanup) pipeline.push(cleanup);
|
|
1234
1494
|
if (controls.$select) {
|
|
1235
1495
|
const projection = controls.$select.asProjection;
|
|
1236
1496
|
if (projection) pipeline.push({ $project: dedupeProjection(projection) });
|
|
@@ -1255,11 +1515,11 @@ async function runSearchWithCountPipeline(host, stage, query, label, { threshold
|
|
|
1255
1515
|
preStages.push({ $addFields: { _score: { $meta: "vectorSearchScore" } } });
|
|
1256
1516
|
preStages.push({ $match: { _score: { $gte: threshold } } });
|
|
1257
1517
|
} else if (classicText) preStages.push({ $addFields: { _score: { $meta: "textScore" } } });
|
|
1258
|
-
const dataStages =
|
|
1259
|
-
if (
|
|
1260
|
-
else if (classicText) dataStages.push({ $sort: { _score: -1 } });
|
|
1518
|
+
const { stages: dataStages, cleanup } = sortStages(controls);
|
|
1519
|
+
if (dataStages.length === 0 && classicText) dataStages.push({ $sort: { _score: -1 } });
|
|
1261
1520
|
if (controls.$skip) dataStages.push({ $skip: controls.$skip });
|
|
1262
1521
|
if (controls.$limit) dataStages.push({ $limit: controls.$limit });
|
|
1522
|
+
if (cleanup) dataStages.push(cleanup);
|
|
1263
1523
|
if (controls.$select) {
|
|
1264
1524
|
const projection = controls.$select.asProjection;
|
|
1265
1525
|
if (projection) dataStages.push({ $project: dedupeProjection(projection) });
|
|
@@ -1309,202 +1569,6 @@ async function runSearchWithMeta(host, stage, dataStages, label) {
|
|
|
1309
1569
|
};
|
|
1310
1570
|
}
|
|
1311
1571
|
//#endregion
|
|
1312
|
-
//#region src/lib/mongo-view-pipeline.ts
|
|
1313
|
-
/** Where a joined table's document lands (`$lookup.as`, then unwound). */
|
|
1314
|
-
function joinedAs(table) {
|
|
1315
|
-
return `${JOINED_PREFIX}${table}`;
|
|
1316
|
-
}
|
|
1317
|
-
/**
|
|
1318
|
-
* Builds the aggregation pipeline of a managed MongoDB view (created with
|
|
1319
|
-
* `viewOn: <entry collection>`):
|
|
1320
|
-
*
|
|
1321
|
-
* 1. per join, in declaration order: `$lookup` + `$unwind` (inner joins drop
|
|
1322
|
-
* unmatched documents, left joins keep them — `preserveNullAndEmptyArrays`);
|
|
1323
|
-
* 2. `@db.view.filter` → `$match`;
|
|
1324
|
-
* 3. aggregates → `$group` (+ `$addFields` turning `countDistinct` sets into
|
|
1325
|
-
* their sizes, + `@db.view.having` → `$match`) + `$project`, otherwise a
|
|
1326
|
-
* flat `$project`. A conditional aggregate's predicate reads the same
|
|
1327
|
-
* pipeline paths (SQL null semantics — `queryNodeToExpr`).
|
|
1328
|
-
*
|
|
1329
|
-
* All field paths are PHYSICAL document paths (`@db.column` renames), joined
|
|
1330
|
-
* tables live under `__joined_<scope>` — the physical collection, or the
|
|
1331
|
-
* `@db.alias` type name for an aliased join (`__joined_Manager`), whose
|
|
1332
|
-
* `$lookup.from` stays the physical collection (since 0.1.141).
|
|
1333
|
-
* The entry collection and a `$lookup.from` may be views. A column whose source may be missing
|
|
1334
|
-
* (`nullable` mapping) reads as null; every other column is a plain path, so
|
|
1335
|
-
* a `$match` / `$sort` on the view can still push down to an index.
|
|
1336
|
-
* @since 0.1.136
|
|
1337
|
-
*/
|
|
1338
|
-
function buildViewPipeline(view) {
|
|
1339
|
-
const plan = view.viewPlan;
|
|
1340
|
-
const columns = view.getViewColumnMappings();
|
|
1341
|
-
const pipeline = [];
|
|
1342
|
-
const prefixes = new Map([[plan.entryTable, ""]]);
|
|
1343
|
-
const pathOf = (ref) => {
|
|
1344
|
-
const { table, source } = view.resolveRefSource(ref);
|
|
1345
|
-
const prefix = prefixes.get(table);
|
|
1346
|
-
if (prefix === void 0) throw new Error(`View "${view.tableName}": "${table}" is not joined before it is referenced`);
|
|
1347
|
-
return prefix + source.column;
|
|
1348
|
-
};
|
|
1349
|
-
for (const join of plan.joins) {
|
|
1350
|
-
pipeline.push(buildLookup(view, join, prefixes));
|
|
1351
|
-
pipeline.push({ $unwind: {
|
|
1352
|
-
path: `$${joinedAs(join.scope)}`,
|
|
1353
|
-
preserveNullAndEmptyArrays: join.kind === "left"
|
|
1354
|
-
} });
|
|
1355
|
-
prefixes.set(join.scope, `${joinedAs(join.scope)}.`);
|
|
1356
|
-
}
|
|
1357
|
-
if (plan.filter) pipeline.push({ $match: viewMatch(plan.filter, pathOf) });
|
|
1358
|
-
/** A column's source field as an aggregation operand. */
|
|
1359
|
-
const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
|
|
1360
|
-
const colValue = (col) => col.nullable ? orNull(colSourceField(col)) : colSourceField(col);
|
|
1361
|
-
const byPath = new Map(columns.map((c) => [c.viewPath, c]));
|
|
1362
|
-
const rendered = /* @__PURE__ */ new Map();
|
|
1363
|
-
const computedExpr = (col, leaf) => {
|
|
1364
|
-
let out = rendered.get(col.viewPath);
|
|
1365
|
-
if (out === void 0) {
|
|
1366
|
-
out = exprToMongo(col.expr, (path) => {
|
|
1367
|
-
const c = byPath.get(path);
|
|
1368
|
-
return c.expr === void 0 ? { $toDouble: leaf(c) } : computedExpr(c, leaf);
|
|
1369
|
-
});
|
|
1370
|
-
rendered.set(col.viewPath, out);
|
|
1371
|
-
}
|
|
1372
|
-
return out;
|
|
1373
|
-
};
|
|
1374
|
-
if (columns.some((c) => c.aggFn)) {
|
|
1375
|
-
const group = { _id: {} };
|
|
1376
|
-
const project = { _id: 0 };
|
|
1377
|
-
const distinctSizes = {};
|
|
1378
|
-
const computed = {};
|
|
1379
|
-
for (const col of columns) if (col.expr !== void 0) {
|
|
1380
|
-
computed[col.viewColumn] = computedExpr(col, (c) => c.aggFn ? `$${c.viewColumn}` : `$_id.${c.viewColumn}`);
|
|
1381
|
-
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
1382
|
-
} else if (col.aggFn) {
|
|
1383
|
-
const predicate = col.aggFilter ? queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
|
|
1384
|
-
group[col.viewColumn] = buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
|
|
1385
|
-
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
1386
|
-
if (col.aggFn === "countDistinct") distinctSizes[col.viewColumn] = distinctCountExpr(`$${col.viewColumn}`);
|
|
1387
|
-
} else {
|
|
1388
|
-
group._id[col.viewColumn] = colValue(col);
|
|
1389
|
-
project[col.viewColumn] = `$_id.${col.viewColumn}`;
|
|
1390
|
-
}
|
|
1391
|
-
pipeline.push({ $group: group });
|
|
1392
|
-
if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
|
|
1393
|
-
if (Object.keys(computed).length > 0) pipeline.push({ $addFields: computed });
|
|
1394
|
-
if (plan.having) pipeline.push({ $match: havingMatch(plan.having, byPath) });
|
|
1395
|
-
pipeline.push({ $project: project });
|
|
1396
|
-
} else {
|
|
1397
|
-
const project = { _id: 0 };
|
|
1398
|
-
for (const col of columns) project[col.viewColumn] = col.expr === void 0 ? colValue(col) : computedExpr(col, colValue);
|
|
1399
|
-
pipeline.push({ $project: project });
|
|
1400
|
-
}
|
|
1401
|
-
return pipeline;
|
|
1402
|
-
}
|
|
1403
|
-
/**
|
|
1404
|
-
* One join's `$lookup`.
|
|
1405
|
-
*
|
|
1406
|
-
* The simple `localField` / `foreignField` form is used whenever it is exact:
|
|
1407
|
-
* a single `=` between a field of the joined table and a field of an outer
|
|
1408
|
-
* table (the entry or an earlier join), with the joined field REQUIRED — the
|
|
1409
|
-
* simple form matches a null / missing local value to null / missing foreign
|
|
1410
|
-
* values, which a required foreign field can never hold. It is kept (rather
|
|
1411
|
-
* than always using the pipeline form) because it can use an index on the
|
|
1412
|
-
* foreign field on every MongoDB version; the pipeline form's `$expr` match
|
|
1413
|
-
* can't before MongoDB 5.0. Everything else uses the pipeline form (`let` +
|
|
1414
|
-
* `$match: { $expr }`, SQL null semantics).
|
|
1415
|
-
*/
|
|
1416
|
-
function buildLookup(view, join, outer) {
|
|
1417
|
-
const scope = join.scope;
|
|
1418
|
-
const as = joinedAs(scope);
|
|
1419
|
-
const simple = join.first ? void 0 : simpleJoinFields(view, join, outer);
|
|
1420
|
-
if (simple) return { $lookup: {
|
|
1421
|
-
from: join.targetTable,
|
|
1422
|
-
...simple,
|
|
1423
|
-
as
|
|
1424
|
-
} };
|
|
1425
|
-
const letVars = {};
|
|
1426
|
-
const varByPath = /* @__PURE__ */ new Map();
|
|
1427
|
-
const pathOf = (ref) => {
|
|
1428
|
-
const { table, source } = view.resolveRefSource(ref);
|
|
1429
|
-
if (table === scope) return `$${source.column}`;
|
|
1430
|
-
const prefix = outer.get(table);
|
|
1431
|
-
if (prefix === void 0) throw new Error(`View "${view.tableName}": the join on "${scope}" references "${table}", which is not joined before it`);
|
|
1432
|
-
const outerPath = prefix + source.column;
|
|
1433
|
-
let varName = varByPath.get(outerPath);
|
|
1434
|
-
if (!varName) {
|
|
1435
|
-
varName = `v${varByPath.size}`;
|
|
1436
|
-
varByPath.set(outerPath, varName);
|
|
1437
|
-
letVars[varName] = `$${outerPath}`;
|
|
1438
|
-
}
|
|
1439
|
-
return `$$${varName}`;
|
|
1440
|
-
};
|
|
1441
|
-
const pipeline = [{ $match: { $expr: queryNodeToExpr(join.condition, pathOf) } }];
|
|
1442
|
-
if (join.first) {
|
|
1443
|
-
const sort = {};
|
|
1444
|
-
for (const { ref, desc } of join.first.order) sort[view.resolveRefSource(ref).source.column] = desc ? -1 : 1;
|
|
1445
|
-
pipeline.push({ $sort: sort }, { $limit: 1 });
|
|
1446
|
-
}
|
|
1447
|
-
return { $lookup: {
|
|
1448
|
-
from: join.targetTable,
|
|
1449
|
-
...varByPath.size > 0 ? { let: letVars } : {},
|
|
1450
|
-
pipeline,
|
|
1451
|
-
as
|
|
1452
|
-
} };
|
|
1453
|
-
}
|
|
1454
|
-
/** `{ localField, foreignField }` when the simple `$lookup` form is exact, else `undefined`. */
|
|
1455
|
-
function simpleJoinFields(view, join, outer) {
|
|
1456
|
-
let node = join.condition;
|
|
1457
|
-
if ("$and" in node) {
|
|
1458
|
-
const children = node.$and;
|
|
1459
|
-
if (children.length !== 1) return void 0;
|
|
1460
|
-
node = children[0];
|
|
1461
|
-
}
|
|
1462
|
-
if ("$or" in node || "$not" in node || "$and" in node) return void 0;
|
|
1463
|
-
const comp = node;
|
|
1464
|
-
if (comp.op !== "$eq" || !isFieldRef(comp.right)) return void 0;
|
|
1465
|
-
const scope = join.scope;
|
|
1466
|
-
const left = view.resolveRefSource(comp.left);
|
|
1467
|
-
const right = view.resolveRefSource(comp.right);
|
|
1468
|
-
let target;
|
|
1469
|
-
let local;
|
|
1470
|
-
if (left.table === scope && right.table !== scope) {
|
|
1471
|
-
target = left;
|
|
1472
|
-
local = right;
|
|
1473
|
-
} else if (right.table === scope && left.table !== scope) {
|
|
1474
|
-
target = right;
|
|
1475
|
-
local = left;
|
|
1476
|
-
} else return;
|
|
1477
|
-
const localPrefix = outer.get(local.table);
|
|
1478
|
-
if (localPrefix === void 0) return void 0;
|
|
1479
|
-
const foreign = target.source;
|
|
1480
|
-
if (!(!foreign.optional && foreign.designType !== "union" && foreign.designType !== "unknown")) return void 0;
|
|
1481
|
-
return {
|
|
1482
|
-
localField: localPrefix + local.source.column,
|
|
1483
|
-
foreignField: foreign.column
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
/**
|
|
1487
|
-
* A view predicate (`@db.view.filter`, `@db.view.having`) as a `$match`
|
|
1488
|
-
* over pipeline paths: the shared query translation (`translateQueryTree` →
|
|
1489
|
-
* `buildMongoFilter`), so operators mean what they mean in queries (`exists`
|
|
1490
|
-
* = holds a value, `matches` takes `/re/flags`); a field-to-field comparison
|
|
1491
|
-
* becomes `$expr`.
|
|
1492
|
-
*/
|
|
1493
|
-
function viewMatch(node, pathOf) {
|
|
1494
|
-
return buildMongoFilter(translateQueryTree(node, pathOf), { fieldOperands: true });
|
|
1495
|
-
}
|
|
1496
|
-
/**
|
|
1497
|
-
* `@db.view.having` as a `$match` after `$group`: refs name view fields —
|
|
1498
|
-
* aggregates are top-level, dimensions under `_id`.
|
|
1499
|
-
*/
|
|
1500
|
-
function havingMatch(node, colMap) {
|
|
1501
|
-
return viewMatch(node, (ref) => {
|
|
1502
|
-
const col = ref.type ? void 0 : colMap.get(ref.field);
|
|
1503
|
-
if (!col) return ref.field;
|
|
1504
|
-
return col.aggFn || col.expr !== void 0 ? col.viewColumn : `_id.${col.viewColumn}`;
|
|
1505
|
-
});
|
|
1506
|
-
}
|
|
1507
|
-
//#endregion
|
|
1508
1572
|
//#region src/lib/mongo-schema-sync.ts
|
|
1509
1573
|
const DESTRUCTIVE_OPTION_KEYS = new Set([
|
|
1510
1574
|
"capped",
|
|
@@ -2094,6 +2158,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2094
2158
|
this.db = db;
|
|
2095
2159
|
this.client = client;
|
|
2096
2160
|
this.options = options;
|
|
2161
|
+
this.viewJoinPruning = options.viewJoinPruning !== false;
|
|
2097
2162
|
}
|
|
2098
2163
|
get _client() {
|
|
2099
2164
|
return this.client;
|
|
@@ -2186,15 +2251,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2186
2251
|
return (applied ?? await run(stages))[0];
|
|
2187
2252
|
};
|
|
2188
2253
|
const searchStage = buildAggregateSearchStage(this, query.controls);
|
|
2254
|
+
const run = (pipeline, label) => {
|
|
2255
|
+
const pruned = searchStage ? void 0 : this._viewRead(query, "aggregate", pipeline);
|
|
2256
|
+
if (pruned) return this._readPipeline(query, pipeline, label, pruned, aggregateOptions(pipeline));
|
|
2257
|
+
this._log(label, pipeline);
|
|
2258
|
+
return wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
|
|
2259
|
+
};
|
|
2189
2260
|
if (query.controls?.$count) {
|
|
2190
|
-
const
|
|
2191
|
-
this._log("aggregate (count)", pipeline);
|
|
2192
|
-
const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
|
|
2261
|
+
const result = await run(buildCountPipeline(query, searchStage, this._predicateFilterOpts), "aggregate (count)");
|
|
2193
2262
|
return result.length > 0 ? result : [{ count: await emptyGroup(true) ? 1 : 0 }];
|
|
2194
2263
|
}
|
|
2195
|
-
const
|
|
2196
|
-
this._log("aggregate", pipeline);
|
|
2197
|
-
const rows = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
|
|
2264
|
+
const rows = await run(buildAggregatePipeline(query, searchStage, this._predicateFilterOpts), "aggregate");
|
|
2198
2265
|
const empty = rows.length === 0 ? await emptyGroup(false) : void 0;
|
|
2199
2266
|
return empty ? [empty] : rows;
|
|
2200
2267
|
}
|
|
@@ -2244,6 +2311,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2244
2311
|
supportsAggregateExpressions() {
|
|
2245
2312
|
return true;
|
|
2246
2313
|
}
|
|
2314
|
+
/**
|
|
2315
|
+
* `$nulls` placement (since 0.1.153): a placed `$sort` / `$rowOrder` key
|
|
2316
|
+
* gets a null-or-missing flag ordered before it (`mongo-sort.ts`) — reads
|
|
2317
|
+
* then run as a pipeline with a blocking sort instead of `find().sort()`.
|
|
2318
|
+
*/
|
|
2319
|
+
supportsNullsPlacement() {
|
|
2320
|
+
return true;
|
|
2321
|
+
}
|
|
2247
2322
|
/** Computed view columns and first-row joins. */
|
|
2248
2323
|
viewCapabilities() {
|
|
2249
2324
|
return ALL_VIEW_CAPABILITIES;
|
|
@@ -2465,7 +2540,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2465
2540
|
if (this._hasExplicitId) return {
|
|
2466
2541
|
addPrimaryKeys: ["_id"],
|
|
2467
2542
|
removePrimaryKeys: meta.originalMetaIdFields.filter((f) => f !== "_id"),
|
|
2468
|
-
addUniqueFields: uniqueFields.length
|
|
2543
|
+
addUniqueFields: uniqueFields.length === 1 ? uniqueFields : void 0,
|
|
2544
|
+
addUniqueKeys: uniqueFields.length > 1 ? [[...uniqueFields]] : void 0
|
|
2469
2545
|
};
|
|
2470
2546
|
const effectiveUnique = uniqueFields.filter((f) => meta.primaryKeys.length <= 1 || !meta.primaryKeys.includes(f));
|
|
2471
2547
|
effectiveUnique.push("_id");
|
|
@@ -2639,8 +2715,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2639
2715
|
data: pageStages(query.controls),
|
|
2640
2716
|
meta: [{ $count: "count" }]
|
|
2641
2717
|
} }];
|
|
2642
|
-
this.
|
|
2643
|
-
const result = await
|
|
2718
|
+
const pruned = this._viewRead(query, "rows", pipeline);
|
|
2719
|
+
const result = await this._readPipeline(query, pipeline, "aggregate (findManyWithCount)", pruned, placedSortOptions(query.controls));
|
|
2644
2720
|
return {
|
|
2645
2721
|
data: result[0]?.data || [],
|
|
2646
2722
|
count: result[0]?.meta[0]?.count || 0
|
|
@@ -2904,7 +2980,12 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2904
2980
|
}
|
|
2905
2981
|
}
|
|
2906
2982
|
async findOne(query) {
|
|
2907
|
-
|
|
2983
|
+
const pruned = this._prunedFind(query, "findOne", 1);
|
|
2984
|
+
if (pruned) {
|
|
2985
|
+
const [row] = await pruned;
|
|
2986
|
+
return row ?? null;
|
|
2987
|
+
}
|
|
2988
|
+
if (containsRelationFilter(query.filter) || hasNullsPlacement(query.controls)) {
|
|
2908
2989
|
const [row] = await this._aggregateFind(query, "findOne", 1);
|
|
2909
2990
|
return row ?? null;
|
|
2910
2991
|
}
|
|
@@ -2918,7 +2999,9 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2918
2999
|
}));
|
|
2919
3000
|
}
|
|
2920
3001
|
async findMany(query) {
|
|
2921
|
-
|
|
3002
|
+
const pruned = this._prunedFind(query, "findMany");
|
|
3003
|
+
if (pruned) return pruned;
|
|
3004
|
+
if (containsRelationFilter(query.filter) || hasNullsPlacement(query.controls)) return this._aggregateFind(query, "findMany");
|
|
2922
3005
|
const filter = buildMongoFilter(query.filter);
|
|
2923
3006
|
const opts = this._buildFindOptions(query.controls);
|
|
2924
3007
|
this._log("findMany", filter, opts);
|
|
@@ -2929,10 +3012,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2929
3012
|
}).toArray());
|
|
2930
3013
|
}
|
|
2931
3014
|
async count(query) {
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
3015
|
+
const stages = () => [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $count: "count" }];
|
|
3016
|
+
const pruned = this._viewRead(query, "count", stages);
|
|
3017
|
+
if (pruned || containsRelationFilter(query.filter)) {
|
|
3018
|
+
const pipeline = pruned?.pipeline ?? stages();
|
|
3019
|
+
return (await this._readPipeline(query, pipeline, "aggregate (count)", pruned))[0]?.count ?? 0;
|
|
2936
3020
|
}
|
|
2937
3021
|
const filter = buildMongoFilter(query.filter);
|
|
2938
3022
|
if (this._estimatesCount() && Object.keys(filter).length === 0) {
|
|
@@ -3137,14 +3221,71 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
3137
3221
|
fieldCounters.set(physical, next + 1);
|
|
3138
3222
|
}
|
|
3139
3223
|
}
|
|
3140
|
-
/**
|
|
3224
|
+
/**
|
|
3225
|
+
* The pruned read of this adapter's managed view for `query` followed by
|
|
3226
|
+
* `stages` (`mongoViewRead`, since 0.1.153): the view pipeline without the
|
|
3227
|
+
* joins the read does not need, on the entry collection — or `undefined`
|
|
3228
|
+
* to read the stored view: pruning off, not a view, nothing droppable, an
|
|
3229
|
+
* operation-wide collation (it would govern the view's join keys too), or
|
|
3230
|
+
* stages that may read a dropped column ({@link stagesReadAny}).
|
|
3231
|
+
*/
|
|
3232
|
+
_viewRead(query, kind, stages) {
|
|
3233
|
+
if (!this.viewJoinPruning || !this._table?.isView) return void 0;
|
|
3234
|
+
if (!containsRelationFilter(query.filter) && this._getCollationOpts(query)) return void 0;
|
|
3235
|
+
const read = mongoViewRead(this._table, query, kind);
|
|
3236
|
+
if (!read) return void 0;
|
|
3237
|
+
const own = typeof stages === "function" ? stages() : stages;
|
|
3238
|
+
if (stagesReadAny(own, read.dropped)) return void 0;
|
|
3239
|
+
return {
|
|
3240
|
+
collection: this.db.collection(read.entry),
|
|
3241
|
+
pipeline: [...read.prefix, ...own]
|
|
3242
|
+
};
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Runs a read pipeline — through the pruned view read `pruned` when there
|
|
3246
|
+
* is one, on the bound collection otherwise. A view's own default
|
|
3247
|
+
* collation is the simple one, so the pruned read pins it: the entry
|
|
3248
|
+
* collection's default must not apply.
|
|
3249
|
+
*/
|
|
3250
|
+
_readPipeline(query, stages, label, pruned, options) {
|
|
3251
|
+
if (pruned) {
|
|
3252
|
+
this._log(`${label} (pruned view)`, pruned.pipeline);
|
|
3253
|
+
return wrapInvalidQuery(() => pruned.collection.aggregate(pruned.pipeline, {
|
|
3254
|
+
...options,
|
|
3255
|
+
collation: { locale: "simple" },
|
|
3256
|
+
...this._getSessionOpts()
|
|
3257
|
+
}).toArray());
|
|
3258
|
+
}
|
|
3259
|
+
this._log(label, stages);
|
|
3260
|
+
return wrapInvalidQuery(() => this.collection.aggregate(stages, {
|
|
3261
|
+
...options,
|
|
3262
|
+
...this._readOpts(query)
|
|
3263
|
+
}).toArray());
|
|
3264
|
+
}
|
|
3265
|
+
/** A find over the pruned view read, or `undefined` when the stored view is read. */
|
|
3266
|
+
_prunedFind(query, label, limit) {
|
|
3267
|
+
const pruned = this._viewRead(query, "rows", () => [...mongoFilterStages(query.filter, this._predicateFilterOpts), ...pageStages(limit ? {
|
|
3268
|
+
...query.controls,
|
|
3269
|
+
$limit: limit
|
|
3270
|
+
} : query.controls)]);
|
|
3271
|
+
return pruned && this._readPipeline(query, pruned.pipeline, `aggregate (${label})`, pruned, placedSortOptions(query.controls));
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* A find as an aggregation pipeline: its filter holds relational
|
|
3275
|
+
* predicates, or its `$sort` places NULL (`$nulls` — `find().sort()` cannot
|
|
3276
|
+
* express it; the placed sort is a blocking sort, so it may spill to disk).
|
|
3277
|
+
*/
|
|
3141
3278
|
async _aggregateFind(query, label, limit) {
|
|
3142
3279
|
const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), ...pageStages(limit ? {
|
|
3143
3280
|
...query.controls,
|
|
3144
3281
|
$limit: limit
|
|
3145
3282
|
} : query.controls)];
|
|
3146
3283
|
this._log(`aggregate (${label})`, pipeline);
|
|
3147
|
-
|
|
3284
|
+
const opts = {
|
|
3285
|
+
...placedSortOptions(query.controls),
|
|
3286
|
+
...this._readOpts(query)
|
|
3287
|
+
};
|
|
3288
|
+
return wrapInvalidQuery(() => this.collection.aggregate(pipeline, opts).toArray());
|
|
3148
3289
|
}
|
|
3149
3290
|
_buildFindOptions(controls) {
|
|
3150
3291
|
const opts = {};
|
|
@@ -3257,13 +3398,24 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
3257
3398
|
return node.fields;
|
|
3258
3399
|
}
|
|
3259
3400
|
};
|
|
3260
|
-
/**
|
|
3401
|
+
/**
|
|
3402
|
+
* `allowDiskUse` for a read whose `$sort` places NULL (`$nulls`): the null
|
|
3403
|
+
* flags make it a blocking sort no index serves, so it may need to spill.
|
|
3404
|
+
*/
|
|
3405
|
+
function placedSortOptions(controls) {
|
|
3406
|
+
return hasNullsPlacement(controls) ? { allowDiskUse: true } : void 0;
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* `$sort` → `$skip` → `$limit` → `$project` stages of a read's controls (a
|
|
3410
|
+
* `$nulls` placement adds its flags before the `$sort` and drops them after
|
|
3411
|
+
* `$limit` — see {@link sortStages}).
|
|
3412
|
+
*/
|
|
3261
3413
|
function pageStages(controls) {
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
if (controls.$sort) stages.push({ $sort: controls.$sort });
|
|
3414
|
+
if (!controls) return [];
|
|
3415
|
+
const { stages, cleanup } = sortStages(controls);
|
|
3265
3416
|
if (controls.$skip) stages.push({ $skip: controls.$skip });
|
|
3266
3417
|
if (controls.$limit) stages.push({ $limit: controls.$limit });
|
|
3418
|
+
if (cleanup) stages.push(cleanup);
|
|
3267
3419
|
if (controls.$select) {
|
|
3268
3420
|
const projection = controls.$select.asProjection;
|
|
3269
3421
|
if (projection) stages.push({ $project: dedupeProjection(projection) });
|