@atscript/db-mongo 0.1.134 → 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/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 buildMongoFilter, i as queryNodeToExpr, n as distinctCountExpr, r as orNull, t as buildAccumulator } from "./mongo-accumulator-Dh6eEkyR.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();
@@ -1766,6 +1813,10 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1766
1813
  calendarBucketUnits() {
1767
1814
  return ALL_BUCKET_UNITS;
1768
1815
  }
1816
+ /** Every aggregate function, `countDistinct` included. */
1817
+ aggregateFns() {
1818
+ return ALL_AGGREGATE_FNS;
1819
+ }
1769
1820
  getValidatorPlugins() {
1770
1821
  return [validateMongoIdPlugin];
1771
1822
  }
@@ -0,0 +1,256 @@
1
+ let _atscript_db = require("@atscript/db");
2
+ let _atscript_db_agg = require("@atscript/db/agg");
3
+ //#region src/lib/mongo-filter.ts
4
+ const EMPTY = {};
5
+ function parseRegexString(value) {
6
+ if (value instanceof RegExp) return {
7
+ pattern: value.source,
8
+ flags: value.flags
9
+ };
10
+ const str = String(value);
11
+ const match = str.match(/^\/(.+)\/([gimsuy]*)$/);
12
+ if (match) return {
13
+ pattern: match[1],
14
+ flags: match[2]
15
+ };
16
+ return {
17
+ pattern: str,
18
+ flags: ""
19
+ };
20
+ }
21
+ /**
22
+ * Earth radius in meters used by MongoDB's `$centerSphere` radians conversion
23
+ * (Mongo documents dividing by 6378.1 km).
24
+ */
25
+ const EARTH_RADIUS_M = 6378100;
26
+ const mongoVisitor = {
27
+ comparison(field, op, value) {
28
+ if (op === "$eq") return { [field]: value };
29
+ if (op === "$exists") return value ? { [field]: { $ne: null } } : { [field]: null };
30
+ if (op === "$regex") {
31
+ const { pattern, flags } = parseRegexString(value);
32
+ return flags ? { [field]: {
33
+ $regex: pattern,
34
+ $options: flags
35
+ } } : { [field]: { $regex: pattern } };
36
+ }
37
+ if (op === "$geoWithin") {
38
+ const { center, radius } = value;
39
+ return { [field]: { $geoWithin: { $centerSphere: [center, radius / EARTH_RADIUS_M] } } };
40
+ }
41
+ return { [field]: { [op]: value } };
42
+ },
43
+ and(children) {
44
+ if (children.length === 0) return EMPTY;
45
+ if (children.length === 1) return children[0];
46
+ return { $and: children };
47
+ },
48
+ or(children) {
49
+ if (children.length === 0) return { _impossible: true };
50
+ if (children.length === 1) return children[0];
51
+ return { $or: children };
52
+ },
53
+ not(child) {
54
+ return { $nor: [child] };
55
+ }
56
+ };
57
+ /** A `{ $field: path }` comparison operand (a field-to-field comparison). */
58
+ function isFieldOperand(value) {
59
+ return value !== null && typeof value === "object" && typeof value.$field === "string";
60
+ }
61
+ /** {@link mongoVisitor} plus field-to-field comparisons (`{ $field }` operands → `$expr`). */
62
+ const fieldOperandVisitor = {
63
+ ...mongoVisitor,
64
+ comparison(field, op, value) {
65
+ return isFieldOperand(value) ? { $expr: { [op]: [`$${field}`, `$${value.$field}`] } } : mongoVisitor.comparison(field, op, value);
66
+ }
67
+ };
68
+ /**
69
+ * Translates a generic {@link FilterExpr} into a MongoDB-compatible
70
+ * {@link Filter} document.
71
+ *
72
+ * MongoDB's query language is nearly identical to the `FilterExpr` structure,
73
+ * so this is largely a structural pass-through via the `walkFilter` visitor.
74
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
75
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
76
+ * request filter never gets that reading.
77
+ */
78
+ function buildMongoFilter(filter, { fieldOperands = false } = {}) {
79
+ if (!filter || Object.keys(filter).length === 0) return EMPTY;
80
+ return (0, _atscript_db.walkFilter)(filter, fieldOperands ? fieldOperandVisitor : mongoVisitor) ?? EMPTY;
81
+ }
82
+ //#endregion
83
+ //#region src/lib/mongo-view-expr.ts
84
+ /**
85
+ * `x IS NOT NULL` in SQL terms — true when `x` is neither null nor missing.
86
+ * Aggregation order puts a missing value below null and every other value
87
+ * above it, so one comparison covers both (`{ $ne: [x, null] }` alone would
88
+ * be true for a MISSING field).
89
+ * @since 0.1.136
90
+ */
91
+ function notNullExpr(x) {
92
+ return { $gt: [x, null] };
93
+ }
94
+ /** `x IS NULL` in SQL terms — true when `x` is null or missing (see {@link notNullExpr}). @since 0.1.136 */
95
+ function isNullExpr(x) {
96
+ return { $lte: [x, null] };
97
+ }
98
+ /**
99
+ * `x`, with a missing value read as null — for a projected column (a missing
100
+ * key would drop it from the row) and a `$group` key (missing and null would
101
+ * form two groups). Only where a value may be missing: the wrapper hides the
102
+ * path from `$match` / `$sort` pushdown.
103
+ * @since 0.1.136
104
+ */
105
+ function orNull(x) {
106
+ return { $ifNull: [x, null] };
107
+ }
108
+ /** A literal inside `$expr` — `$`-prefixed strings would otherwise read as field paths. */
109
+ function literal(value) {
110
+ return typeof value === "string" && value.startsWith("$") ? { $literal: value } : value;
111
+ }
112
+ const COMPARISONS = {
113
+ $eq: "$eq",
114
+ $ne: "$ne",
115
+ $gt: "$gt",
116
+ $gte: "$gte",
117
+ $lt: "$lt",
118
+ $lte: "$lte"
119
+ };
120
+ /** `cond` AND-guarded so that every field operand is non-null (SQL: NULL operand → not true). */
121
+ function guarded(operands, cond) {
122
+ return { $and: [...operands.map((o) => notNullExpr(o)), cond] };
123
+ }
124
+ /**
125
+ * Translates a view predicate (join condition, conditional-aggregate filter)
126
+ * to an aggregation expression for `$match: { $expr }` / `$cond`, matching
127
+ * SQL's three-valued logic wherever a NULL operand makes SQL's comparison
128
+ * UNKNOWN (treated as false):
129
+ *
130
+ * - `<`, `<=`, `>`, `>=`, `!= <literal>`, field `=` field, field `!=` field
131
+ * and `not in` are guarded with "every field operand is not null";
132
+ * - `= null` / `not exists` → null-or-missing; `!= null` / `exists` → neither;
133
+ * - `= <literal>` and `in (…)` compare directly (a null operand never equals
134
+ * a non-null literal); an empty `not in` is true;
135
+ * - `and` / `or` / `not` map directly — so `not (x > 1)` is TRUE for a null
136
+ * `x` here while SQL yields UNKNOWN (documented divergence);
137
+ * - `matches` is rejected (`$regexMatch` needs MongoDB 4.2).
138
+ * @since 0.1.136
139
+ */
140
+ function queryNodeToExpr(node, pathOf) {
141
+ if ("$and" in node) return { $and: node.$and.map((n) => queryNodeToExpr(n, pathOf)) };
142
+ if ("$or" in node) return { $or: node.$or.map((n) => queryNodeToExpr(n, pathOf)) };
143
+ if ("$not" in node) return { $not: [queryNodeToExpr(node.$not, pathOf)] };
144
+ const comp = node;
145
+ const x = pathOf(comp.left);
146
+ switch (comp.op) {
147
+ case "$exists": return comp.right === false ? isNullExpr(x) : notNullExpr(x);
148
+ case "$in": return { $in: [x, (Array.isArray(comp.right) ? comp.right : [comp.right]).map((v) => literal(v))] };
149
+ case "$nin": {
150
+ const values = Array.isArray(comp.right) ? comp.right : [comp.right];
151
+ if (values.length === 0) return { $literal: true };
152
+ return guarded([x], { $not: [{ $in: [x, values.map((v) => literal(v))] }] });
153
+ }
154
+ case "$regex": throw new Error("matches is not supported in view predicates");
155
+ default:
156
+ }
157
+ const op = COMPARISONS[comp.op];
158
+ if (!op) throw new Error(`Operator "${comp.op}" is not supported in view predicates`);
159
+ if ((0, _atscript_db.isFieldRef)(comp.right)) {
160
+ const y = pathOf(comp.right);
161
+ return guarded([x, y], { [op]: [x, y] });
162
+ }
163
+ if (comp.right === null || comp.right === void 0) {
164
+ if (op === "$eq") return isNullExpr(x);
165
+ if (op === "$ne") return notNullExpr(x);
166
+ return { $literal: false };
167
+ }
168
+ const value = literal(comp.right);
169
+ if (op === "$eq") return { $eq: [x, value] };
170
+ return guarded([x], { [op]: [x, value] });
171
+ }
172
+ //#endregion
173
+ //#region src/lib/mongo-accumulator.ts
174
+ /**
175
+ * The `$group` accumulator of one aggregate — shared by grouped queries
176
+ * (`agg.ts`) and managed views (`mongo-view-pipeline.ts`), so both count and
177
+ * sum alike.
178
+ *
179
+ * - `count(*)` → `{ $sum: 1 }`; `count(f)` counts values that are neither
180
+ * null nor missing (SQL `COUNT(f)`);
181
+ * - `sum` / `avg` / `min` / `max` → `$sum` / `$avg` / `$min` / `$max` (all
182
+ * skip null / missing values);
183
+ * - `countDistinct(f)` → `{ $addToSet: { $ifNull: [f, "$$REMOVE"] } }`, a
184
+ * SET of the non-null values (`$$REMOVE` adds nothing) the caller turns
185
+ * into its size with {@link distinctCountExpr}.
186
+ *
187
+ * `where` (a conditional aggregate's row predicate, an aggregation
188
+ * expression) swaps the source for `{ $cond: [where, src, null] }` — the
189
+ * rejected rows contribute a null, which every accumulator skips — and makes
190
+ * the counts `{ $sum: { $cond: [where (and not null), 1, 0] } }`.
191
+ *
192
+ * @param fn - The aggregate function (re-asserted: `INVALID_QUERY` when unknown).
193
+ * @param src - The source operand (`"$path"`), or `"*"` for `count(*)`.
194
+ * @param where - Row predicate of a conditional aggregate.
195
+ * @param path - Error path of the re-assertion.
196
+ */
197
+ function buildAccumulator(fn, src, where, path) {
198
+ (0, _atscript_db_agg.assertAggregateFn)(fn, path);
199
+ if (fn === "count") {
200
+ if (src === "*") return { $sum: where ? { $cond: [
201
+ where,
202
+ 1,
203
+ 0
204
+ ] } : 1 };
205
+ return { $sum: { $cond: [
206
+ where ? { $and: [where, notNullExpr(src)] } : notNullExpr(src),
207
+ 1,
208
+ 0
209
+ ] } };
210
+ }
211
+ const value = where ? { $cond: [
212
+ where,
213
+ src,
214
+ null
215
+ ] } : src;
216
+ return fn === "countDistinct" ? { $addToSet: { $ifNull: [value, "$$REMOVE"] } } : { [`$${fn}`]: value };
217
+ }
218
+ /**
219
+ * The size of a `countDistinct` set (`"$alias"`) — the value the accumulator
220
+ * stands for, projected right after `$group` so later stages (`$having`,
221
+ * `$sort`) see a number.
222
+ */
223
+ function distinctCountExpr(set) {
224
+ return { $size: set };
225
+ }
226
+ //#endregion
227
+ Object.defineProperty(exports, "buildAccumulator", {
228
+ enumerable: true,
229
+ get: function() {
230
+ return buildAccumulator;
231
+ }
232
+ });
233
+ Object.defineProperty(exports, "buildMongoFilter", {
234
+ enumerable: true,
235
+ get: function() {
236
+ return buildMongoFilter;
237
+ }
238
+ });
239
+ Object.defineProperty(exports, "distinctCountExpr", {
240
+ enumerable: true,
241
+ get: function() {
242
+ return distinctCountExpr;
243
+ }
244
+ });
245
+ Object.defineProperty(exports, "orNull", {
246
+ enumerable: true,
247
+ get: function() {
248
+ return orNull;
249
+ }
250
+ });
251
+ Object.defineProperty(exports, "queryNodeToExpr", {
252
+ enumerable: true,
253
+ get: function() {
254
+ return queryNodeToExpr;
255
+ }
256
+ });