@atscript/db-mongo 0.1.135 → 0.1.137

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.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
2
2
  import { AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
3
3
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
4
- import { BucketUnit } from "@uniqu/core";
4
+ import { AggregateFn, BucketUnit } from "@uniqu/core";
5
5
 
6
6
  //#region src/lib/collection-patcher.d.ts
7
7
  /**
@@ -303,6 +303,13 @@ declare class MongoAdapter extends BaseDbAdapter {
303
303
  supportsNativePatch(): boolean;
304
304
  /** All five units, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
305
305
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
306
+ /** Every aggregate function, `countDistinct` included. */
307
+ aggregateFns(): ReadonlySet<AggregateFn>;
308
+ /**
309
+ * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
310
+ * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
311
+ */
312
+ viewRenderRevision(): string;
306
313
  getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
307
314
  /**
308
315
  * Mongo can filter on JSON-stored fields natively — arrays via implicit
@@ -471,8 +478,15 @@ declare class MongoAdapter extends BaseDbAdapter {
471
478
  *
472
479
  * MongoDB's query language is nearly identical to the `FilterExpr` structure,
473
480
  * so this is largely a structural pass-through via the `walkFilter` visitor.
481
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
482
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
483
+ * request filter never gets that reading.
474
484
  */
475
- declare function buildMongoFilter(filter: FilterExpr): Filter<any>;
485
+ declare function buildMongoFilter(filter: FilterExpr, {
486
+ fieldOperands
487
+ }?: {
488
+ fieldOperands?: boolean;
489
+ }): Filter<any>;
476
490
  //#endregion
477
491
  //#region src/lib/validate-plugins.d.ts
478
492
  declare const validateMongoIdPlugin: TValidatorPlugin;
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
2
2
  import { AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
3
- import { BucketUnit } from "@uniqu/core";
3
+ import { AggregateFn, BucketUnit } from "@uniqu/core";
4
4
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
5
5
 
6
6
  //#region src/lib/collection-patcher.d.ts
@@ -303,6 +303,13 @@ declare class MongoAdapter extends BaseDbAdapter {
303
303
  supportsNativePatch(): boolean;
304
304
  /** All five units, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
305
305
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
306
+ /** Every aggregate function, `countDistinct` included. */
307
+ aggregateFns(): ReadonlySet<AggregateFn>;
308
+ /**
309
+ * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
310
+ * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
311
+ */
312
+ viewRenderRevision(): string;
306
313
  getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
307
314
  /**
308
315
  * Mongo can filter on JSON-stored fields natively — arrays via implicit
@@ -471,8 +478,15 @@ declare class MongoAdapter extends BaseDbAdapter {
471
478
  *
472
479
  * MongoDB's query language is nearly identical to the `FilterExpr` structure,
473
480
  * so this is largely a structural pass-through via the `walkFilter` visitor.
481
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
482
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
483
+ * request filter never gets that reading.
474
484
  */
475
- declare function buildMongoFilter(filter: FilterExpr): Filter<any>;
485
+ declare function buildMongoFilter(filter: FilterExpr, {
486
+ fieldOperands
487
+ }?: {
488
+ fieldOperands?: boolean;
489
+ }): Filter<any>;
476
490
  //#endregion
477
491
  //#region src/lib/validate-plugins.d.ts
478
492
  declare const validateMongoIdPlugin: TValidatorPlugin;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { t as buildMongoFilter } from "./mongo-filter-CtyITmZM.mjs";
2
- import { ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, computeInsights, createFailureCollector, getDbFieldOp, getKeyProps, isAtscriptDbView, resolveDesignType } from "@atscript/db";
1
+ import { a as queryNodeToExpr, i as orNull, n as distinctCountExpr, r as buildMongoFilter, t as buildAccumulator } from "./mongo-accumulator-Dc0w4t0l.mjs";
2
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, computeInsights, createFailureCollector, getDbFieldOp, getKeyProps, isAtscriptDbView, isFieldRef, resolveDesignType, tableNameOf, translateQueryTree } from "@atscript/db";
3
3
  import { MongoClient, MongoServerError, ObjectId } from "mongodb";
4
4
  import { resolveAggregateSearch } from "@atscript/db/agg";
5
5
  //#region src/lib/path-utils.ts
@@ -497,7 +497,7 @@ function buildFromLookup(host, withRel, relation, tableResolver) {
497
497
  if (!targetMeta) return;
498
498
  const remoteFK = findRemoteFK(targetMeta, host._table.tableName, relation.alias);
499
499
  if (!remoteFK) return;
500
- const targetTableName = resolveRelTargetTableName(relation);
500
+ const targetTableName = tableNameOf(relation.targetType());
501
501
  const innerPipeline = buildLookupInnerPipeline(withRel, remoteFK.fields);
502
502
  const { letVars, exprMatch } = buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
503
503
  const stages = [{ $lookup: {
@@ -522,8 +522,8 @@ function buildViaLookup(host, withRel, relation, tableResolver) {
522
522
  if (!junctionType) return;
523
523
  const junctionMeta = tableResolver(junctionType);
524
524
  if (!junctionMeta) return;
525
- const junctionTableName = junctionType.metadata?.get("db.table") || junctionType.id || "";
526
- const targetTableName = resolveRelTargetTableName(relation);
525
+ const junctionTableName = tableNameOf(junctionType);
526
+ const targetTableName = tableNameOf(relation.targetType());
527
527
  const fkToThis = findRemoteFK(junctionMeta, host._table.tableName);
528
528
  if (!fkToThis) return;
529
529
  const fkToTarget = findRemoteFK(junctionMeta, targetTableName);
@@ -619,7 +619,7 @@ function mergeRelationResults(rows, results, primaryKeys, relMeta) {
619
619
  }
620
620
  /** Finds FK entry for a TO relation from this table's foreignKeys map. */
621
621
  function findFKForRelation(relation, foreignKeys) {
622
- const targetTableName = resolveRelTargetTableName(relation);
622
+ const targetTableName = tableNameOf(relation.targetType());
623
623
  for (const fk of foreignKeys.values()) if (relation.alias) {
624
624
  if (fk.alias === relation.alias) return {
625
625
  localFields: fk.fields,
@@ -639,11 +639,6 @@ function findRemoteFK(target, thisTableName, alias) {
639
639
  if (!alias && fk.targetTable === thisTableName) return fk;
640
640
  }
641
641
  }
642
- /** Resolves the target table/collection name from a relation's target type. */
643
- function resolveRelTargetTableName(relation) {
644
- const targetType = relation.targetType();
645
- return targetType?.metadata?.get("db.table") || targetType?.id || "";
646
- }
647
642
  //#endregion
648
643
  //#region src/lib/mongo-search.ts
649
644
  /** Returns available search indexes as generic metadata for UI. */
@@ -1019,6 +1014,175 @@ async function runSearchWithCountPipeline(host, stage, query, label, threshold,
1019
1014
  };
1020
1015
  }
1021
1016
  //#endregion
1017
+ //#region src/lib/mongo-view-pipeline.ts
1018
+ /** Where a joined table's document lands (`$lookup.as`, then unwound). */
1019
+ function joinedAs(table) {
1020
+ return `${JOINED_PREFIX}${table}`;
1021
+ }
1022
+ /**
1023
+ * Builds the aggregation pipeline of a managed MongoDB view (created with
1024
+ * `viewOn: <entry collection>`):
1025
+ *
1026
+ * 1. per join, in declaration order: `$lookup` + `$unwind` (inner joins drop
1027
+ * unmatched documents, left joins keep them — `preserveNullAndEmptyArrays`);
1028
+ * 2. `@db.view.filter` → `$match`;
1029
+ * 3. aggregates → `$group` (+ `$addFields` turning `countDistinct` sets into
1030
+ * their sizes, + `@db.view.having` → `$match`) + `$project`, otherwise a
1031
+ * flat `$project`. A conditional aggregate's predicate reads the same
1032
+ * pipeline paths (SQL null semantics — `queryNodeToExpr`).
1033
+ *
1034
+ * All field paths are PHYSICAL document paths (`@db.column` renames), joined
1035
+ * tables live under `__joined_<table>`. A column whose source may be missing
1036
+ * (`nullable` mapping) reads as null; every other column is a plain path, so
1037
+ * a `$match` / `$sort` on the view can still push down to an index.
1038
+ * @since 0.1.136
1039
+ */
1040
+ function buildViewPipeline(view) {
1041
+ const plan = view.viewPlan;
1042
+ const columns = view.getViewColumnMappings();
1043
+ const pipeline = [];
1044
+ const prefixes = new Map([[plan.entryTable, ""]]);
1045
+ const pathOf = (ref) => {
1046
+ const { table, source } = view.resolveRefSource(ref);
1047
+ const prefix = prefixes.get(table);
1048
+ if (prefix === void 0) throw new Error(`View "${view.tableName}": "${table}" is not joined before it is referenced`);
1049
+ return prefix + source.column;
1050
+ };
1051
+ for (const join of plan.joins) {
1052
+ pipeline.push(buildLookup(view, join, prefixes));
1053
+ pipeline.push({ $unwind: {
1054
+ path: `$${joinedAs(join.targetTable)}`,
1055
+ preserveNullAndEmptyArrays: join.kind === "left"
1056
+ } });
1057
+ prefixes.set(join.targetTable, `${joinedAs(join.targetTable)}.`);
1058
+ }
1059
+ if (plan.filter) pipeline.push({ $match: viewMatch(plan.filter, pathOf) });
1060
+ /** A column's source field as an aggregation operand. */
1061
+ const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
1062
+ const colValue = (col) => col.nullable ? orNull(colSourceField(col)) : colSourceField(col);
1063
+ if (columns.some((c) => c.aggFn)) {
1064
+ const group = { _id: {} };
1065
+ const project = { _id: 0 };
1066
+ const distinctSizes = {};
1067
+ for (const col of columns) if (col.aggFn) {
1068
+ const predicate = col.aggFilter ? queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
1069
+ group[col.viewColumn] = buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
1070
+ project[col.viewColumn] = `$${col.viewColumn}`;
1071
+ if (col.aggFn === "countDistinct") distinctSizes[col.viewColumn] = distinctCountExpr(`$${col.viewColumn}`);
1072
+ } else {
1073
+ group._id[col.viewColumn] = colValue(col);
1074
+ project[col.viewColumn] = `$_id.${col.viewColumn}`;
1075
+ }
1076
+ pipeline.push({ $group: group });
1077
+ if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
1078
+ if (plan.having) pipeline.push({ $match: havingMatch(plan.having, columns) });
1079
+ pipeline.push({ $project: project });
1080
+ } else {
1081
+ const project = { _id: 0 };
1082
+ for (const col of columns) project[col.viewColumn] = colValue(col);
1083
+ pipeline.push({ $project: project });
1084
+ }
1085
+ return pipeline;
1086
+ }
1087
+ /**
1088
+ * One join's `$lookup`.
1089
+ *
1090
+ * The simple `localField` / `foreignField` form is used whenever it is exact:
1091
+ * a single `=` between a field of the joined table and a field of an outer
1092
+ * table (the entry or an earlier join), with the joined field REQUIRED — the
1093
+ * simple form matches a null / missing local value to null / missing foreign
1094
+ * values, which a required foreign field can never hold. It is kept (rather
1095
+ * than always using the pipeline form) because it can use an index on the
1096
+ * foreign field on every MongoDB version; the pipeline form's `$expr` match
1097
+ * can't before MongoDB 5.0. Everything else uses the pipeline form (`let` +
1098
+ * `$match: { $expr }`, SQL null semantics).
1099
+ */
1100
+ function buildLookup(view, join, outer) {
1101
+ const as = joinedAs(join.targetTable);
1102
+ const simple = simpleJoinFields(view, join, outer);
1103
+ if (simple) return { $lookup: {
1104
+ from: join.targetTable,
1105
+ ...simple,
1106
+ as
1107
+ } };
1108
+ const letVars = {};
1109
+ const varByPath = /* @__PURE__ */ new Map();
1110
+ const pathOf = (ref) => {
1111
+ const { table, source } = view.resolveRefSource(ref);
1112
+ if (table === join.targetTable) return `$${source.column}`;
1113
+ const prefix = outer.get(table);
1114
+ if (prefix === void 0) throw new Error(`View "${view.tableName}": the join on "${join.targetTable}" references "${table}", which is not joined before it`);
1115
+ const outerPath = prefix + source.column;
1116
+ let varName = varByPath.get(outerPath);
1117
+ if (!varName) {
1118
+ varName = `v${varByPath.size}`;
1119
+ varByPath.set(outerPath, varName);
1120
+ letVars[varName] = `$${outerPath}`;
1121
+ }
1122
+ return `$$${varName}`;
1123
+ };
1124
+ const expr = queryNodeToExpr(join.condition, pathOf);
1125
+ return { $lookup: {
1126
+ from: join.targetTable,
1127
+ ...varByPath.size > 0 ? { let: letVars } : {},
1128
+ pipeline: [{ $match: { $expr: expr } }],
1129
+ as
1130
+ } };
1131
+ }
1132
+ /** `{ localField, foreignField }` when the simple `$lookup` form is exact, else `undefined`. */
1133
+ function simpleJoinFields(view, join, outer) {
1134
+ let node = join.condition;
1135
+ if ("$and" in node) {
1136
+ const children = node.$and;
1137
+ if (children.length !== 1) return void 0;
1138
+ node = children[0];
1139
+ }
1140
+ if ("$or" in node || "$not" in node || "$and" in node) return void 0;
1141
+ const comp = node;
1142
+ if (comp.op !== "$eq" || !isFieldRef(comp.right)) return void 0;
1143
+ const left = view.resolveRefSource(comp.left);
1144
+ const right = view.resolveRefSource(comp.right);
1145
+ let target;
1146
+ let local;
1147
+ if (left.table === join.targetTable && right.table !== join.targetTable) {
1148
+ target = left;
1149
+ local = right;
1150
+ } else if (right.table === join.targetTable && left.table !== join.targetTable) {
1151
+ target = right;
1152
+ local = left;
1153
+ } else return;
1154
+ const localPrefix = outer.get(local.table);
1155
+ if (localPrefix === void 0) return void 0;
1156
+ const foreign = target.source;
1157
+ if (!(!foreign.optional && foreign.designType !== "union" && foreign.designType !== "unknown")) return void 0;
1158
+ return {
1159
+ localField: localPrefix + local.source.column,
1160
+ foreignField: foreign.column
1161
+ };
1162
+ }
1163
+ /**
1164
+ * A view predicate (`@db.view.filter`, `@db.view.having`) as a `$match`
1165
+ * over pipeline paths: the shared query translation (`translateQueryTree` →
1166
+ * `buildMongoFilter`), so operators mean what they mean in queries (`exists`
1167
+ * = holds a value, `matches` takes `/re/flags`); a field-to-field comparison
1168
+ * becomes `$expr`.
1169
+ */
1170
+ function viewMatch(node, pathOf) {
1171
+ return buildMongoFilter(translateQueryTree(node, pathOf), { fieldOperands: true });
1172
+ }
1173
+ /**
1174
+ * `@db.view.having` as a `$match` after `$group`: refs name view fields —
1175
+ * aggregates are top-level, dimensions under `_id`.
1176
+ */
1177
+ function havingMatch(node, columns) {
1178
+ const colMap = new Map(columns.map((c) => [c.viewPath, c]));
1179
+ return viewMatch(node, (ref) => {
1180
+ const col = ref.type ? void 0 : colMap.get(ref.field);
1181
+ if (!col) return ref.field;
1182
+ return col.aggFn ? col.viewColumn : `_id.${col.viewColumn}`;
1183
+ });
1184
+ }
1185
+ //#endregion
1022
1186
  //#region src/lib/mongo-schema-sync.ts
1023
1187
  const DESTRUCTIVE_OPTION_KEYS = new Set([
1024
1188
  "capped",
@@ -1079,134 +1243,17 @@ async function getObjectKindImpl(host, name) {
1079
1243
  if (type === void 0) return;
1080
1244
  return type === "view" ? "view" : "table";
1081
1245
  }
1082
- /** Creates a MongoDB view from the AtscriptDbView's view plan. */
1246
+ /** Creates a MongoDB view from the AtscriptDbView's view plan (pipeline: {@link buildViewPipeline}). */
1083
1247
  async function ensureView(host, view) {
1084
1248
  if (await host.collectionExists()) return;
1085
1249
  const plan = view.viewPlan;
1086
- const columns = view.getViewColumnMappings();
1087
- const pipeline = [];
1088
- for (const join of plan.joins) {
1089
- const { localField, foreignField } = resolveJoinFields(join.condition, plan.entryTable, join.targetTable);
1090
- pipeline.push({ $lookup: {
1091
- from: join.targetTable,
1092
- localField,
1093
- foreignField,
1094
- as: `${JOINED_PREFIX}${join.targetTable}`
1095
- } });
1096
- pipeline.push({ $unwind: {
1097
- path: `$__joined_${join.targetTable}`,
1098
- preserveNullAndEmptyArrays: true
1099
- } });
1100
- }
1101
- if (plan.filter) {
1102
- const matchExpr = queryNodeToMatch(plan.filter, plan.entryTable);
1103
- pipeline.push({ $match: matchExpr });
1104
- }
1105
- const hasAggregates = columns.some((c) => c.aggFn);
1106
- /** Resolves a column to its MongoDB source field path. */
1107
- const colSourceField = (col) => col.sourceTable === plan.entryTable ? `$${col.sourceColumn}` : `$${JOINED_PREFIX}${col.sourceTable}.${col.sourceColumn}`;
1108
- if (hasAggregates) {
1109
- const group = { _id: {} };
1110
- const project = { _id: 0 };
1111
- for (const col of columns) if (col.aggFn) {
1112
- if (col.aggFn === "count" && col.aggField === "*") group[col.viewColumn] = { $sum: 1 };
1113
- else group[col.viewColumn] = { [`$${col.aggFn}`]: colSourceField(col) };
1114
- project[col.viewColumn] = `$${col.viewColumn}`;
1115
- } else {
1116
- group._id[col.viewColumn] = colSourceField(col);
1117
- project[col.viewColumn] = `$_id.${col.viewColumn}`;
1118
- }
1119
- pipeline.push({ $group: group });
1120
- if (plan.having) {
1121
- const havingMatch = queryNodeToHaving(plan.having, columns);
1122
- pipeline.push({ $match: havingMatch });
1123
- }
1124
- pipeline.push({ $project: project });
1125
- } else {
1126
- const project = { _id: 0 };
1127
- for (const col of columns) project[col.viewColumn] = colSourceField(col);
1128
- pipeline.push({ $project: project });
1129
- }
1250
+ const pipeline = buildViewPipeline(view);
1130
1251
  host._log("createView", host._table.tableName, plan.entryTable, pipeline);
1131
1252
  await host.db.createCollection(host._table.tableName, {
1132
1253
  viewOn: plan.entryTable,
1133
1254
  pipeline
1134
1255
  });
1135
1256
  }
1136
- /** Extracts localField/foreignField from a join condition. */
1137
- function resolveJoinFields(condition, entryTable, joinTable) {
1138
- const c = "$and" in condition ? condition.$and[0] : condition;
1139
- if ((c.left.type ? c.left.type()?.metadata?.get("db.table") || "" : entryTable) === joinTable) return {
1140
- localField: c.right.field,
1141
- foreignField: c.left.field
1142
- };
1143
- return {
1144
- localField: c.left.field,
1145
- foreignField: c.right.field
1146
- };
1147
- }
1148
- /** Translates an AtscriptQueryNode to a MongoDB $match expression. */
1149
- function queryNodeToMatch(node, entryTable) {
1150
- if ("$and" in node) {
1151
- const items = node.$and;
1152
- const result = [];
1153
- for (const item of items) result.push(queryNodeToMatch(item, entryTable));
1154
- return { $and: result };
1155
- }
1156
- if ("$or" in node) {
1157
- const items = node.$or;
1158
- const result = [];
1159
- for (const item of items) result.push(queryNodeToMatch(item, entryTable));
1160
- return { $or: result };
1161
- }
1162
- if ("$not" in node) return { $not: queryNodeToMatch(node.$not, entryTable) };
1163
- const comp = node;
1164
- const fieldPath = resolveViewFieldPath(comp.left, entryTable);
1165
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1166
- const rightPath = resolveViewFieldPath(comp.right, entryTable);
1167
- return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1168
- }
1169
- if (comp.op === "$eq") return { [fieldPath]: comp.right };
1170
- if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1171
- return { [fieldPath]: { [comp.op]: comp.right } };
1172
- }
1173
- /** Resolves a field ref to a MongoDB dot path for view pipeline expressions. */
1174
- function resolveViewFieldPath(ref, entryTable) {
1175
- if (!ref.type) return ref.field;
1176
- const table = ref.type()?.metadata?.get("db.table") || "";
1177
- if (table === entryTable) return ref.field;
1178
- return `${JOINED_PREFIX}${table}.${ref.field}`;
1179
- }
1180
- /**
1181
- * Translates an AtscriptQueryNode to a MongoDB $match for use after $group (HAVING).
1182
- * After $group, aggregate fields are top-level and dimension fields are under _id.
1183
- */
1184
- function queryNodeToHaving(node, columns) {
1185
- const colMap = new Map(columns.map((c) => [c.viewColumn, c]));
1186
- const resolveHavingField = (ref) => {
1187
- if (!ref.type) {
1188
- const col = colMap.get(ref.field);
1189
- if (col?.aggFn) return ref.field;
1190
- if (col) return `_id.${ref.field}`;
1191
- }
1192
- return ref.field;
1193
- };
1194
- return queryNodeToHavingInner(node, resolveHavingField);
1195
- }
1196
- function queryNodeToHavingInner(node, resolveField) {
1197
- if ("$and" in node) return { $and: node.$and.map((n) => queryNodeToHavingInner(n, resolveField)) };
1198
- if ("$or" in node) return { $or: node.$or.map((n) => queryNodeToHavingInner(n, resolveField)) };
1199
- if ("$not" in node) return { $not: queryNodeToHavingInner(node.$not, resolveField) };
1200
- const comp = node;
1201
- const fieldPath = resolveField(comp.left);
1202
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1203
- const rightPath = resolveField(comp.right);
1204
- return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1205
- }
1206
- if (comp.op === "$eq") return { [fieldPath]: comp.right };
1207
- if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1208
- return { [fieldPath]: { [comp.op]: comp.right } };
1209
- }
1210
1257
  async function dropTableImpl(host) {
1211
1258
  host._log("drop", host._table.tableName);
1212
1259
  await host.collection.drop();
@@ -1249,65 +1296,75 @@ async function renameTableImpl(host, oldName) {
1249
1296
  async function syncColumnsImpl(host, diff) {
1250
1297
  const renamed = [];
1251
1298
  const added = [];
1252
- const update = {};
1253
- if (diff.renamed.length > 0) {
1254
- const renameSpec = {};
1255
- for (const r of diff.renamed) {
1256
- if (pathCrossesArray(host, r.field.path)) {
1257
- host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1258
- continue;
1259
- }
1260
- renameSpec[r.oldName] = r.field.physicalName;
1261
- renamed.push(r.field.physicalName);
1262
- }
1263
- if (Object.keys(renameSpec).length > 0) update.$rename = renameSpec;
1264
- }
1265
- if (diff.added.length > 0) {
1266
- const setSpec = {};
1267
- for (const field of diff.added) {
1268
- const defaultVal = resolveSyncDefault(field);
1269
- if (defaultVal !== void 0) setSpec[arraySafePath(host, field.physicalName, field.path)] = defaultVal;
1270
- added.push(field.physicalName);
1299
+ const renameSpec = {};
1300
+ for (const r of diff.renamed) {
1301
+ if (pathCrossesArray(host, r.field.path)) {
1302
+ host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1303
+ continue;
1271
1304
  }
1272
- if (Object.keys(setSpec).length > 0) update.$set = setSpec;
1305
+ renameSpec[r.oldName] = r.field.physicalName;
1306
+ renamed.push(r.field.physicalName);
1307
+ }
1308
+ if (renamed.length > 0) await host.collection.updateMany({}, { $rename: renameSpec }, host._getSessionOpts());
1309
+ for (const field of diff.added) {
1310
+ const value = resolveSyncDefault(field);
1311
+ if (value !== void 0) await defaultBackfill(host, field.path, value);
1312
+ added.push(field.physicalName);
1273
1313
  }
1274
- if (Object.keys(update).length > 0) await host.collection.updateMany({}, update, host._getSessionOpts());
1275
1314
  return {
1276
1315
  added,
1277
1316
  renamed
1278
1317
  };
1279
1318
  }
1319
+ /**
1320
+ * Backfills an added field's `@db.default` literal where the field is
1321
+ * missing — a stored value is never overwritten. Optional fields without a
1322
+ * default get no backfill (absent = "optional" in Mongo). A path crossing an
1323
+ * array narrows the innermost `$[]` to an `arrayFilters` element missing the
1324
+ * sub-path; outer arrays keep `$[]`, so empty arrays are a no-op (a bare
1325
+ * dotted path into an empty array is rejected with code 28).
1326
+ */
1327
+ async function defaultBackfill(host, logicalPath, value) {
1328
+ const segments = positionalSegments(host, logicalPath);
1329
+ const inner = segments.lastIndexOf("$[]");
1330
+ const opts = host._getSessionOpts();
1331
+ if (inner === -1) {
1332
+ const path = segments.join(".");
1333
+ await host.collection.updateMany({ [path]: { $exists: false } }, { $set: { [path]: value } }, opts);
1334
+ return;
1335
+ }
1336
+ segments[inner] = "$[backfill]";
1337
+ const rest = segments.slice(inner + 1).join(".");
1338
+ await host.collection.updateMany({}, { $set: { [segments.join(".")]: value } }, {
1339
+ ...opts,
1340
+ arrayFilters: [{ [`backfill.${rest}`]: { $exists: false } }]
1341
+ });
1342
+ }
1280
1343
  async function dropColumnsImpl(host, columns) {
1281
1344
  if (columns.length === 0) return;
1282
1345
  const dropped = new Set(columns);
1283
1346
  const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
1284
1347
  const unsetSpec = {};
1285
- for (const col of minimal) unsetSpec[arraySafePath(host, col, col)] = "";
1348
+ for (const col of minimal) unsetSpec[positionalSegments(host, col, col).join(".")] = "";
1286
1349
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1287
1350
  }
1288
1351
  /**
1289
- * Rewrites a dotted physical path to use Mongo's all-positional $[] operator
1290
- * at every segment that's typed as an array in the table's flatMap. Returns
1291
- * the input unchanged when no segment crosses an array boundary.
1292
- *
1293
- * `logicalPath` drives the array-boundary walk (flatMap is keyed by logical
1294
- * path); the leaf of `physicalPath` is preserved so any `@db.column` rename
1295
- * on the leaf still applies.
1352
+ * The segments of a field's physical document path with `$[]` after every
1353
+ * segment whose logical prefix is an array in the flatMap — Mongo's
1354
+ * all-positional operator walks every element. `physicalPath` defaults to
1355
+ * the logical path's document path (same segment count).
1296
1356
  */
1297
- function arraySafePath(host, physicalPath, logicalPath) {
1298
- const logicalSegments = logicalPath.split(".");
1299
- if (logicalSegments.length < 2) return physicalPath;
1300
- const physicalSegments = physicalPath.split(".");
1301
- const physicalLeaf = physicalSegments[physicalSegments.length - 1];
1357
+ function positionalSegments(host, logicalPath, physicalPath = host._table.getMetadata().documentPath(logicalPath)) {
1358
+ const logical = logicalPath.split(".");
1359
+ const physical = physicalPath.split(".");
1302
1360
  const out = [];
1303
1361
  let prefix = "";
1304
- for (let i = 0; i < logicalSegments.length; i++) {
1305
- const isLeaf = i === logicalSegments.length - 1;
1306
- out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1307
- prefix = joinPath(prefix, logicalSegments[i]);
1308
- if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1362
+ for (let i = 0; i < logical.length; i++) {
1363
+ out.push(physical[i]);
1364
+ prefix = joinPath(prefix, logical[i]);
1365
+ if (i < logical.length - 1 && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1309
1366
  }
1310
- return out.join(".");
1367
+ return out;
1311
1368
  }
1312
1369
  /** Returns true if any non-leaf segment of the path is typed as an array. */
1313
1370
  function pathCrossesArray(host, logicalPath) {
@@ -1766,6 +1823,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1766
1823
  calendarBucketUnits() {
1767
1824
  return ALL_BUCKET_UNITS;
1768
1825
  }
1826
+ /** Every aggregate function, `countDistinct` included. */
1827
+ aggregateFns() {
1828
+ return ALL_AGGREGATE_FNS;
1829
+ }
1830
+ /**
1831
+ * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
1832
+ * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
1833
+ */
1834
+ viewRenderRevision() {
1835
+ return "2";
1836
+ }
1769
1837
  getValidatorPlugins() {
1770
1838
  return [validateMongoIdPlugin];
1771
1839
  }
@@ -1874,11 +1942,6 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1874
1942
  this._addMongoIndexField("unique", "__pk", field);
1875
1943
  this._pendingUniqueFields.push(field);
1876
1944
  }
1877
- if (metadata.has("db.default.increment")) {
1878
- const physicalName = metadata.get("db.column") ?? field;
1879
- const startValue = metadata.get("db.default.increment");
1880
- this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1881
- }
1882
1945
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1883
1946
  indexName: index.indexName,
1884
1947
  field,
@@ -1954,9 +2017,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1954
2017
  path: value
1955
2018
  });
1956
2019
  }
1957
- for (const fd of this._table.fieldDescriptors) if (fd.collate && fd.collate !== "binary") {
1958
- if (!this._collateFields) this._collateFields = /* @__PURE__ */ new Map();
1959
- this._collateFields.set(fd.physicalName, fd.collate);
2020
+ for (const fd of this._table.fieldDescriptors) {
2021
+ if (fd.collate && fd.collate !== "binary") {
2022
+ this._collateFields ??= /* @__PURE__ */ new Map();
2023
+ this._collateFields.set(fd.path, fd.collate);
2024
+ this._collateFields.set(fd.physicalName, fd.collate);
2025
+ }
2026
+ const def = fd.defaultValue;
2027
+ if (def?.kind === "fn" && def.fn === "increment") this._incrementFields.set(fd.physicalName, def.start);
1960
2028
  }
1961
2029
  }
1962
2030
  /** Returns MongoDB-specific search index map (internal). */