@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 +7 -23
- package/dist/agg.mjs +6 -22
- package/dist/index.cjs +194 -143
- package/dist/index.d.cts +11 -2
- package/dist/index.d.mts +11 -2
- package/dist/index.mjs +181 -130
- package/dist/mongo-accumulator-C8J36t07.cjs +256 -0
- package/dist/mongo-accumulator-Dh6eEkyR.mjs +227 -0
- package/package.json +2 -2
- package/dist/mongo-filter-Bf5rWV4P.cjs +0 -73
- package/dist/mongo-filter-CtyITmZM.mjs +0 -68
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
|
526
|
-
const targetTableName =
|
|
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 =
|
|
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
|
|
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
|
+
});
|