@atscript/db-mongo 0.1.135 → 0.1.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agg.cjs CHANGED
@@ -1,27 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_filter = require("./mongo-filter-Bf5rWV4P.cjs");
2
+ const require_mongo_accumulator = require("./mongo-accumulator-C8J36t07.cjs");
3
3
  let _atscript_db_agg = require("@atscript/db/agg");
4
4
  let _uniqu_core = require("@uniqu/core");
5
5
  //#region src/agg.ts
6
- /** `$group` accumulator per value aggregate (`count` is built below). */
7
- const ACCUMULATORS = {
8
- sum: "$sum",
9
- avg: "$avg",
10
- min: "$min",
11
- max: "$max"
12
- };
13
- /**
14
- * Maps an AggregateExpr to a MongoDB $group accumulator expression.
15
- */
6
+ /** Maps an AggregateExpr to its MongoDB `$group` accumulator (see `buildAccumulator`). */
16
7
  function toAccumulator(expr) {
17
- (0, _atscript_db_agg.assertAggregateFn)(expr.$fn);
18
- if (expr.$fn !== "count") return { [ACCUMULATORS[expr.$fn]]: `$${expr.$field}` };
19
- if (expr.$field === "*") return { $sum: 1 };
20
- return { $sum: { $cond: [
21
- { $ne: [`$${expr.$field}`, null] },
22
- 1,
23
- 0
24
- ] } };
8
+ return require_mongo_accumulator.buildAccumulator(expr.$fn, expr.$field === "*" ? "*" : `$${expr.$field}`);
25
9
  }
26
10
  const DAY_MS = 864e5;
27
11
  const ISO_DATE = "%Y-%m-%d";
