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