@atscript/db-mongo 0.1.151 → 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/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { a as buildMongoQuery, c as planStages, d as orNull, f as queryNodeToExpr, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, o as collationOfAdapter, p as correlate, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator } from "./mongo-accumulator-DYhg8usT.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, 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 };
@@ -901,13 +1159,22 @@ function resolveGeoKeyPath(host, indexName) {
901
1159
  }]);
902
1160
  return field;
903
1161
  }
904
- /** Appends a `$project` stage, keeping the computed distance in inclusion mode. */
1162
+ /**
1163
+ * Appends a `$project` stage, keeping the computed distance in inclusion mode.
1164
+ * The distance key goes on a COPY: `asProjection` (and `dedupeProjection`)
1165
+ * hand back the caller's own `$select` map when nothing needs rewriting — an
1166
+ * object the caller may share across queries (moost-db memoizes, and in dev /
1167
+ * test deep-freezes, the projection of a read without `$select`).
1168
+ */
905
1169
  function pushGeoProjection(stages, controls) {
906
1170
  const projection = controls?.$select?.asProjection;
907
1171
  if (!projection) return;
908
1172
  const deduped = dedupeProjection(projection);
909
- if (Object.values(deduped).some((v) => v === 1)) deduped[DISTANCE_FIELD] = 1;
910
- stages.push({ $project: deduped });
1173
+ const isInclusion = Object.values(deduped).some((v) => v === 1);
1174
+ stages.push({ $project: isInclusion ? {
1175
+ ...deduped,
1176
+ [DISTANCE_FIELD]: 1
1177
+ } : deduped });
911
1178
  }
912
1179
  /** Renames the internal distance field to the public `$distance` pseudo-field. */