@@ -120,13 +104,13 @@ function bucketExpression(b) {
120
104
  function buildPrefix(query, searchStage) {
121
105
  const controls = query.controls || {};
122
106
  const groupBy = controls.$groupBy ?? [];
123
- const pipeline = searchStage ? [searchStage, { $match: require_mongo_filter.buildMongoFilter(query.filter) }] : [{ $match: require_mongo_filter.buildMongoFilter(query.filter) }];
107
+ const pipeline = searchStage ? [searchStage, { $match: require_mongo_accumulator.buildMongoFilter(query.filter) }] : [{ $match: require_mongo_accumulator.buildMongoFilter(query.filter) }];
124
108
  const groupId = {};
125
109
  const groupKeys = [];
126
110
  for (const [index, key] of groupBy.entries()) {
127
111
  const idKey = `k${index}`;
128
112
  const bucket = controls.$select?.bucketByAlias(key);
129
- groupId[idKey] = bucket ? bucketExpression(bucket) : { $ifNull: [`$${key}`, null] };
113
+ groupId[idKey] = bucket ? bucketExpression(bucket) : require_mongo_accumulator.orNull(`$${key}`);
130
114
  groupKeys.push([key, idKey]);
131
115
  }
132
116
  return {
@@ -163,11 +147,11 @@ function buildGroupedStages(query, { accumulators, searchStage }) {
163
147
  for (const expr of controls.$select?.aggregates ?? []) {
164
148
  const alias = (0, _atscript_db_agg.resolveAlias)(expr);
165
149
  groupStage[alias] = toAccumulator(expr);
166
- project[alias] = 1;
150
+ project[alias] = expr.$fn === "countDistinct" ? require_mongo_accumulator.distinctCountExpr(`$${alias}`) : 1;
167
151
  }
168
152
  pipeline.push({ $group: groupStage });
169
153
  pipeline.push({ $project: project });
170
- if (controls.$having) pipeline.push({ $match: require_mongo_filter.buildMongoFilter(controls.$having) });
154
+ if (controls.$having) pipeline.push({ $match: require_mongo_accumulator.buildMongoFilter(controls.$having) });
171
155
  return {
172
156
  pipeline,
173
157
  controls
package/dist/agg.mjs CHANGED
@@ -1,26 +1,10 @@
1
- import { t as buildMongoFilter } from "./mongo-filter-CtyITmZM.mjs";
2
- import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
1
+ import { a as buildMongoFilter, n as distinctCountExpr, r as orNull, t as buildAccumulator } from "./mongo-accumulator-Dh6eEkyR.mjs";
2
+ import { resolveAlias } from "@atscript/db/agg";
3
3
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
4
4
  //#region src/agg.ts
5
- /** `$group` accumulator per value aggregate (`count` is built below). */
6
- const ACCUMULATORS = {
7
- sum: "$sum",
8
- avg: "$avg",
9
- min: "$min",
10
- max: "$max"
11
- };
12
- /**
13
- * Maps an AggregateExpr to a MongoDB $group accumulator expression.
14
- */
5
+ /** Maps an AggregateExpr to its MongoDB `$group` accumulator (see `buildAccumulator`). */
15
6
  function toAccumulator(expr) {
16
- assertAggregateFn(expr.$fn);
17
- if (expr.$fn !== "count") return { [ACCUMULATORS[expr.$fn]]: `$${expr.$field}` };
18
- if (expr.$field === "*") return { $sum: 1 };
19
- return { $sum: { $cond: [
20
- { $ne: [`$${expr.$field}`, null] },
21
- 1,
22
- 0
23
- ] } };
7
+ return buildAccumulator(expr.$fn, expr.$field === "*" ? "*" : `$${expr.$field}`);
24
8
  }
25
9
  const DAY_MS = 864e5;
26
10
  const ISO_DATE = "%Y-%m-%d";
@@ -125,7 +109,7 @@ function buildPrefix(query, searchStage) {
125
109
  for (const [index, key] of groupBy.entries()) {
126
110
  const idKey = `k${index}`;
127
111
  const bucket = controls.$select?.bucketByAlias(key);
128
- groupId[idKey] = bucket ? bucketExpression(bucket) : { $ifNull: [`$${key}`, null] };
112
+ groupId[idKey] = bucket ? bucketExpression(bucket) : orNull(`$${key}`);
129
113
  groupKeys.push([key, idKey]);
130
114
  }
131
115
  return {
@@ -162,7 +146,7 @@ function buildGroupedStages(query, { accumulators, searchStage }) {
162
146
  for (const expr of controls.$select?.aggregates ?? []) {
163
147
  const alias = resolveAlias(expr);
164
148
  groupStage[alias] = toAccumulator(expr);
165
- project[alias] = 1;
149
+ project[alias] = expr.$fn === "countDistinct" ? distinctCountExpr(`$${alias}`) : 1;
166
150
  }
167
151
  pipeline.push({ $group: groupStage });
168
152
  pipeline.push({ $project: project });
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_filter = require("./mongo-filter-Bf5rWV4P.cjs");
2
+ const require_mongo_accumulator = require("./mongo-accumulator-C8J36t07.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");
@@ -498,7 +498,7 @@ function buildFromLookup(host, withRel, relation, tableResolver) {
498
498
  if (!targetMeta) return;
499
499
  const remoteFK = findRemoteFK(targetMeta, host._table.tableName, relation.alias);
500
500
  if (!remoteFK) return;
501
- const targetTableName = resolveRelTargetTableName(relation);
501
+ const targetTableName = (0, _atscript_db.tableNameOf)(relation.targetType());
502
502
  const innerPipeline = buildLookupInnerPipeline(withRel, remoteFK.fields);
503
503
  const { letVars, exprMatch } = buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
504
504
  const stages = [{ $lookup: {
@@ -523,8 +523,8 @@ function buildViaLookup(host, withRel, relation, tableResolver) {
523
523
  if (!junctionType) return;
524
524
  const junctionMeta = tableResolver(junctionType);
525
525
  if (!junctionMeta) return;
526
- const junctionTableName = junctionType.metadata?.get("db.table") || junctionType.id || "";
527
- const targetTableName = resolveRelTargetTableName(relation);
526
+ const junctionTableName = (0, _atscript_db.tableNameOf)(junctionType);
527
+ const targetTableName = (0, _atscript_db.tableNameOf)(relation.targetType());
528
528
  const fkToThis = findRemoteFK(junctionMeta, host._table.tableName);
529
529
  if (!fkToThis) return;
530
530
  const fkToTarget = findRemoteFK(junctionMeta, targetTableName);
@@ -565,7 +565,7 @@ function buildLookupInnerPipeline(withRel, requiredFields) {
565
565
  const limit = nested.$limit ?? flatRel.$limit;
566
566
  const skip = nested.$skip ?? flatRel.$skip;
567
567
  const select = nested.$select || flatRel.$select;
568
- if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: require_mongo_filter.buildMongoFilter(filter) });
568
+ if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: require_mongo_accumulator.buildMongoFilter(filter) });
569
569
  if (sort) pipeline.push({ $sort: sort });
570
570
  if (skip) pipeline.push({ $skip: skip });
571
571
  if (limit !== null && limit !== void 0) pipeline.push({ $limit: limit });
@@ -620,7 +620,7 @@ function mergeRelationResults(rows, results, primaryKeys, relMeta) {
620
620
  }
621
621
  /** Finds FK entry for a TO relation from this table's foreignKeys map. */
622
622
  function findFKForRelation(relation, foreignKeys) {
623
- const targetTableName = resolveRelTargetTableName(relation);
623
+ const targetTableName = (0, _atscript_db.tableNameOf)(relation.targetType());
624
624
  for (const fk of foreignKeys.values()) if (relation.alias) {
625
625
  if (fk.alias === relation.alias) return {
626
626
  localFields: fk.fields,
@@ -640,11 +640,6 @@ function findRemoteFK(target, thisTableName, alias) {
640
640
  if (!alias && fk.targetTable === thisTableName) return fk;
641
641
  }
642
642
  }
643
- /** Resolves the target table/collection name from a relation's target type. */
644
- function resolveRelTargetTableName(relation) {
645
- const targetType = relation.targetType();
646
- return targetType?.metadata?.get("db.table") || targetType?.id || "";
647
- }
648
643
  //#endregion
649
644
  //#region src/lib/mongo-search.ts
650
645
  /** Returns available search indexes as generic metadata for UI. */
@@ -752,7 +747,7 @@ function buildGeoNearStage(host, point, query, indexName) {
752
747
  distanceField: DISTANCE_FIELD,
753
748
  spherical: true,
754
749
  key: resolveGeoKeyPath(host, indexName),
755
- query: require_mongo_filter.buildMongoFilter(query.filter)
750
+ query: require_mongo_accumulator.buildMongoFilter(query.filter)
756
751
  };
757
752
  if (typeof controls.$maxDistance === "number") geoNear.maxDistance = controls.$maxDistance;
758
753
  if (typeof controls.$minDistance === "number") geoNear.minDistance = controls.$minDistance;
@@ -965,7 +960,7 @@ function buildVectorSearchStage(host, vector, indexName, limit) {
965
960
  }
966
961
  /** Runs a search/vector pipeline and returns results. Shared by search + vectorSearch. */
967
962
  async function runSearchPipeline(host, stage, query, label, threshold, classicText = false) {
968
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
963
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
969
964
  const controls = query.controls || {};
970
965
  const pipeline = [stage];
971
966
  if (threshold !== void 0) {
@@ -987,7 +982,7 @@ async function runSearchPipeline(host, stage, query, label, threshold, classicTe
987
982
  }
988
983
  /** Runs a search/vector pipeline with $facet for count. Shared by searchWithCount + vectorSearchWithCount. */
989
984
  async function runSearchWithCountPipeline(host, stage, query, label, threshold, classicText = false) {
990
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
985
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
991
986
  const controls = query.controls || {};
992
987
  const preStages = [];
993
988
  if (threshold !== void 0) {
@@ -1020,6 +1015,175 @@ async function runSearchWithCountPipeline(host, stage, query, label, threshold,
1020
1015
  };
1021
1016
  }
1022
1017
  //#endregion
1018
+ //#region src/lib/mongo-view-pipeline.ts
1019
+ /** Where a joined table's document lands (`$lookup.as`, then unwound). */
1020
+ function joinedAs(table) {
1021
+ return `${JOINED_PREFIX}${table}`;
1022
+ }
1023
+ /**
1024
+ * Builds the aggregation pipeline of a managed MongoDB view (created with
1025
+ * `viewOn: <entry collection>`):
1026
+ *
1027
+ * 1. per join, in declaration order: `$lookup` + `$unwind` (inner joins drop
1028
+ * unmatched documents, left joins keep them — `preserveNullAndEmptyArrays`);
1029
+ * 2. `@db.view.filter` → `$match`;
1030
+ * 3. aggregates → `$group` (+ `$addFields` turning `countDistinct` sets into
1031
+ * their sizes, + `@db.view.having` → `$match`) + `$project`, otherwise a
1032
+ * flat `$project`. A conditional aggregate's predicate reads the same
1033
+ * pipeline paths (SQL null semantics — `queryNodeToExpr`).
1034
+ *
1035
+ * All field paths are PHYSICAL document paths (`@db.column` renames), joined
1036
+ * tables live under `__joined_<table>`. A column whose source may be missing
1037
+ * (`nullable` mapping) reads as null; every other column is a plain path, so
1038
+ * a `$match` / `$sort` on the view can still push down to an index.
1039
+ * @since 0.1.136
1040
+ */
1041
+ function buildViewPipeline(view) {
1042
+ const plan = view.viewPlan;
1043
+ const columns = view.getViewColumnMappings();
1044
+ const pipeline = [];
1045
+ const prefixes = new Map([[plan.entryTable, ""]]);
1046
+ const pathOf = (ref) => {
1047
+ const { table, source } = view.resolveRefSource(ref);
1048
+ const prefix = prefixes.get(table);
1049
+ if (prefix === void 0) throw new Error(`View "${view.tableName}": "${table}" is not joined before it is referenced`);
1050
+ return prefix + source.column;
1051
+ };
1052
+ for (const join of plan.joins) {
1053
+ pipeline.push(buildLookup(view, join, prefixes));
1054
+ pipeline.push({ $unwind: {
1055
+ path: `$${joinedAs(join.targetTable)}`,
1056
+ preserveNullAndEmptyArrays: join.kind === "left"
1057
+ } });
1058
+ prefixes.set(join.targetTable, `${joinedAs(join.targetTable)}.`);
1059
+ }
1060
+ if (plan.filter) pipeline.push({ $match: viewMatch(plan.filter, pathOf) });
1061
+ /** A column's source field as an aggregation operand. */
1062
+ const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
1063
+ const colValue = (col) => col.nullable ? require_mongo_accumulator.orNull(colSourceField(col)) : colSourceField(col);
1064
+ if (columns.some((c) => c.aggFn)) {
1065
+ const group = { _id: {} };
1066
+ const project = { _id: 0 };
1067
+ const distinctSizes = {};
1068
+ for (const col of columns) if (col.aggFn) {
1069
+ const predicate = col.aggFilter ? require_mongo_accumulator.queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
1070
+ group[col.viewColumn] = require_mongo_accumulator.buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
1071
+ project[col.viewColumn] = `$${col.viewColumn}`;
1072
+ if (col.aggFn === "countDistinct") distinctSizes[col.viewColumn] = require_mongo_accumulator.distinctCountExpr(`$${col.viewColumn}`);
1073
+ } else {
1074
+ group._id[col.viewColumn] = colValue(col);
1075
+ project[col.viewColumn] = `$_id.${col.viewColumn}`;
1076
+ }
1077
+ pipeline.push({ $group: group });
1078
+ if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
1079
+ if (plan.having) pipeline.push({ $match: havingMatch(plan.having, columns) });
1080
+ pipeline.push({ $project: project });
1081
+ } else {
1082
+ const project = { _id: 0 };
1083
+ for (const col of columns) project[col.viewColumn] = colValue(col);
1084
+ pipeline.push({ $project: project });
1085
+ }
1086
+ return pipeline;
1087
+ }
1088
+ /**
1089
+ * One join's `$lookup`.
1090
+ *
1091
+ * The simple `localField` / `foreignField` form is used whenever it is exact:
1092
+ * a single `=` between a field of the joined table and a field of an outer
1093
+ * table (the entry or an earlier join), with the joined field REQUIRED — the
1094
+ * simple form matches a null / missing local value to null / missing foreign
1095
+ * values, which a required foreign field can never hold. It is kept (rather
1096
+ * than always using the pipeline form) because it can use an index on the
1097
+ * foreign field on every MongoDB version; the pipeline form's `$expr` match
1098
+ * can't before MongoDB 5.0. Everything else uses the pipeline form (`let` +
1099
+ * `$match: { $expr }`, SQL null semantics).
1100
+ */
1101
+ function buildLookup(view, join, outer) {
1102
+ const as = joinedAs(join.targetTable);
1103
+ const simple = simpleJoinFields(view, join, outer);
1104
+ if (simple) return { $lookup: {
1105
+ from: join.targetTable,
1106
+ ...simple,
1107
+ as
1108
+ } };
1109
+ const letVars = {};
1110
+ const varByPath = /* @__PURE__ */ new Map();
1111
+ const pathOf = (ref) => {
1112
+ const { table, source } = view.resolveRefSource(ref);
1113
+ if (table === join.targetTable) return `$${source.column}`;
1114
+ const prefix = outer.get(table);
1115
+ if (prefix === void 0) throw new Error(`View "${view.tableName}": the join on "${join.targetTable}" references "${table}", which is not joined before it`);
1116
+ const outerPath = prefix + source.column;
1117
+ let varName = varByPath.get(outerPath);
1118
+ if (!varName) {
1119
+ varName = `v${varByPath.size}`;
1120
+ varByPath.set(outerPath, varName);
1121
+ letVars[varName] = `$${outerPath}`;
1122
+ }
1123
+ return `$$${varName}`;
1124
+ };
1125
+ const expr = require_mongo_accumulator.queryNodeToExpr(join.condition, pathOf);
1126
+ return { $lookup: {
1127
+ from: join.targetTable,
1128
+ ...varByPath.size > 0 ? { let: letVars } : {},
1129
+ pipeline: [{ $match: { $expr: expr } }],
1130
+ as
1131
+ } };
1132
+ }
1133
+ /** `{ localField, foreignField }` when the simple `$lookup` form is exact, else `undefined`. */
1134
+ function simpleJoinFields(view, join, outer) {
1135
+ let node = join.condition;
1136
+ if ("$and" in node) {
1137
+ const children = node.$and;
1138
+ if (children.length !== 1) return void 0;
1139
+ node = children[0];
1140
+ }
1141
+ if ("$or" in node || "$not" in node || "$and" in node) return void 0;
1142
+ const comp = node;
1143
+ if (comp.op !== "$eq" || !(0, _atscript_db.isFieldRef)(comp.right)) return void 0;
1144
+ const left = view.resolveRefSource(comp.left);
1145
+ const right = view.resolveRefSource(comp.right);
1146
+ let target;
1147
+ let local;
1148
+ if (left.table === join.targetTable && right.table !== join.targetTable) {
1149
+ target = left;
1150
+ local = right;
1151
+ } else if (right.table === join.targetTable && left.table !== join.targetTable) {
1152
+ target = right;
1153
+ local = left;
1154
+ } else return;
1155
+ const localPrefix = outer.get(local.table);
1156
+ if (localPrefix === void 0) return void 0;
1157
+ const foreign = target.source;
1158
+ if (!(!foreign.optional && foreign.designType !== "union" && foreign.designType !== "unknown")) return void 0;
1159
+ return {
1160
+ localField: localPrefix + local.source.column,
1161
+ foreignField: foreign.column
1162
+ };
1163
+ }
1164
+ /**
1165
+ * A view predicate (`@db.view.filter`, `@db.view.having`) as a `$match`
1166
+ * over pipeline paths: the shared query translation (`translateQueryTree` →
1167
+ * `buildMongoFilter`), so operators mean what they mean in queries (`exists`
1168
+ * = holds a value, `matches` takes `/re/flags`); a field-to-field comparison
1169
+ * becomes `$expr`.
1170
+ */
1171
+ function viewMatch(node, pathOf) {
1172
+ return require_mongo_accumulator.buildMongoFilter((0, _atscript_db.translateQueryTree)(node, pathOf), { fieldOperands: true });
1173
+ }
1174
+ /**
1175
+ * `@db.view.having` as a `$match` after `$group`: refs name view fields —
1176
+ * aggregates are top-level, dimensions under `_id`.
1177
+ */
1178
+ function havingMatch(node, columns) {
1179
+ const colMap = new Map(columns.map((c) => [c.viewPath, c]));
1180
+ return viewMatch(node, (ref) => {
1181
+ const col = ref.type ? void 0 : colMap.get(ref.field);
1182
+ if (!col) return ref.field;
1183
+ return col.aggFn ? col.viewColumn : `_id.${col.viewColumn}`;
1184
+ });
1185
+ }
1186
+ //#endregion
1023
1187
  //#region src/lib/mongo-schema-sync.ts
1024
1188
  const DESTRUCTIVE_OPTION_KEYS = new Set([
1025
1189
  "capped",
@@ -1080,134 +1244,17 @@ async function getObjectKindImpl(host, name) {
1080
1244
  if (type === void 0) return;
1081
1245
  return type === "view" ? "view" : "table";
1082
1246
  }
1083
- /** Creates a MongoDB view from the AtscriptDbView's view plan. */
1247
+ /** Creates a MongoDB view from the AtscriptDbView's view plan (pipeline: {@link buildViewPipeline}). */
1084
1248
  async function ensureView(host, view) {
1085
1249
  if (await host.collectionExists()) return;
1086
1250
  const plan = view.viewPlan;
1087
- const columns = view.getViewColumnMappings();
1088
- const pipeline = [];
1089
- for (const join of plan.joins) {
1090
- const { localField, foreignField } = resolveJoinFields(join.condition, plan.entryTable, join.targetTable);
1091
- pipeline.push({ $lookup: {
1092
- from: join.targetTable,
1093
- localField,
1094
- foreignField,
1095
- as: `${JOINED_PREFIX}${join.targetTable}`
1096
- } });
1097
- pipeline.push({ $unwind: {
1098
- path: `$__joined_${join.targetTable}`,
1099
- preserveNullAndEmptyArrays: true
1100
- } });
1101
- }
1102
- if (plan.filter) {
1103
- const matchExpr = queryNodeToMatch(plan.filter, plan.entryTable);
1104
- pipeline.push({ $match: matchExpr });
1105
- }
1106
- const hasAggregates = columns.some((c) => c.aggFn);
1107
- /** Resolves a column to its MongoDB source field path. */
1108
- const colSourceField = (col) => col.sourceTable === plan.entryTable ? `$${col.sourceColumn}` : `$${JOINED_PREFIX}${col.sourceTable}.${col.sourceColumn}`;
1109
- if (hasAggregates) {
1110
- const group = { _id: {} };
1111
- const project = { _id: 0 };
1112
- for (const col of columns) if (col.aggFn) {
1113
- if (col.aggFn === "count" && col.aggField === "*") group[col.viewColumn] = { $sum: 1 };
1114
- else group[col.viewColumn] = { [`$${col.aggFn}`]: colSourceField(col) };
1115
- project[col.viewColumn] = `$${col.viewColumn}`;
1116
- } else {
1117
- group._id[col.viewColumn] = colSourceField(col);
1118
- project[col.viewColumn] = `$_id.${col.viewColumn}`;
1119
- }
1120
- pipeline.push({ $group: group });
1121
- if (plan.having) {
1122
- const havingMatch = queryNodeToHaving(plan.having, columns);
1123
- pipeline.push({ $match: havingMatch });
1124
- }
1125
- pipeline.push({ $project: project });
1126
- } else {
1127
- const project = { _id: 0 };
1128
- for (const col of columns) project[col.viewColumn] = colSourceField(col);
1129
- pipeline.push({ $project: project });
1130
- }
1251
+ const pipeline = buildViewPipeline(view);
1131
1252
  host._log("createView", host._table.tableName, plan.entryTable, pipeline);
1132
1253
  await host.db.createCollection(host._table.tableName, {
1133
1254
  viewOn: plan.entryTable,
1134
1255
  pipeline
1135
1256
  });
1136
1257
  }
1137
- /** Extracts localField/foreignField from a join condition. */
1138
- function resolveJoinFields(condition, entryTable, joinTable) {
1139
- const c = "$and" in condition ? condition.$and[0] : condition;
1140
- if ((c.left.type ? c.left.type()?.metadata?.get("db.table") || "" : entryTable) === joinTable) return {
1141
- localField: c.right.field,
1142
- foreignField: c.left.field
1143
- };
1144
- return {
1145
- localField: c.left.field,
1146
- foreignField: c.right.field
1147
- };
1148
- }
1149
- /** Translates an AtscriptQueryNode to a MongoDB $match expression. */
1150
- function queryNodeToMatch(node, entryTable) {
1151
- if ("$and" in node) {
1152
- const items = node.$and;
1153
- const result = [];
1154
- for (const item of items) result.push(queryNodeToMatch(item, entryTable));
1155
- return { $and: result };
1156
- }
1157
- if ("$or" in node) {
1158
- const items = node.$or;
1159
- const result = [];
1160
- for (const item of items) result.push(queryNodeToMatch(item, entryTable));
1161
- return { $or: result };
1162
- }
1163
- if ("$not" in node) return { $not: queryNodeToMatch(node.$not, entryTable) };
1164
- const comp = node;
1165
- const fieldPath = resolveViewFieldPath(comp.left, entryTable);
1166
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1167
- const rightPath = resolveViewFieldPath(comp.right, entryTable);
1168
- return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1169
- }
1170
- if (comp.op === "$eq") return { [fieldPath]: comp.right };
1171
- if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1172
- return { [fieldPath]: { [comp.op]: comp.right } };
1173
- }
1174
- /** Resolves a field ref to a MongoDB dot path for view pipeline expressions. */
1175
- function resolveViewFieldPath(ref, entryTable) {
1176
- if (!ref.type) return ref.field;
1177
- const table = ref.type()?.metadata?.get("db.table") || "";
1178
- if (table === entryTable) return ref.field;
1179
- return `${JOINED_PREFIX}${table}.${ref.field}`;
1180
- }
1181
- /**
1182
- * Translates an AtscriptQueryNode to a MongoDB $match for use after $group (HAVING).
1183
- * After $group, aggregate fields are top-level and dimension fields are under _id.
1184
- */
1185
- function queryNodeToHaving(node, columns) {
1186
- const colMap = new Map(columns.map((c) => [c.viewColumn, c]));
1187
- const resolveHavingField = (ref) => {
1188
- if (!ref.type) {
1189
- const col = colMap.get(ref.field);
1190
- if (col?.aggFn) return ref.field;
1191
- if (col) return `_id.${ref.field}`;
1192
- }
1193
- return ref.field;
1194
- };
1195
- return queryNodeToHavingInner(node, resolveHavingField);
1196
- }
1197
- function queryNodeToHavingInner(node, resolveField) {
1198
- if ("$and" in node) return { $and: node.$and.map((n) => queryNodeToHavingInner(n, resolveField)) };
1199
- if ("$or" in node) return { $or: node.$or.map((n) => queryNodeToHavingInner(n, resolveField)) };
1200
- if ("$not" in node) return { $not: queryNodeToHavingInner(node.$not, resolveField) };
1201
- const comp = node;
1202
- const fieldPath = resolveField(comp.left);
1203
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1204
- const rightPath = resolveField(comp.right);
1205
- return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1206
- }
1207
- if (comp.op === "$eq") return { [fieldPath]: comp.right };
1208
- if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1209
- return { [fieldPath]: { [comp.op]: comp.right } };
1210
- }
1211
1258
  async function dropTableImpl(host) {
1212
1259
  host._log("drop", host._table.tableName);
1213
1260
  await host.collection.drop();
@@ -1767,6 +1814,10 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1767
1814
  calendarBucketUnits() {
1768
1815
  return _atscript_db.ALL_BUCKET_UNITS;
1769
1816
  }
1817
+ /** Every aggregate function, `countDistinct` included. */
1818
+ aggregateFns() {
1819
+ return _atscript_db.ALL_AGGREGATE_FNS;
1820
+ }
1770
1821
  getValidatorPlugins() {
1771
1822
  return [validateMongoIdPlugin];
1772
1823
  }
@@ -2030,7 +2081,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2030
2081
  return geoSearchWithCountImpl(this, point, query, indexName);
2031
2082
  }
2032
2083
  async findManyWithCount(query) {
2033
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2084
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2034
2085
  const controls = query.controls || {};
2035
2086
  const dataStages = [];
2036
2087
  if (controls.$sort) dataStages.push({ $sort: controls.$sort });
@@ -2077,7 +2128,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2077
2128
  _buildCasFilter(filter, expectedVersion, op) {
2078
2129
  const versionColumn = this._table.versionColumn;
2079
2130
  if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error(`${op}: expectedVersion requires versionColumn`);
2080
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2131
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2081
2132
  if (expectedVersion !== void 0 && versionColumn !== void 0) mongoFilter[versionColumn] = expectedVersion;
2082
2133
  return mongoFilter;
2083
2134
  }
@@ -2128,7 +2179,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2128
2179
  };
2129
2180
  }
2130
2181
  async findOne(query) {
2131
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2182
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2132
2183
  const opts = this._buildFindOptions(query.controls);
2133
2184
  this._log("findOne", filter, opts);
2134
2185
  return wrapInvalidQuery(() => this.collection.findOne(filter, {
@@ -2138,7 +2189,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2138
2189
  }));
2139
2190
  }
2140
2191
  async findMany(query) {
2141
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2192
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2142
2193
  const opts = this._buildFindOptions(query.controls);
2143
2194
  this._log("findMany", filter, opts);
2144
2195
  return wrapInvalidQuery(() => this.collection.find(filter, {
@@ -2148,7 +2199,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2148
2199
  }).toArray());
2149
2200
  }
2150
2201
  async count(query) {
2151
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2202
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2152
2203
  this._log("countDocuments", filter);
2153
2204
  return this.collection.countDocuments(filter, {
2154
2205
  ...this._getCollationOpts(query),
@@ -2183,13 +2234,13 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2183
2234
  };
2184
2235
  }
2185
2236
  async deleteOne(filter) {
2186
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2237
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2187
2238
  this._log("deleteOne", mongoFilter);
2188
2239
  return { deletedCount: (await this.collection.deleteOne(mongoFilter, this._getSessionOpts())).deletedCount };
2189
2240
  }
2190
2241
  async updateMany(filter, data, ops) {
2191
2242
  const versionColumn = this._table.versionColumn;
2192
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2243
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2193
2244
  const updateDoc = buildMongoUpdateDoc(data, ops, versionColumn);
2194
2245
  this._log("updateMany", mongoFilter, updateDoc);
2195
2246
  const result = await this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts());
@@ -2199,7 +2250,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2199
2250
  };
2200
2251
  }
2201
2252
  async replaceMany(filter, data) {
2202
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2253
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2203
2254
  const updateDoc = buildMongoUpdateDoc(data, void 0, this._table.versionColumn);
2204
2255
  this._log("replaceMany", mongoFilter, updateDoc);
2205
2256
  const result = await this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts());
@@ -2209,7 +2260,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2209
2260
  };
2210
2261
  }
2211
2262
  async deleteMany(filter) {
2212
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2263
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2213
2264
  this._log("deleteMany", mongoFilter);
2214
2265
  return { deletedCount: (await this.collection.deleteMany(mongoFilter, this._getSessionOpts())).deletedCount };
2215
2266
  }
@@ -2519,7 +2570,7 @@ function createAdapter(connection, _options) {
2519
2570
  exports.CollectionPatcher = CollectionPatcher;
2520
2571
  exports.INDEX_PREFIX = INDEX_PREFIX;
2521
2572
  exports.MongoAdapter = MongoAdapter;
2522
- exports.buildMongoFilter = require_mongo_filter.buildMongoFilter;
2573
+ exports.buildMongoFilter = require_mongo_accumulator.buildMongoFilter;
2523
2574
  exports.createAdapter = createAdapter;
2524
2575
  exports.mongoIndexKey = mongoIndexKey;
2525
2576
  exports.validateMongoIdPlugin = validateMongoIdPlugin;
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,8 @@ 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>;
306
308
  getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
307
309
  /**
308
310
  * Mongo can filter on JSON-stored fields natively — arrays via implicit
@@ -471,8 +473,15 @@ declare class MongoAdapter extends BaseDbAdapter {
471
473
  *
472
474
  * MongoDB's query language is nearly identical to the `FilterExpr` structure,
473
475
  * so this is largely a structural pass-through via the `walkFilter` visitor.
476
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
477
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
478
+ * request filter never gets that reading.
474
479
  */
475
- declare function buildMongoFilter(filter: FilterExpr): Filter<any>;
480
+ declare function buildMongoFilter(filter: FilterExpr, {
481
+ fieldOperands
482
+ }?: {
483
+ fieldOperands?: boolean;
484
+ }): Filter<any>;
476
485
  //#endregion
477
486
  //#region src/lib/validate-plugins.d.ts
478
487
  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,8 @@ 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>;
306
308
  getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
307
309
  /**
308
310
  * Mongo can filter on JSON-stored fields natively — arrays via implicit
@@ -471,8 +473,15 @@ declare class MongoAdapter extends BaseDbAdapter {
471
473
  *
472
474
  * MongoDB's query language is nearly identical to the `FilterExpr` structure,
473
475
  * so this is largely a structural pass-through via the `walkFilter` visitor.
476
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
477
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
478
+ * request filter never gets that reading.
474
479
  */
475
- declare function buildMongoFilter(filter: FilterExpr): Filter<any>;
480
+ declare function buildMongoFilter(filter: FilterExpr, {
481
+ fieldOperands
482
+ }?: {
483
+ fieldOperands?: boolean;
484
+ }): Filter<any>;
476
485
  //#endregion
477
486
  //#region src/lib/validate-plugins.d.ts
478
487
  declare const validateMongoIdPlugin: TValidatorPlugin;