@atscript/db-mongo 0.1.135 → 0.1.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agg.cjs +7 -23
- package/dist/agg.mjs +6 -22
- package/dist/index.cjs +259 -191
- package/dist/index.d.cts +16 -2
- package/dist/index.d.mts +16 -2
- package/dist/index.mjs +246 -178
- package/dist/mongo-accumulator-BalE9LkU.cjs +265 -0
- package/dist/mongo-accumulator-Dc0w4t0l.mjs +236 -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.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
|
|
2
2
|
import { AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
|
|
3
3
|
import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
|
|
4
|
-
import { BucketUnit } from "@uniqu/core";
|
|
4
|
+
import { AggregateFn, BucketUnit } from "@uniqu/core";
|
|
5
5
|
|
|
6
6
|
//#region src/lib/collection-patcher.d.ts
|
|
7
7
|
/**
|
|
@@ -303,6 +303,13 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
303
303
|
supportsNativePatch(): boolean;
|
|
304
304
|
/** All five units, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
|
|
305
305
|
calendarBucketUnits(): ReadonlySet<BucketUnit>;
|
|
306
|
+
/** Every aggregate function, `countDistinct` included. */
|
|
307
|
+
aggregateFns(): ReadonlySet<AggregateFn>;
|
|
308
|
+
/**
|
|
309
|
+
* See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
|
|
310
|
+
* BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
|
|
311
|
+
*/
|
|
312
|
+
viewRenderRevision(): string;
|
|
306
313
|
getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
|
|
307
314
|
/**
|
|
308
315
|
* Mongo can filter on JSON-stored fields natively — arrays via implicit
|
|
@@ -471,8 +478,15 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
471
478
|
*
|
|
472
479
|
* MongoDB's query language is nearly identical to the `FilterExpr` structure,
|
|
473
480
|
* so this is largely a structural pass-through via the `walkFilter` visitor.
|
|
481
|
+
* `fieldOperands` (view predicates only — `translateQueryTree` output) turns
|
|
482
|
+
* `{ $field: path }` operands into field-to-field `$expr` comparisons; a
|
|
483
|
+
* request filter never gets that reading.
|
|
474
484
|
*/
|
|
475
|
-
declare function buildMongoFilter(filter: FilterExpr
|
|
485
|
+
declare function buildMongoFilter(filter: FilterExpr, {
|
|
486
|
+
fieldOperands
|
|
487
|
+
}?: {
|
|
488
|
+
fieldOperands?: boolean;
|
|
489
|
+
}): Filter<any>;
|
|
476
490
|
//#endregion
|
|
477
491
|
//#region src/lib/validate-plugins.d.ts
|
|
478
492
|
declare const validateMongoIdPlugin: TValidatorPlugin;
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
|
|
2
2
|
import { AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
|
|
3
|
-
import { BucketUnit } from "@uniqu/core";
|
|
3
|
+
import { AggregateFn, BucketUnit } from "@uniqu/core";
|
|
4
4
|
import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
|
|
5
5
|
|
|
6
6
|
//#region src/lib/collection-patcher.d.ts
|
|
@@ -303,6 +303,13 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
303
303
|
supportsNativePatch(): boolean;
|
|
304
304
|
/** All five units, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
|
|
305
305
|
calendarBucketUnits(): ReadonlySet<BucketUnit>;
|
|
306
|
+
/** Every aggregate function, `countDistinct` included. */
|
|
307
|
+
aggregateFns(): ReadonlySet<AggregateFn>;
|
|
308
|
+
/**
|
|
309
|
+
* See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
|
|
310
|
+
* BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
|
|
311
|
+
*/
|
|
312
|
+
viewRenderRevision(): string;
|
|
306
313
|
getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
|
|
307
314
|
/**
|
|
308
315
|
* Mongo can filter on JSON-stored fields natively — arrays via implicit
|
|
@@ -471,8 +478,15 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
471
478
|
*
|
|
472
479
|
* MongoDB's query language is nearly identical to the `FilterExpr` structure,
|
|
473
480
|
* so this is largely a structural pass-through via the `walkFilter` visitor.
|
|
481
|
+
* `fieldOperands` (view predicates only — `translateQueryTree` output) turns
|
|
482
|
+
* `{ $field: path }` operands into field-to-field `$expr` comparisons; a
|
|
483
|
+
* request filter never gets that reading.
|
|
474
484
|
*/
|
|
475
|
-
declare function buildMongoFilter(filter: FilterExpr
|
|
485
|
+
declare function buildMongoFilter(filter: FilterExpr, {
|
|
486
|
+
fieldOperands
|
|
487
|
+
}?: {
|
|
488
|
+
fieldOperands?: boolean;
|
|
489
|
+
}): Filter<any>;
|
|
476
490
|
//#endregion
|
|
477
491
|
//#region src/lib/validate-plugins.d.ts
|
|
478
492
|
declare const validateMongoIdPlugin: TValidatorPlugin;
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, computeInsights, createFailureCollector, getDbFieldOp, getKeyProps, isAtscriptDbView, resolveDesignType } from "@atscript/db";
|
|
1
|
+
import { a as queryNodeToExpr, i as orNull, n as distinctCountExpr, r as buildMongoFilter, t as buildAccumulator } from "./mongo-accumulator-Dc0w4t0l.mjs";
|
|
2
|
+
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, computeInsights, createFailureCollector, getDbFieldOp, getKeyProps, isAtscriptDbView, isFieldRef, resolveDesignType, tableNameOf, translateQueryTree } from "@atscript/db";
|
|
3
3
|
import { MongoClient, MongoServerError, ObjectId } from "mongodb";
|
|
4
4
|
import { resolveAggregateSearch } from "@atscript/db/agg";
|
|
5
5
|
//#region src/lib/path-utils.ts
|
|
@@ -497,7 +497,7 @@ function buildFromLookup(host, withRel, relation, tableResolver) {
|
|
|
497
497
|
if (!targetMeta) return;
|
|
498
498
|
const remoteFK = findRemoteFK(targetMeta, host._table.tableName, relation.alias);
|
|
499
499
|
if (!remoteFK) return;
|
|
500
|
-
const targetTableName =
|
|
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();
|
|
@@ -1249,65 +1296,75 @@ async function renameTableImpl(host, oldName) {
|
|
|
1249
1296
|
async function syncColumnsImpl(host, diff) {
|
|
1250
1297
|
const renamed = [];
|
|
1251
1298
|
const added = [];
|
|
1252
|
-
const
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
|
|
1258
|
-
continue;
|
|
1259
|
-
}
|
|
1260
|
-
renameSpec[r.oldName] = r.field.physicalName;
|
|
1261
|
-
renamed.push(r.field.physicalName);
|
|
1262
|
-
}
|
|
1263
|
-
if (Object.keys(renameSpec).length > 0) update.$rename = renameSpec;
|
|
1264
|
-
}
|
|
1265
|
-
if (diff.added.length > 0) {
|
|
1266
|
-
const setSpec = {};
|
|
1267
|
-
for (const field of diff.added) {
|
|
1268
|
-
const defaultVal = resolveSyncDefault(field);
|
|
1269
|
-
if (defaultVal !== void 0) setSpec[arraySafePath(host, field.physicalName, field.path)] = defaultVal;
|
|
1270
|
-
added.push(field.physicalName);
|
|
1299
|
+
const renameSpec = {};
|
|
1300
|
+
for (const r of diff.renamed) {
|
|
1301
|
+
if (pathCrossesArray(host, r.field.path)) {
|
|
1302
|
+
host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
|
|
1303
|
+
continue;
|
|
1271
1304
|
}
|
|
1272
|
-
|
|
1305
|
+
renameSpec[r.oldName] = r.field.physicalName;
|
|
1306
|
+
renamed.push(r.field.physicalName);
|
|
1307
|
+
}
|
|
1308
|
+
if (renamed.length > 0) await host.collection.updateMany({}, { $rename: renameSpec }, host._getSessionOpts());
|
|
1309
|
+
for (const field of diff.added) {
|
|
1310
|
+
const value = resolveSyncDefault(field);
|
|
1311
|
+
if (value !== void 0) await defaultBackfill(host, field.path, value);
|
|
1312
|
+
added.push(field.physicalName);
|
|
1273
1313
|
}
|
|
1274
|
-
if (Object.keys(update).length > 0) await host.collection.updateMany({}, update, host._getSessionOpts());
|
|
1275
1314
|
return {
|
|
1276
1315
|
added,
|
|
1277
1316
|
renamed
|
|
1278
1317
|
};
|
|
1279
1318
|
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Backfills an added field's `@db.default` literal where the field is
|
|
1321
|
+
* missing — a stored value is never overwritten. Optional fields without a
|
|
1322
|
+
* default get no backfill (absent = "optional" in Mongo). A path crossing an
|
|
1323
|
+
* array narrows the innermost `$[]` to an `arrayFilters` element missing the
|
|
1324
|
+
* sub-path; outer arrays keep `$[]`, so empty arrays are a no-op (a bare
|
|
1325
|
+
* dotted path into an empty array is rejected with code 28).
|
|
1326
|
+
*/
|
|
1327
|
+
async function defaultBackfill(host, logicalPath, value) {
|
|
1328
|
+
const segments = positionalSegments(host, logicalPath);
|
|
1329
|
+
const inner = segments.lastIndexOf("$[]");
|
|
1330
|
+
const opts = host._getSessionOpts();
|
|
1331
|
+
if (inner === -1) {
|
|
1332
|
+
const path = segments.join(".");
|
|
1333
|
+
await host.collection.updateMany({ [path]: { $exists: false } }, { $set: { [path]: value } }, opts);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
segments[inner] = "$[backfill]";
|
|
1337
|
+
const rest = segments.slice(inner + 1).join(".");
|
|
1338
|
+
await host.collection.updateMany({}, { $set: { [segments.join(".")]: value } }, {
|
|
1339
|
+
...opts,
|
|
1340
|
+
arrayFilters: [{ [`backfill.${rest}`]: { $exists: false } }]
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1280
1343
|
async function dropColumnsImpl(host, columns) {
|
|
1281
1344
|
if (columns.length === 0) return;
|
|
1282
1345
|
const dropped = new Set(columns);
|
|
1283
1346
|
const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
|
|
1284
1347
|
const unsetSpec = {};
|
|
1285
|
-
for (const col of minimal) unsetSpec[
|
|
1348
|
+
for (const col of minimal) unsetSpec[positionalSegments(host, col, col).join(".")] = "";
|
|
1286
1349
|
await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
|
|
1287
1350
|
}
|
|
1288
1351
|
/**
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
* `logicalPath` drives the array-boundary walk (flatMap is keyed by logical
|
|
1294
|
-
* path); the leaf of `physicalPath` is preserved so any `@db.column` rename
|
|
1295
|
-
* on the leaf still applies.
|
|
1352
|
+
* The segments of a field's physical document path with `$[]` after every
|
|
1353
|
+
* segment whose logical prefix is an array in the flatMap — Mongo's
|
|
1354
|
+
* all-positional operator walks every element. `physicalPath` defaults to
|
|
1355
|
+
* the logical path's document path (same segment count).
|
|
1296
1356
|
*/
|
|
1297
|
-
function
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
const physicalSegments = physicalPath.split(".");
|
|
1301
|
-
const physicalLeaf = physicalSegments[physicalSegments.length - 1];
|
|
1357
|
+
function positionalSegments(host, logicalPath, physicalPath = host._table.getMetadata().documentPath(logicalPath)) {
|
|
1358
|
+
const logical = logicalPath.split(".");
|
|
1359
|
+
const physical = physicalPath.split(".");
|
|
1302
1360
|
const out = [];
|
|
1303
1361
|
let prefix = "";
|
|
1304
|
-
for (let i = 0; i <
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
|
|
1362
|
+
for (let i = 0; i < logical.length; i++) {
|
|
1363
|
+
out.push(physical[i]);
|
|
1364
|
+
prefix = joinPath(prefix, logical[i]);
|
|
1365
|
+
if (i < logical.length - 1 && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
|
|
1309
1366
|
}
|
|
1310
|
-
return out
|
|
1367
|
+
return out;
|
|
1311
1368
|
}
|
|
1312
1369
|
/** Returns true if any non-leaf segment of the path is typed as an array. */
|
|
1313
1370
|
function pathCrossesArray(host, logicalPath) {
|
|
@@ -1766,6 +1823,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1766
1823
|
calendarBucketUnits() {
|
|
1767
1824
|
return ALL_BUCKET_UNITS;
|
|
1768
1825
|
}
|
|
1826
|
+
/** Every aggregate function, `countDistinct` included. */
|
|
1827
|
+
aggregateFns() {
|
|
1828
|
+
return ALL_AGGREGATE_FNS;
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
|
|
1832
|
+
* BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
|
|
1833
|
+
*/
|
|
1834
|
+
viewRenderRevision() {
|
|
1835
|
+
return "2";
|
|
1836
|
+
}
|
|
1769
1837
|
getValidatorPlugins() {
|
|
1770
1838
|
return [validateMongoIdPlugin];
|
|
1771
1839
|
}
|
|
@@ -1874,11 +1942,6 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1874
1942
|
this._addMongoIndexField("unique", "__pk", field);
|
|
1875
1943
|
this._pendingUniqueFields.push(field);
|
|
1876
1944
|
}
|
|
1877
|
-
if (metadata.has("db.default.increment")) {
|
|
1878
|
-
const physicalName = metadata.get("db.column") ?? field;
|
|
1879
|
-
const startValue = metadata.get("db.default.increment");
|
|
1880
|
-
this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
|
|
1881
|
-
}
|
|
1882
1945
|
for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
|
|
1883
1946
|
indexName: index.indexName,
|
|
1884
1947
|
field,
|
|
@@ -1954,9 +2017,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1954
2017
|
path: value
|
|
1955
2018
|
});
|
|
1956
2019
|
}
|
|
1957
|
-
for (const fd of this._table.fieldDescriptors)
|
|
1958
|
-
if (
|
|
1959
|
-
|
|
2020
|
+
for (const fd of this._table.fieldDescriptors) {
|
|
2021
|
+
if (fd.collate && fd.collate !== "binary") {
|
|
2022
|
+
this._collateFields ??= /* @__PURE__ */ new Map();
|
|
2023
|
+
this._collateFields.set(fd.path, fd.collate);
|
|
2024
|
+
this._collateFields.set(fd.physicalName, fd.collate);
|
|
2025
|
+
}
|
|
2026
|
+
const def = fd.defaultValue;
|
|
2027
|
+
if (def?.kind === "fn" && def.fn === "increment") this._incrementFields.set(fd.physicalName, def.start);
|
|
1960
2028
|
}
|
|
1961
2029
|
}
|
|
1962
2030
|
/** Returns MongoDB-specific search index map (internal). */
|