913
1180
  function renameDistance(row) {
@@ -1217,11 +1484,13 @@ async function runSearchPipeline(host, stage, query, label, { threshold, classic
1217
1484
  pipeline.push({ $match: { _score: { $gte: threshold } } });
1218
1485
  } else if (classicText) pipeline.push({ $addFields: { _score: { $meta: "textScore" } } });
1219
1486
  pipeline.push(...filterStages ?? mongoFilterStages(query.filter, filterOptionsOf(host)));
1220
- if (controls.$sort) pipeline.push({ $sort: controls.$sort });
1487
+ const { stages: sorted, cleanup } = sortStages(controls);
1488
+ if (sorted.length > 0) pipeline.push(...sorted);
1221
1489
  else if (classicText) pipeline.push({ $sort: { _score: -1 } });
1222
1490
  if (controls.$skip) pipeline.push({ $skip: controls.$skip });
1223
1491
  if (controls.$limit) pipeline.push({ $limit: controls.$limit });
1224
1492
  else pipeline.push({ $limit: 1e3 });
1493
+ if (cleanup) pipeline.push(cleanup);
1225
1494
  if (controls.$select) {
1226
1495
  const projection = controls.$select.asProjection;
1227
1496
  if (projection) pipeline.push({ $project: dedupeProjection(projection) });
@@ -1246,11 +1515,11 @@ async function runSearchWithCountPipeline(host, stage, query, label, { threshold
1246
1515
  preStages.push({ $addFields: { _score: { $meta: "vectorSearchScore" } } });
1247
1516
  preStages.push({ $match: { _score: { $gte: threshold } } });
1248
1517
  } else if (classicText) preStages.push({ $addFields: { _score: { $meta: "textScore" } } });
1249
- const dataStages = [];
1250
- if (controls.$sort) dataStages.push({ $sort: controls.$sort });
1251
- 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 } });
1252
1520
  if (controls.$skip) dataStages.push({ $skip: controls.$skip });
1253
1521
  if (controls.$limit) dataStages.push({ $limit: controls.$limit });
1522
+ if (cleanup) dataStages.push(cleanup);
1254
1523
  if (controls.$select) {
1255
1524
  const projection = controls.$select.asProjection;
1256
1525
  if (projection) dataStages.push({ $project: dedupeProjection(projection) });
@@ -1300,202 +1569,6 @@ async function runSearchWithMeta(host, stage, dataStages, label) {
1300
1569
  };
1301
1570
  }
1302
1571
  //#endregion
1303
- //#region src/lib/mongo-view-pipeline.ts
1304
- /** Where a joined table's document lands (`$lookup.as`, then unwound). */
1305
- function joinedAs(table) {
1306
- return `${JOINED_PREFIX}${table}`;
1307
- }
1308
- /**
1309
- * Builds the aggregation pipeline of a managed MongoDB view (created with
1310
- * `viewOn: <entry collection>`):
1311
- *
1312
- * 1. per join, in declaration order: `$lookup` + `$unwind` (inner joins drop
1313
- * unmatched documents, left joins keep them — `preserveNullAndEmptyArrays`);
1314
- * 2. `@db.view.filter` → `$match`;
1315
- * 3. aggregates → `$group` (+ `$addFields` turning `countDistinct` sets into
1316
- * their sizes, + `@db.view.having` → `$match`) + `$project`, otherwise a
1317
- * flat `$project`. A conditional aggregate's predicate reads the same
1318
- * pipeline paths (SQL null semantics — `queryNodeToExpr`).
1319
- *
1320
- * All field paths are PHYSICAL document paths (`@db.column` renames), joined
1321
- * tables live under `__joined_<scope>` — the physical collection, or the
1322
- * `@db.alias` type name for an aliased join (`__joined_Manager`), whose
1323
- * `$lookup.from` stays the physical collection (since 0.1.141).
1324
- * The entry collection and a `$lookup.from` may be views. A column whose source may be missing
1325
- * (`nullable` mapping) reads as null; every other column is a plain path, so
1326
- * a `$match` / `$sort` on the view can still push down to an index.
1327
- * @since 0.1.136
1328
- */
1329
- function buildViewPipeline(view) {
1330
- const plan = view.viewPlan;
1331
- const columns = view.getViewColumnMappings();
1332
- const pipeline = [];
1333
- const prefixes = new Map([[plan.entryTable, ""]]);
1334
- const pathOf = (ref) => {
1335
- const { table, source } = view.resolveRefSource(ref);
1336
- const prefix = prefixes.get(table);
1337
- if (prefix === void 0) throw new Error(`View "${view.tableName}": "${table}" is not joined before it is referenced`);
1338
- return prefix + source.column;
1339
- };
1340
- for (const join of plan.joins) {
1341
- pipeline.push(buildLookup(view, join, prefixes));
1342
- pipeline.push({ $unwind: {
1343
- path: `$${joinedAs(join.scope)}`,
1344
- preserveNullAndEmptyArrays: join.kind === "left"
1345
- } });
1346
- prefixes.set(join.scope, `${joinedAs(join.scope)}.`);
1347
- }
1348
- if (plan.filter) pipeline.push({ $match: viewMatch(plan.filter, pathOf) });
1349
- /** A column's source field as an aggregation operand. */
1350
- const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
1351
- const colValue = (col) => col.nullable ? orNull(colSourceField(col)) : colSourceField(col);
1352
- const byPath = new Map(columns.map((c) => [c.viewPath, c]));
1353
- const rendered = /* @__PURE__ */ new Map();
1354
- const computedExpr = (col, leaf) => {
1355
- let out = rendered.get(col.viewPath);
1356
- if (out === void 0) {
1357
- out = exprToMongo(col.expr, (path) => {
1358
- const c = byPath.get(path);
1359
- return c.expr === void 0 ? { $toDouble: leaf(c) } : computedExpr(c, leaf);
1360
- });
1361
- rendered.set(col.viewPath, out);
1362
- }
1363
- return out;
1364
- };
1365
- if (columns.some((c) => c.aggFn)) {
1366
- const group = { _id: {} };
1367
- const project = { _id: 0 };
1368
- const distinctSizes = {};
1369
- const computed = {};
1370
- for (const col of columns) if (col.expr !== void 0) {
1371
- computed[col.viewColumn] = computedExpr(col, (c) => c.aggFn ? `$${c.viewColumn}` : `$_id.${c.viewColumn}`);
1372
- project[col.viewColumn] = `$${col.viewColumn}`;
1373
- } else if (col.aggFn) {
1374
- const predicate = col.aggFilter ? queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
1375
- group[col.viewColumn] = buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
1376
- project[col.viewColumn] = `$${col.viewColumn}`;
1377
- if (col.aggFn === "countDistinct") distinctSizes[col.viewColumn] = distinctCountExpr(`$${col.viewColumn}`);
1378
- } else {
1379
- group._id[col.viewColumn] = colValue(col);
1380
- project[col.viewColumn] = `$_id.${col.viewColumn}`;
1381
- }
1382
- pipeline.push({ $group: group });
1383
- if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
1384
- if (Object.keys(computed).length > 0) pipeline.push({ $addFields: computed });
1385
- if (plan.having) pipeline.push({ $match: havingMatch(plan.having, byPath) });
1386
- pipeline.push({ $project: project });
1387
- } else {
1388
- const project = { _id: 0 };
1389
- for (const col of columns) project[col.viewColumn] = col.expr === void 0 ? colValue(col) : computedExpr(col, colValue);
1390
- pipeline.push({ $project: project });
1391
- }
1392
- return pipeline;
1393
- }
1394
- /**
1395
- * One join's `$lookup`.
1396
- *
1397
- * The simple `localField` / `foreignField` form is used whenever it is exact:
1398
- * a single `=` between a field of the joined table and a field of an outer
1399
- * table (the entry or an earlier join), with the joined field REQUIRED — the
1400
- * simple form matches a null / missing local value to null / missing foreign
1401
- * values, which a required foreign field can never hold. It is kept (rather
1402
- * than always using the pipeline form) because it can use an index on the
1403
- * foreign field on every MongoDB version; the pipeline form's `$expr` match
1404
- * can't before MongoDB 5.0. Everything else uses the pipeline form (`let` +
1405
- * `$match: { $expr }`, SQL null semantics).
1406
- */
1407
- function buildLookup(view, join, outer) {
1408
- const scope = join.scope;
1409
- const as = joinedAs(scope);
1410
- const simple = join.first ? void 0 : simpleJoinFields(view, join, outer);
1411
- if (simple) return { $lookup: {
1412
- from: join.targetTable,
1413
- ...simple,
1414
- as
1415
- } };
1416
- const letVars = {};
1417
- const varByPath = /* @__PURE__ */ new Map();
1418
- const pathOf = (ref) => {
1419
- const { table, source } = view.resolveRefSource(ref);
1420
- if (table === scope) return `$${source.column}`;
1421
- const prefix = outer.get(table);
1422
- if (prefix === void 0) throw new Error(`View "${view.tableName}": the join on "${scope}" references "${table}", which is not joined before it`);
1423
- const outerPath = prefix + source.column;
1424
- let varName = varByPath.get(outerPath);
1425
- if (!varName) {
1426
- varName = `v${varByPath.size}`;
1427
- varByPath.set(outerPath, varName);
1428
- letVars[varName] = `$${outerPath}`;
1429
- }
1430
- return `$$${varName}`;
1431
- };
1432
- const pipeline = [{ $match: { $expr: queryNodeToExpr(join.condition, pathOf) } }];
1433
- if (join.first) {
1434
- const sort = {};
1435
- for (const { ref, desc } of join.first.order) sort[view.resolveRefSource(ref).source.column] = desc ? -1 : 1;
1436
- pipeline.push({ $sort: sort }, { $limit: 1 });
1437
- }
1438
- return { $lookup: {
1439
- from: join.targetTable,
1440
- ...varByPath.size > 0 ? { let: letVars } : {},
1441
- pipeline,
1442
- as
1443
- } };
1444
- }
1445
- /** `{ localField, foreignField }` when the simple `$lookup` form is exact, else `undefined`. */
1446
- function simpleJoinFields(view, join, outer) {
1447
- let node = join.condition;
1448
- if ("$and" in node) {
1449
- const children = node.$and;
1450
- if (children.length !== 1) return void 0;
1451
- node = children[0];
1452
- }
1453
- if ("$or" in node || "$not" in node || "$and" in node) return void 0;
1454
- const comp = node;
1455
- if (comp.op !== "$eq" || !isFieldRef(comp.right)) return void 0;
1456
- const scope = join.scope;
1457
- const left = view.resolveRefSource(comp.left);
1458
- const right = view.resolveRefSource(comp.right);
1459
- let target;
1460
- let local;
1461
- if (left.table === scope && right.table !== scope) {
1462
- target = left;
1463
- local = right;
1464
- } else if (right.table === scope && left.table !== scope) {
1465
- target = right;
1466
- local = left;
1467
- } else return;
1468
- const localPrefix = outer.get(local.table);
1469
- if (localPrefix === void 0) return void 0;
1470
- const foreign = target.source;
1471
- if (!(!foreign.optional && foreign.designType !== "union" && foreign.designType !== "unknown")) return void 0;
1472
- return {
1473
- localField: localPrefix + local.source.column,
1474
- foreignField: foreign.column
1475
- };
1476
- }
1477
- /**
1478
- * A view predicate (`@db.view.filter`, `@db.view.having`) as a `$match`
1479
- * over pipeline paths: the shared query translation (`translateQueryTree` →
1480
- * `buildMongoFilter`), so operators mean what they mean in queries (`exists`
1481
- * = holds a value, `matches` takes `/re/flags`); a field-to-field comparison
1482
- * becomes `$expr`.
1483
- */
1484
- function viewMatch(node, pathOf) {
1485
- return buildMongoFilter(translateQueryTree(node, pathOf), { fieldOperands: true });
1486
- }
1487
- /**
1488
- * `@db.view.having` as a `$match` after `$group`: refs name view fields —
1489
- * aggregates are top-level, dimensions under `_id`.
1490
- */
1491
- function havingMatch(node, colMap) {
1492
- return viewMatch(node, (ref) => {
1493
- const col = ref.type ? void 0 : colMap.get(ref.field);
1494
- if (!col) return ref.field;
1495
- return col.aggFn || col.expr !== void 0 ? col.viewColumn : `_id.${col.viewColumn}`;
1496
- });
1497
- }
1498
- //#endregion
1499
1572
  //#region src/lib/mongo-schema-sync.ts
1500
1573
  const DESTRUCTIVE_OPTION_KEYS = new Set([
1501
1574
  "capped",
@@ -2085,6 +2158,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2085
2158
  this.db = db;
2086
2159
  this.client = client;
2087
2160
  this.options = options;
2161
+ this.viewJoinPruning = options.viewJoinPruning !== false;
2088
2162
  }
2089
2163
  get _client() {
2090
2164
  return this.client;
@@ -2177,15 +2251,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2177
2251
  return (applied ?? await run(stages))[0];
2178
2252
  };
2179
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
+ };
2180
2260
  if (query.controls?.$count) {
2181
- const pipeline = buildCountPipeline(query, searchStage, this._predicateFilterOpts);
2182
- this._log("aggregate (count)", pipeline);
2183
- const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
2261
+ const result = await run(buildCountPipeline(query, searchStage, this._predicateFilterOpts), "aggregate (count)");
2184
2262
  return result.length > 0 ? result : [{ count: await emptyGroup(true) ? 1 : 0 }];
2185
2263
  }
2186
- const pipeline = buildAggregatePipeline(query, searchStage, this._predicateFilterOpts);
2187
- this._log("aggregate", pipeline);
2188
- const rows = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
2264
+ const rows = await run(buildAggregatePipeline(query, searchStage, this._predicateFilterOpts), "aggregate");
2189
2265
  const empty = rows.length === 0 ? await emptyGroup(false) : void 0;
2190
2266
  return empty ? [empty] : rows;
2191
2267
  }
