@atscript/db-mongo 0.1.136 → 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 CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_accumulator = require("./mongo-accumulator-C8J36t07.cjs");
2
+ const require_mongo_accumulator = require("./mongo-accumulator-BalE9LkU.cjs");
3
3
  let _atscript_db_agg = require("@atscript/db/agg");
4
4
  let _uniqu_core = require("@uniqu/core");
5
5
  //#region src/agg.ts
package/dist/agg.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as buildMongoFilter, n as distinctCountExpr, r as orNull, t as buildAccumulator } from "./mongo-accumulator-Dh6eEkyR.mjs";
1
+ import { i as orNull, n as distinctCountExpr, r as buildMongoFilter, t as buildAccumulator } from "./mongo-accumulator-Dc0w4t0l.mjs";
2
2
  import { resolveAlias } from "@atscript/db/agg";
3
3
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
4
4
  //#region src/agg.ts
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_accumulator = require("./mongo-accumulator-C8J36t07.cjs");
2
+ const require_mongo_accumulator = require("./mongo-accumulator-BalE9LkU.cjs");
3
3
  let _atscript_db = require("@atscript/db");
4
4
  let mongodb = require("mongodb");
5
5
  let _atscript_db_agg = require("@atscript/db/agg");
@@ -1297,65 +1297,75 @@ async function renameTableImpl(host, oldName) {
1297
1297
  async function syncColumnsImpl(host, diff) {
1298
1298
  const renamed = [];
1299
1299
  const added = [];
1300
- const update = {};
1301
- if (diff.renamed.length > 0) {
1302
- const renameSpec = {};
1303
- for (const r of diff.renamed) {
1304
- if (pathCrossesArray(host, r.field.path)) {
1305
- host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1306
- continue;
1307
- }
1308
- renameSpec[r.oldName] = r.field.physicalName;
1309
- renamed.push(r.field.physicalName);
1300
+ const renameSpec = {};
1301
+ for (const r of diff.renamed) {
1302
+ if (pathCrossesArray(host, r.field.path)) {
1303
+ host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1304
+ continue;
1310
1305
  }
1311
- if (Object.keys(renameSpec).length > 0) update.$rename = renameSpec;
1312
- }
1313
- if (diff.added.length > 0) {
1314
- const setSpec = {};
1315
- for (const field of diff.added) {
1316
- const defaultVal = resolveSyncDefault(field);
1317
- if (defaultVal !== void 0) setSpec[arraySafePath(host, field.physicalName, field.path)] = defaultVal;
1318
- added.push(field.physicalName);
1319
- }
1320
- if (Object.keys(setSpec).length > 0) update.$set = setSpec;
1306
+ renameSpec[r.oldName] = r.field.physicalName;
1307
+ renamed.push(r.field.physicalName);
1308
+ }
1309
+ if (renamed.length > 0) await host.collection.updateMany({}, { $rename: renameSpec }, host._getSessionOpts());
1310
+ for (const field of diff.added) {
1311
+ const value = resolveSyncDefault(field);
1312
+ if (value !== void 0) await defaultBackfill(host, field.path, value);
1313
+ added.push(field.physicalName);
1321
1314
  }
1322
- if (Object.keys(update).length > 0) await host.collection.updateMany({}, update, host._getSessionOpts());
1323
1315
  return {
1324
1316
  added,
1325
1317
  renamed
1326
1318
  };
1327
1319
  }
1320
+ /**
1321
+ * Backfills an added field's `@db.default` literal where the field is
1322
+ * missing — a stored value is never overwritten. Optional fields without a
1323
+ * default get no backfill (absent = "optional" in Mongo). A path crossing an
1324
+ * array narrows the innermost `$[]` to an `arrayFilters` element missing the
1325
+ * sub-path; outer arrays keep `$[]`, so empty arrays are a no-op (a bare
1326
+ * dotted path into an empty array is rejected with code 28).
1327
+ */
1328
+ async function defaultBackfill(host, logicalPath, value) {
1329
+ const segments = positionalSegments(host, logicalPath);
1330
+ const inner = segments.lastIndexOf("$[]");
1331
+ const opts = host._getSessionOpts();
1332
+ if (inner === -1) {
1333
+ const path = segments.join(".");
1334
+ await host.collection.updateMany({ [path]: { $exists: false } }, { $set: { [path]: value } }, opts);
1335
+ return;
1336
+ }
1337
+ segments[inner] = "$[backfill]";
1338
+ const rest = segments.slice(inner + 1).join(".");
1339
+ await host.collection.updateMany({}, { $set: { [segments.join(".")]: value } }, {
1340
+ ...opts,
1341
+ arrayFilters: [{ [`backfill.${rest}`]: { $exists: false } }]
1342
+ });
1343
+ }
1328
1344
  async function dropColumnsImpl(host, columns) {
1329
1345
  if (columns.length === 0) return;
1330
1346
  const dropped = new Set(columns);
1331
1347
  const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
1332
1348
  const unsetSpec = {};
1333
- for (const col of minimal) unsetSpec[arraySafePath(host, col, col)] = "";
1349
+ for (const col of minimal) unsetSpec[positionalSegments(host, col, col).join(".")] = "";
1334
1350
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1335
1351
  }
1336
1352
  /**
1337
- * Rewrites a dotted physical path to use Mongo's all-positional $[] operator
1338
- * at every segment that's typed as an array in the table's flatMap. Returns
1339
- * the input unchanged when no segment crosses an array boundary.
1340
- *
1341
- * `logicalPath` drives the array-boundary walk (flatMap is keyed by logical
1342
- * path); the leaf of `physicalPath` is preserved so any `@db.column` rename
1343
- * on the leaf still applies.
1353
+ * The segments of a field's physical document path with `$[]` after every
1354
+ * segment whose logical prefix is an array in the flatMap — Mongo's
1355
+ * all-positional operator walks every element. `physicalPath` defaults to
1356
+ * the logical path's document path (same segment count).
1344
1357
  */
1345
- function arraySafePath(host, physicalPath, logicalPath) {
1346
- const logicalSegments = logicalPath.split(".");
1347
- if (logicalSegments.length < 2) return physicalPath;
1348
- const physicalSegments = physicalPath.split(".");
1349
- const physicalLeaf = physicalSegments[physicalSegments.length - 1];
1358
+ function positionalSegments(host, logicalPath, physicalPath = host._table.getMetadata().documentPath(logicalPath)) {
1359
+ const logical = logicalPath.split(".");
1360
+ const physical = physicalPath.split(".");
1350
1361
  const out = [];
1351
1362
  let prefix = "";
1352
- for (let i = 0; i < logicalSegments.length; i++) {
1353
- const isLeaf = i === logicalSegments.length - 1;
1354
- out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1355
- prefix = joinPath(prefix, logicalSegments[i]);
1356
- if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1363
+ for (let i = 0; i < logical.length; i++) {
1364
+ out.push(physical[i]);
1365
+ prefix = joinPath(prefix, logical[i]);
1366
+ if (i < logical.length - 1 && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1357
1367
  }
1358
- return out.join(".");
1368
+ return out;
1359
1369
  }
1360
1370
  /** Returns true if any non-leaf segment of the path is typed as an array. */
1361
1371
  function pathCrossesArray(host, logicalPath) {
@@ -1818,6 +1828,13 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1818
1828
  aggregateFns() {
1819
1829
  return _atscript_db.ALL_AGGREGATE_FNS;
1820
1830
  }
1831
+ /**
1832
+ * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
1833
+ * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
1834
+ */
1835
+ viewRenderRevision() {
1836
+ return "2";
1837
+ }
1821
1838
  getValidatorPlugins() {
1822
1839
  return [validateMongoIdPlugin];
1823
1840
  }
@@ -1926,11 +1943,6 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1926
1943
  this._addMongoIndexField("unique", "__pk", field);
1927
1944
  this._pendingUniqueFields.push(field);
1928
1945
  }
1929
- if (metadata.has("db.default.increment")) {
1930
- const physicalName = metadata.get("db.column") ?? field;
1931
- const startValue = metadata.get("db.default.increment");
1932
- this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1933
- }
1934
1946
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1935
1947
  indexName: index.indexName,
1936
1948
  field,
@@ -2006,9 +2018,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2006
2018
  path: value
2007
2019
  });
2008
2020
  }
2009
- for (const fd of this._table.fieldDescriptors) if (fd.collate && fd.collate !== "binary") {
2010
- if (!this._collateFields) this._collateFields = /* @__PURE__ */ new Map();
2011
- this._collateFields.set(fd.physicalName, fd.collate);
2021
+ for (const fd of this._table.fieldDescriptors) {
2022
+ if (fd.collate && fd.collate !== "binary") {
2023
+ this._collateFields ??= /* @__PURE__ */ new Map();
2024
+ this._collateFields.set(fd.path, fd.collate);
2025
+ this._collateFields.set(fd.physicalName, fd.collate);
2026
+ }
2027
+ const def = fd.defaultValue;
2028
+ if (def?.kind === "fn" && def.fn === "increment") this._incrementFields.set(fd.physicalName, def.start);
2012
2029
  }
2013
2030
  }
2014
2031
  /** Returns MongoDB-specific search index map (internal). */
package/dist/index.d.cts CHANGED
@@ -305,6 +305,11 @@ declare class MongoAdapter extends BaseDbAdapter {
305
305
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
306
306
  /** Every aggregate function, `countDistinct` included. */
307
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;
308
313
  getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
309
314
  /**
310
315
  * Mongo can filter on JSON-stored fields natively — arrays via implicit
package/dist/index.d.mts CHANGED
@@ -305,6 +305,11 @@ declare class MongoAdapter extends BaseDbAdapter {
305
305
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
306
306
  /** Every aggregate function, `countDistinct` included. */
307
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;
308
313
  getValidatorPlugins(): ReturnType<BaseDbAdapter["getValidatorPlugins"]>;
309
314
  /**
310
315
  * Mongo can filter on JSON-stored fields natively — arrays via implicit
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as buildMongoFilter, i as queryNodeToExpr, n as distinctCountExpr, r as orNull, t as buildAccumulator } from "./mongo-accumulator-Dh6eEkyR.mjs";
1
+ import { a as queryNodeToExpr, i as orNull, n as distinctCountExpr, r as buildMongoFilter, t as buildAccumulator } from "./mongo-accumulator-Dc0w4t0l.mjs";
2
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";
@@ -1296,65 +1296,75 @@ async function renameTableImpl(host, oldName) {
1296
1296
  async function syncColumnsImpl(host, diff) {
1297
1297
  const renamed = [];
1298
1298
  const added = [];
1299
- const update = {};
1300
- if (diff.renamed.length > 0) {
1301
- const renameSpec = {};
1302
- for (const r of diff.renamed) {
1303
- if (pathCrossesArray(host, r.field.path)) {
1304
- host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1305
- continue;
1306
- }
1307
- renameSpec[r.oldName] = r.field.physicalName;
1308
- renamed.push(r.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;
1309
1304
  }
1310
- if (Object.keys(renameSpec).length > 0) update.$rename = renameSpec;
1311
- }
1312
- if (diff.added.length > 0) {
1313
- const setSpec = {};
1314
- for (const field of diff.added) {
1315
- const defaultVal = resolveSyncDefault(field);
1316
- if (defaultVal !== void 0) setSpec[arraySafePath(host, field.physicalName, field.path)] = defaultVal;
1317
- added.push(field.physicalName);
1318
- }
1319
- if (Object.keys(setSpec).length > 0) update.$set = setSpec;
1305
+ renameSpec[r.oldName] = r.field.physicalName;
1306
+ renamed.push(r.field.physicalName);
1307
+ }
1308
+ if (renamed.length > 0) await host.collection.updateMany({}, { $rename: renameSpec }, host._getSessionOpts());
1309
+ for (const field of diff.added) {
1310
+ const value = resolveSyncDefault(field);
1311
+ if (value !== void 0) await defaultBackfill(host, field.path, value);
1312
+ added.push(field.physicalName);
1320
1313
  }
1321
- if (Object.keys(update).length > 0) await host.collection.updateMany({}, update, host._getSessionOpts());
1322
1314
  return {
1323
1315
  added,
1324
1316
  renamed
1325
1317
  };
1326
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
+ }
1327
1343
  async function dropColumnsImpl(host, columns) {
1328
1344
  if (columns.length === 0) return;
1329
1345
  const dropped = new Set(columns);
1330
1346
  const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
1331
1347
  const unsetSpec = {};
1332
- for (const col of minimal) unsetSpec[arraySafePath(host, col, col)] = "";
1348
+ for (const col of minimal) unsetSpec[positionalSegments(host, col, col).join(".")] = "";
1333
1349
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1334
1350
  }
1335
1351
  /**
1336
- * Rewrites a dotted physical path to use Mongo's all-positional $[] operator
1337
- * at every segment that's typed as an array in the table's flatMap. Returns
1338
- * the input unchanged when no segment crosses an array boundary.
1339
- *
1340
- * `logicalPath` drives the array-boundary walk (flatMap is keyed by logical
1341
- * path); the leaf of `physicalPath` is preserved so any `@db.column` rename
1342
- * 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).
1343
1356
  */
1344
- function arraySafePath(host, physicalPath, logicalPath) {
1345
- const logicalSegments = logicalPath.split(".");
1346
- if (logicalSegments.length < 2) return physicalPath;
1347
- const physicalSegments = physicalPath.split(".");
1348
- 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(".");
1349
1360
  const out = [];
1350
1361
  let prefix = "";
1351
- for (let i = 0; i < logicalSegments.length; i++) {
1352
- const isLeaf = i === logicalSegments.length - 1;
1353
- out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1354
- prefix = joinPath(prefix, logicalSegments[i]);
1355
- 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("$[]");
1356
1366
  }
1357
- return out.join(".");
1367
+ return out;
1358
1368
  }
1359
1369
  /** Returns true if any non-leaf segment of the path is typed as an array. */
1360
1370
  function pathCrossesArray(host, logicalPath) {
@@ -1817,6 +1827,13 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1817
1827
  aggregateFns() {
1818
1828
  return ALL_AGGREGATE_FNS;
1819
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
+ }
1820
1837
  getValidatorPlugins() {
1821
1838
  return [validateMongoIdPlugin];
1822
1839
  }
@@ -1925,11 +1942,6 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1925
1942
  this._addMongoIndexField("unique", "__pk", field);
1926
1943
  this._pendingUniqueFields.push(field);
1927
1944
  }
1928
- if (metadata.has("db.default.increment")) {
1929
- const physicalName = metadata.get("db.column") ?? field;
1930
- const startValue = metadata.get("db.default.increment");
1931
- this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1932
- }
1933
1945
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1934
1946
  indexName: index.indexName,
1935
1947
  field,
@@ -2005,9 +2017,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2005
2017
  path: value
2006
2018
  });
2007
2019
  }
2008
- for (const fd of this._table.fieldDescriptors) if (fd.collate && fd.collate !== "binary") {
2009
- if (!this._collateFields) this._collateFields = /* @__PURE__ */ new Map();
2010
- this._collateFields.set(fd.physicalName, fd.collate);
2020
+ for (const fd of this._table.fieldDescriptors) {
2021
+ if (fd.collate && fd.collate !== "binary") {
2022
+ this._collateFields ??= /* @__PURE__ */ new Map();
2023
+ this._collateFields.set(fd.path, fd.collate);
2024
+ this._collateFields.set(fd.physicalName, fd.collate);
2025
+ }
2026
+ const def = fd.defaultValue;
2027
+ if (def?.kind === "fn" && def.fn === "increment") this._incrementFields.set(fd.physicalName, def.start);
2011
2028
  }
2012
2029
  }
2013
2030
  /** Returns MongoDB-specific search index map (internal). */
@@ -1,85 +1,5 @@
1
1
  let _atscript_db = require("@atscript/db");
2
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
3
  //#region src/lib/mongo-view-expr.ts
84
4
  /**
85
5
  * `x IS NOT NULL` in SQL terms — true when `x` is neither null nor missing.
@@ -122,6 +42,15 @@ function guarded(operands, cond) {
122
42
  return { $and: [...operands.map((o) => notNullExpr(o)), cond] };
123
43
  }
124
44
  /**
45
+ * Field-to-field comparison `x <op> y` (`$eq` … `$lte`): both operands must
46
+ * be non-null (SQL `NULL = NULL` is UNKNOWN) — view predicates and
47
+ * `buildMongoFilter`'s field operands.
48
+ * @since 0.1.137
49
+ */
50
+ function fieldCompareExpr(op, x, y) {
51
+ return guarded([x, y], { [op]: [x, y] });
52
+ }
53
+ /**
125
54
  * Translates a view predicate (join condition, conditional-aggregate filter)
126
55
  * to an aggregation expression for `$match: { $expr }` / `$cond`, matching
127
56
  * SQL's three-valued logic wherever a NULL operand makes SQL's comparison
@@ -156,10 +85,7 @@ function queryNodeToExpr(node, pathOf) {
156
85
  }
157
86
  const op = COMPARISONS[comp.op];
158
87
  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
- }
88
+ if ((0, _atscript_db.isFieldRef)(comp.right)) return fieldCompareExpr(op, x, pathOf(comp.right));
163
89
  if (comp.right === null || comp.right === void 0) {
164
90
  if (op === "$eq") return isNullExpr(x);
165
91
  if (op === "$ne") return notNullExpr(x);
@@ -170,6 +96,89 @@ function queryNodeToExpr(node, pathOf) {
170
96
  return guarded([x], { [op]: [x, value] });
171
97
  }
172
98
  //#endregion
99
+ //#region src/lib/mongo-filter.ts
100
+ const EMPTY = {};
101
+ function parseRegexString(value) {
102
+ if (value instanceof RegExp) return {
103
+ pattern: value.source,
104
+ flags: value.flags
105
+ };
106
+ const str = String(value);
107
+ const match = str.match(/^\/(.+)\/([gimsuy]*)$/);
108
+ if (match) return {
109
+ pattern: match[1],
110
+ flags: match[2]
111
+ };
112
+ return {
113
+ pattern: str,
114
+ flags: ""
115
+ };
116
+ }
117
+ /**
118
+ * Earth radius in meters used by MongoDB's `$centerSphere` radians conversion
119
+ * (Mongo documents dividing by 6378.1 km).
120
+ */
121
+ const EARTH_RADIUS_M = 6378100;
122
+ const mongoVisitor = {
123
+ comparison(field, op, value) {
124
+ if (op === "$eq") return { [field]: value };
125
+ if (op === "$exists") return value ? { [field]: { $ne: null } } : { [field]: null };
126
+ if (op === "$regex") {
127
+ const { pattern, flags } = parseRegexString(value);
128
+ return flags ? { [field]: {
129
+ $regex: pattern,
130
+ $options: flags
131
+ } } : { [field]: { $regex: pattern } };
132
+ }
133
+ if (op === "$geoWithin") {
134
+ const { center, radius } = value;
135
+ return { [field]: { $geoWithin: { $centerSphere: [center, radius / EARTH_RADIUS_M] } } };
136
+ }
137
+ return { [field]: { [op]: value } };
138
+ },
139
+ and(children) {
140
+ if (children.length === 0) return EMPTY;
141
+ if (children.length === 1) return children[0];
142
+ return { $and: children };
143
+ },
144
+ or(children) {
145
+ if (children.length === 0) return { _impossible: true };
146
+ if (children.length === 1) return children[0];
147
+ return { $or: children };
148
+ },
149
+ not(child) {
150
+ return { $nor: [child] };
151
+ }
152
+ };
153
+ /** A `{ $field: path }` comparison operand (a field-to-field comparison). */
154
+ function isFieldOperand(value) {
155
+ return value !== null && typeof value === "object" && typeof value.$field === "string";
156
+ }
157
+ /**
158
+ * {@link mongoVisitor} plus field-to-field comparisons (`{ $field }` operands →
159
+ * `$expr`), null-guarded like SQL ({@link fieldCompareExpr}).
160
+ */
161
+ const fieldOperandVisitor = {
162
+ ...mongoVisitor,
163
+ comparison(field, op, value) {
164
+ return isFieldOperand(value) ? { $expr: fieldCompareExpr(op, `$${field}`, `$${value.$field}`) } : mongoVisitor.comparison(field, op, value);
165
+ }
166
+ };
167
+ /**
168
+ * Translates a generic {@link FilterExpr} into a MongoDB-compatible
169
+ * {@link Filter} document.
170
+ *
171
+ * MongoDB's query language is nearly identical to the `FilterExpr` structure,
172
+ * so this is largely a structural pass-through via the `walkFilter` visitor.
173
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
174
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
175
+ * request filter never gets that reading.
176
+ */
177
+ function buildMongoFilter(filter, { fieldOperands = false } = {}) {
178
+ if (!filter || Object.keys(filter).length === 0) return EMPTY;
179
+ return (0, _atscript_db.walkFilter)(filter, fieldOperands ? fieldOperandVisitor : mongoVisitor) ?? EMPTY;
180
+ }
181
+ //#endregion
173
182
  //#region src/lib/mongo-accumulator.ts
174
183
  /**
175
184
  * The `$group` accumulator of one aggregate — shared by grouped queries
@@ -1,85 +1,5 @@
1
1
  import { isFieldRef, walkFilter } from "@atscript/db";
2
2
  import { assertAggregateFn } from "@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 walkFilter(filter, fieldOperands ? fieldOperandVisitor : mongoVisitor) ?? EMPTY;
81
- }
82
- //#endregion
83
3
  //#region src/lib/mongo-view-expr.ts
84
4
  /**
85
5
  * `x IS NOT NULL` in SQL terms — true when `x` is neither null nor missing.
@@ -122,6 +42,15 @@ function guarded(operands, cond) {
122
42
  return { $and: [...operands.map((o) => notNullExpr(o)), cond] };
123
43
  }
124
44
  /**
45
+ * Field-to-field comparison `x <op> y` (`$eq` … `$lte`): both operands must
46
+ * be non-null (SQL `NULL = NULL` is UNKNOWN) — view predicates and
47
+ * `buildMongoFilter`'s field operands.
48
+ * @since 0.1.137
49
+ */
50
+ function fieldCompareExpr(op, x, y) {
51
+ return guarded([x, y], { [op]: [x, y] });
52
+ }
53
+ /**
125
54
  * Translates a view predicate (join condition, conditional-aggregate filter)
126
55
  * to an aggregation expression for `$match: { $expr }` / `$cond`, matching
127
56
  * SQL's three-valued logic wherever a NULL operand makes SQL's comparison
@@ -156,10 +85,7 @@ function queryNodeToExpr(node, pathOf) {
156
85
  }
157
86
  const op = COMPARISONS[comp.op];
158
87
  if (!op) throw new Error(`Operator "${comp.op}" is not supported in view predicates`);
159
- if (isFieldRef(comp.right)) {
160
- const y = pathOf(comp.right);
161
- return guarded([x, y], { [op]: [x, y] });
162
- }
88
+ if (isFieldRef(comp.right)) return fieldCompareExpr(op, x, pathOf(comp.right));
163
89
  if (comp.right === null || comp.right === void 0) {
164
90
  if (op === "$eq") return isNullExpr(x);
165
91
  if (op === "$ne") return notNullExpr(x);
@@ -170,6 +96,89 @@ function queryNodeToExpr(node, pathOf) {
170
96
  return guarded([x], { [op]: [x, value] });
171
97
  }
172
98
  //#endregion
99
+ //#region src/lib/mongo-filter.ts
100
+ const EMPTY = {};
101
+ function parseRegexString(value) {
102
+ if (value instanceof RegExp) return {
103
+ pattern: value.source,
104
+ flags: value.flags
105
+ };
106
+ const str = String(value);
107
+ const match = str.match(/^\/(.+)\/([gimsuy]*)$/);
108
+ if (match) return {
109
+ pattern: match[1],
110
+ flags: match[2]
111
+ };
112
+ return {
113
+ pattern: str,
114
+ flags: ""
115
+ };
116
+ }
117
+ /**
118
+ * Earth radius in meters used by MongoDB's `$centerSphere` radians conversion
119
+ * (Mongo documents dividing by 6378.1 km).
120
+ */
121
+ const EARTH_RADIUS_M = 6378100;
122
+ const mongoVisitor = {
123
+ comparison(field, op, value) {
124
+ if (op === "$eq") return { [field]: value };
125
+ if (op === "$exists") return value ? { [field]: { $ne: null } } : { [field]: null };
126
+ if (op === "$regex") {
127
+ const { pattern, flags } = parseRegexString(value);
128
+ return flags ? { [field]: {
129
+ $regex: pattern,
130
+ $options: flags
131
+ } } : { [field]: { $regex: pattern } };
132
+ }
133
+ if (op === "$geoWithin") {
134
+ const { center, radius } = value;
135
+ return { [field]: { $geoWithin: { $centerSphere: [center, radius / EARTH_RADIUS_M] } } };
136
+ }
137
+ return { [field]: { [op]: value } };
138
+ },
139
+ and(children) {
140
+ if (children.length === 0) return EMPTY;
141
+ if (children.length === 1) return children[0];
142
+ return { $and: children };
143
+ },
144
+ or(children) {
145
+ if (children.length === 0) return { _impossible: true };
146
+ if (children.length === 1) return children[0];
147
+ return { $or: children };
148
+ },
149
+ not(child) {
150
+ return { $nor: [child] };
151
+ }
152
+ };
153
+ /** A `{ $field: path }` comparison operand (a field-to-field comparison). */
154
+ function isFieldOperand(value) {
155
+ return value !== null && typeof value === "object" && typeof value.$field === "string";
156
+ }
157
+ /**
158
+ * {@link mongoVisitor} plus field-to-field comparisons (`{ $field }` operands →
159
+ * `$expr`), null-guarded like SQL ({@link fieldCompareExpr}).
160
+ */
161
+ const fieldOperandVisitor = {
162
+ ...mongoVisitor,
163
+ comparison(field, op, value) {
164
+ return isFieldOperand(value) ? { $expr: fieldCompareExpr(op, `$${field}`, `$${value.$field}`) } : mongoVisitor.comparison(field, op, value);
165
+ }
166
+ };
167
+ /**
168
+ * Translates a generic {@link FilterExpr} into a MongoDB-compatible
169
+ * {@link Filter} document.
170
+ *
171
+ * MongoDB's query language is nearly identical to the `FilterExpr` structure,
172
+ * so this is largely a structural pass-through via the `walkFilter` visitor.
173
+ * `fieldOperands` (view predicates only — `translateQueryTree` output) turns
174
+ * `{ $field: path }` operands into field-to-field `$expr` comparisons; a
175
+ * request filter never gets that reading.
176
+ */
177
+ function buildMongoFilter(filter, { fieldOperands = false } = {}) {
178
+ if (!filter || Object.keys(filter).length === 0) return EMPTY;
179
+ return walkFilter(filter, fieldOperands ? fieldOperandVisitor : mongoVisitor) ?? EMPTY;
180
+ }
181
+ //#endregion
173
182
  //#region src/lib/mongo-accumulator.ts
174
183
  /**
175
184
  * The `$group` accumulator of one aggregate — shared by grouped queries
@@ -224,4 +233,4 @@ function distinctCountExpr(set) {
224
233
  return { $size: set };
225
234
  }
226
235
  //#endregion
227
- export { buildMongoFilter as a, queryNodeToExpr as i, distinctCountExpr as n, orNull as r, buildAccumulator as t };
236
+ export { queryNodeToExpr as a, orNull as i, distinctCountExpr as n, buildMongoFilter as r, buildAccumulator as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.136",
3
+ "version": "0.1.137",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -58,7 +58,7 @@
58
58
  "@atscript/typescript": "^0.1.92",
59
59
  "@uniqu/core": "^0.1.11",
60
60
  "mongodb": "^6.17.0",
61
- "@atscript/db": "^0.1.136"
61
+ "@atscript/db": "^0.1.137"
62
62
  },
63
63
  "scripts": {
64
64
  "postinstall": "asc -f dts",