@@ -2235,6 +2311,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2235
2311
  supportsAggregateExpressions() {
2236
2312
  return true;
2237
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
+ }
2238
2322
  /** Computed view columns and first-row joins. */
2239
2323
  viewCapabilities() {
2240
2324
  return ALL_VIEW_CAPABILITIES;
@@ -2456,7 +2540,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2456
2540
  if (this._hasExplicitId) return {
2457
2541
  addPrimaryKeys: ["_id"],
2458
2542
  removePrimaryKeys: meta.originalMetaIdFields.filter((f) => f !== "_id"),
2459
- addUniqueFields: uniqueFields.length > 0 ? uniqueFields : void 0
2543
+ addUniqueFields: uniqueFields.length === 1 ? uniqueFields : void 0,
2544
+ addUniqueKeys: uniqueFields.length > 1 ? [[...uniqueFields]] : void 0
2460
2545
  };
2461
2546
  const effectiveUnique = uniqueFields.filter((f) => meta.primaryKeys.length <= 1 || !meta.primaryKeys.includes(f));
2462
2547
  effectiveUnique.push("_id");
@@ -2630,8 +2715,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2630
2715
  data: pageStages(query.controls),
2631
2716
  meta: [{ $count: "count" }]
2632
2717
  } }];
2633
- this._log("aggregate (findManyWithCount)", pipeline);
2634
- const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
2718
+ const pruned = this._viewRead(query, "rows", pipeline);
2719
+ const result = await this._readPipeline(query, pipeline, "aggregate (findManyWithCount)", pruned, placedSortOptions(query.controls));
2635
2720
  return {
2636
2721
  data: result[0]?.data || [],
2637
2722
  count: result[0]?.meta[0]?.count || 0
@@ -2895,7 +2980,12 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2895
2980
  }
2896
2981
  }
2897
2982
  async findOne(query) {
2898
- if (containsRelationFilter(query.filter)) {
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)) {
2899
2989
  const [row] = await this._aggregateFind(query, "findOne", 1);
2900
2990
  return row ?? null;
2901
2991
  }
@@ -2909,7 +2999,9 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2909
2999
  }));
2910
3000
  }
2911
3001
  async findMany(query) {
2912
- if (containsRelationFilter(query.filter)) return this._aggregateFind(query, "findMany");
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");
2913
3005
  const filter = buildMongoFilter(query.filter);
2914
3006
  const opts = this._buildFindOptions(query.controls);
2915
3007
  this._log("findMany", filter, opts);
@@ -2920,10 +3012,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2920
3012
  }).toArray());
2921
3013
  }
2922
3014
  async count(query) {
2923
- if (containsRelationFilter(query.filter)) {
2924
- const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $count: "count" }];
2925
- this._log("aggregate (count)", pipeline);
2926
- return (await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray()))[0]?.count ?? 0;
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;
2927
3020
  }
2928
3021
  const filter = buildMongoFilter(query.filter);
2929
3022
  if (this._estimatesCount() && Object.keys(filter).length === 0) {
@@ -3128,14 +3221,71 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3128
3221
  fieldCounters.set(physical, next + 1);
3129
3222
  }
3130
3223
  }
3131
- /** A find whose filter holds relational predicates, as an aggregation pipeline. */
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
+ */
3132
3278
  async _aggregateFind(query, label, limit) {
3133
3279
  const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), ...pageStages(limit ? {
3134
3280
  ...query.controls,
3135
3281
  $limit: limit
3136
3282
  } : query.controls)];
3137
3283
  this._log(`aggregate (${label})`, pipeline);
3138
- return wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
3284
+ const opts = {
3285
+ ...placedSortOptions(query.controls),
3286
+ ...this._readOpts(query)
3287
+ };
3288
+ return wrapInvalidQuery(() => this.collection.aggregate(pipeline, opts).toArray());
3139
3289
  }
3140
3290
  _buildFindOptions(controls) {
3141
3291
  const opts = {};
@@ -3248,13 +3398,24 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3248
3398
  return node.fields;
3249
3399
  }
3250
3400
  };
3251
- /** `$sort` → `$skip` → `$limit` → `$project` stages of a read's controls. */
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
+ */
3252
3413
  function pageStages(controls) {
3253
- const stages = [];
3254
- if (!controls) return stages;
3255
- if (controls.$sort) stages.push({ $sort: controls.$sort });
3414
+ if (!controls) return [];
3415
+ const { stages, cleanup } = sortStages(controls);
3256
3416
  if (controls.$skip) stages.push({ $skip: controls.$skip });
3257
3417
  if (controls.$limit) stages.push({ $limit: controls.$limit });
3418
+ if (cleanup) stages.push(cleanup);
3258
3419
  if (controls.$select) {
3259
3420
  const projection = controls.$select.asProjection;
3260
3421
  if (projection) stages.push({ $project: dedupeProjection(projection) });