@semics-tech/mongolite 1.4.1 → 1.4.2

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.
@@ -4,6 +4,7 @@ import { extractRawIndexColumns } from './utils/indexing.js';
4
4
  import { ChangeStream } from './changeStream.js';
5
5
  import { applyProjectionToDocument, resolveProjectionMode } from './utils/projection.js';
6
6
  import { evaluateOperators } from './utils/queryOperators.js';
7
+ import { evaluateExpression, evaluateExprFilter, filterUsesExpr, getPath, isOperatorExpression, SUPPORTED_STAGES, SUPPORTED_ACCUMULATORS, } from './utils/aggregation.js';
7
8
  /**
8
9
  * Safely stringifies JSON data with validation and error handling.
9
10
  * Prevents storing malformed JSON that could cause parsing issues later.
@@ -135,6 +136,10 @@ function validateDocumentForStorage(doc, path = 'root') {
135
136
  }
136
137
  }
137
138
  }
139
+ /** A $project value that is a computed expression rather than an inclusion/exclusion flag. */
140
+ function isComputedProjection(value) {
141
+ return (typeof value === 'string' && value.startsWith('$')) || isOperatorExpression(value);
142
+ }
138
143
  /**
139
144
  * MongoLiteCollection provides methods to interact with a specific SQLite table
140
145
  * as if it were a MongoDB collection.
@@ -1043,25 +1048,26 @@ export class MongoLiteCollection {
1043
1048
  await this.ensureTable();
1044
1049
  const paramsForDelete = [];
1045
1050
  const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForDelete);
1046
- // Get the number of documents that would be deleted
1047
- const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
1048
- const countResult = await this.db.get(countSql, paramsForDelete);
1049
1051
  const deleteSql = `
1050
1052
  DELETE FROM "${this.name}"
1051
1053
  WHERE ROWID IN (SELECT ROWID FROM "${this.name}" WHERE ${whereClause} LIMIT 1);
1052
1054
  `;
1055
+ // Use the statement's own change count rather than a separate COUNT(*):
1056
+ // `LIMIT 1` on an aggregate does not stop the scan, so the pre-count read every
1057
+ // matching row (a full json_extract table scan on unindexed filters).
1058
+ let result;
1053
1059
  try {
1054
- await this.db.run(deleteSql, paramsForDelete);
1060
+ result = await this.db.run(deleteSql, paramsForDelete);
1055
1061
  }
1056
1062
  catch (error) {
1057
1063
  if (error.code === 'SQLITE_BUSY') {
1058
- await this.retryWithBackoff(async () => this.db.run(deleteSql, paramsForDelete), `delete for filter ${JSON.stringify(filter)}`);
1064
+ result = await this.retryWithBackoff(async () => this.db.run(deleteSql, paramsForDelete), `delete for filter ${JSON.stringify(filter)}`);
1059
1065
  }
1060
1066
  else {
1061
1067
  throw error;
1062
1068
  }
1063
1069
  }
1064
- return { acknowledged: true, deletedCount: countResult?.count ? 1 : 0 };
1070
+ return { acknowledged: true, deletedCount: result.changes ? 1 : 0 };
1065
1071
  }
1066
1072
  /**
1067
1073
  * Deletes multiple documents matching the filter.
@@ -1339,7 +1345,10 @@ export class MongoLiteCollection {
1339
1345
  }
1340
1346
  /**
1341
1347
  * Executes an aggregation pipeline on the collection.
1342
- * Supports: $match, $project, $sort, $limit, $skip, $count, $group, $unwind, $addFields.
1348
+ * Supports: $match (incl. $expr), $project, $sort, $limit, $skip, $count, $group
1349
+ * ($sum, $avg, $min, $max, $push, $addToSet, $first, $last, $count), $unwind, $addFields/$set,
1350
+ * $replaceRoot/$replaceWith and $lookup (equality form, local collections).
1351
+ * Unknown stages and accumulators throw.
1343
1352
  * @param pipeline An array of pipeline stage documents.
1344
1353
  * @returns An object with a toArray() method that returns the aggregation result.
1345
1354
  */
@@ -1360,8 +1369,20 @@ export class MongoLiteCollection {
1360
1369
  // Only push the initial $match down to SQL when it is the very first stage.
1361
1370
  // Applying a $match that appears after other stages (e.g. after $unwind/$group) too
1362
1371
  // early would change pipeline semantics.
1372
+ for (const stage of pipeline) {
1373
+ const keys = Object.keys(stage);
1374
+ if (keys.length !== 1) {
1375
+ throw new Error('A pipeline stage specification object must contain exactly one field.');
1376
+ }
1377
+ if (!SUPPORTED_STAGES.includes(keys[0])) {
1378
+ throw new Error(`Unrecognized or unsupported aggregation pipeline stage: ${keys[0]}`);
1379
+ }
1380
+ }
1363
1381
  const firstStage = pipeline[0];
1364
- const firstIsMatch = firstStage !== undefined && '$match' in firstStage;
1382
+ // $expr can't be pushed to SQL; it's evaluated in memory instead.
1383
+ const firstIsMatch = firstStage !== undefined &&
1384
+ '$match' in firstStage &&
1385
+ !filterUsesExpr(firstStage.$match);
1365
1386
  if (firstIsMatch) {
1366
1387
  const matchFilter = firstStage.$match;
1367
1388
  const docs = await this.find(matchFilter).toArray();
@@ -1389,9 +1410,43 @@ export class MongoLiteCollection {
1389
1410
  results = results.map((doc) => this.applyAggregateProjection(doc, projection));
1390
1411
  break;
1391
1412
  }
1392
- case '$addFields': {
1393
- const fields = stage['$addFields'];
1394
- results = results.map((doc) => ({ ...doc, ...fields }));
1413
+ case '$addFields':
1414
+ case '$set': {
1415
+ const fields = stage[stageKey];
1416
+ results = results.map((doc) => {
1417
+ const out = structuredClone(doc);
1418
+ for (const [path, expr] of Object.entries(fields)) {
1419
+ const val = evaluateExpression(doc, expr);
1420
+ if (val === undefined)
1421
+ continue;
1422
+ if (path.includes('.'))
1423
+ this.setNestedValue(out, path, val);
1424
+ else
1425
+ out[path] = val;
1426
+ }
1427
+ return out;
1428
+ });
1429
+ break;
1430
+ }
1431
+ case '$replaceRoot':
1432
+ case '$replaceWith': {
1433
+ const expr = stageKey === '$replaceRoot'
1434
+ ? stage['$replaceRoot'].newRoot
1435
+ : stage['$replaceWith'];
1436
+ if (expr === undefined) {
1437
+ throw new Error('$replaceRoot requires a newRoot expression');
1438
+ }
1439
+ results = results.map((doc) => {
1440
+ const root = evaluateExpression(doc, expr);
1441
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) {
1442
+ throw new Error("'newRoot' expression must evaluate to an object");
1443
+ }
1444
+ return root;
1445
+ });
1446
+ break;
1447
+ }
1448
+ case '$lookup': {
1449
+ results = await this.applyLookupStage(results, stage['$lookup']);
1395
1450
  break;
1396
1451
  }
1397
1452
  case '$sort': {
@@ -1433,22 +1488,43 @@ export class MongoLiteCollection {
1433
1488
  break;
1434
1489
  }
1435
1490
  case '$unwind': {
1436
- const path = typeof stage['$unwind'] === 'string'
1437
- ? stage['$unwind']
1438
- : stage['$unwind'].path;
1439
- const fieldName = path.startsWith('$') ? path.slice(1) : path;
1491
+ const spec = stage['$unwind'];
1492
+ const opts = typeof spec === 'string' ? { path: spec } : spec;
1493
+ const path = opts.path;
1494
+ if (typeof path !== 'string' || !path.startsWith('$')) {
1495
+ throw new Error('$unwind requires a path starting with "$"');
1496
+ }
1497
+ const fieldName = path.slice(1);
1498
+ const preserve = opts.preserveNullAndEmptyArrays === true;
1499
+ const indexField = typeof opts.includeArrayIndex === 'string' ? opts.includeArrayIndex : undefined;
1440
1500
  const unwound = [];
1441
1501
  for (const doc of results) {
1442
- const arrayVal = this.getNestedValue(doc, fieldName);
1443
- if (Array.isArray(arrayVal)) {
1444
- for (const item of arrayVal) {
1445
- const newDoc = { ...doc };
1502
+ const arrayVal = getPath(doc, fieldName);
1503
+ if (Array.isArray(arrayVal) && arrayVal.length > 0) {
1504
+ arrayVal.forEach((item, idx) => {
1505
+ const newDoc = fieldName.includes('.') ? structuredClone(doc) : { ...doc };
1446
1506
  this.setNestedValue(newDoc, fieldName, item);
1507
+ if (indexField)
1508
+ newDoc[indexField] = idx;
1509
+ unwound.push(newDoc);
1510
+ });
1511
+ }
1512
+ else if (Array.isArray(arrayVal) || arrayVal === null || arrayVal === undefined) {
1513
+ if (preserve) {
1514
+ const newDoc = fieldName.includes('.') ? structuredClone(doc) : { ...doc };
1515
+ if (Array.isArray(arrayVal))
1516
+ this.unsetNestedValue(newDoc, fieldName);
1517
+ if (indexField)
1518
+ newDoc[indexField] = null;
1447
1519
  unwound.push(newDoc);
1448
1520
  }
1449
1521
  }
1450
- else if (arrayVal !== undefined && arrayVal !== null) {
1451
- unwound.push(doc);
1522
+ else {
1523
+ // Non-array operand is treated as a single-element array
1524
+ const newDoc = { ...doc };
1525
+ if (indexField)
1526
+ newDoc[indexField] = null;
1527
+ unwound.push(newDoc);
1452
1528
  }
1453
1529
  }
1454
1530
  results = unwound;
@@ -1465,130 +1541,131 @@ export class MongoLiteCollection {
1465
1541
  applyGroupStage(docs, groupSpec) {
1466
1542
  const groups = new Map();
1467
1543
  const idSpec = groupSpec['_id'];
1468
- for (const doc of docs) {
1469
- let groupKey;
1470
- if (idSpec === null) {
1471
- groupKey = null;
1472
- }
1473
- else if (typeof idSpec === 'string' && idSpec.startsWith('$')) {
1474
- groupKey = this.getNestedValue(doc, idSpec.slice(1));
1544
+ // Validate accumulators up front so an unknown one throws even for empty input.
1545
+ const accumulators = [];
1546
+ for (const [field, expr] of Object.entries(groupSpec)) {
1547
+ if (field === '_id')
1548
+ continue;
1549
+ if (typeof expr !== 'object' || expr === null || Array.isArray(expr)) {
1550
+ throw new Error(`The field '${field}' must be an accumulator object`);
1475
1551
  }
1476
- else if (typeof idSpec === 'object' && idSpec !== null) {
1477
- const keyObj = {};
1478
- for (const [k, expr] of Object.entries(idSpec)) {
1479
- if (typeof expr === 'string' && expr.startsWith('$')) {
1480
- keyObj[k] = this.getNestedValue(doc, expr.slice(1));
1481
- }
1482
- else {
1483
- keyObj[k] = expr;
1484
- }
1485
- }
1486
- groupKey = keyObj;
1552
+ const keys = Object.keys(expr);
1553
+ if (keys.length !== 1) {
1554
+ throw new Error(`The field '${field}' must specify exactly one accumulator`);
1487
1555
  }
1488
- else {
1489
- groupKey = idSpec;
1556
+ if (!SUPPORTED_ACCUMULATORS.includes(keys[0])) {
1557
+ throw new Error(`Unknown or unsupported $group accumulator operator: ${keys[0]}`);
1490
1558
  }
1559
+ accumulators.push([field, keys[0], expr[keys[0]]]);
1560
+ }
1561
+ const keyValues = new Map();
1562
+ for (const doc of docs) {
1563
+ let groupKey = idSpec === undefined ? null : evaluateExpression(doc, idSpec);
1564
+ if (groupKey === undefined)
1565
+ groupKey = null;
1491
1566
  const keyStr = JSON.stringify(groupKey);
1492
1567
  if (!groups.has(keyStr)) {
1493
1568
  groups.set(keyStr, []);
1569
+ keyValues.set(keyStr, groupKey);
1494
1570
  }
1495
1571
  groups.get(keyStr).push(doc);
1496
1572
  }
1497
1573
  const results = [];
1498
1574
  for (const [keyStr, groupDocs] of groups) {
1499
- const groupResult = { _id: JSON.parse(keyStr) };
1500
- for (const [field, expr] of Object.entries(groupSpec)) {
1501
- if (field === '_id')
1502
- continue;
1503
- if (typeof expr === 'object' && expr !== null) {
1504
- const aggOp = Object.keys(expr)[0];
1505
- const aggArg = expr[aggOp];
1506
- switch (aggOp) {
1507
- case '$sum': {
1508
- if (typeof aggArg === 'number') {
1509
- groupResult[field] = groupDocs.length * aggArg;
1510
- }
1511
- else if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1512
- const fieldName = aggArg.slice(1);
1513
- groupResult[field] = groupDocs.reduce((sum, doc) => {
1514
- const val = this.getNestedValue(doc, fieldName);
1515
- return sum + (typeof val === 'number' ? val : 0);
1516
- }, 0);
1517
- }
1518
- break;
1519
- }
1520
- case '$avg': {
1521
- if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1522
- const fieldName = aggArg.slice(1);
1523
- const nums = groupDocs
1524
- .map((doc) => this.getNestedValue(doc, fieldName))
1525
- .filter((v) => typeof v === 'number');
1526
- groupResult[field] =
1527
- nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
1528
- }
1529
- break;
1530
- }
1531
- case '$min': {
1532
- if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1533
- const fieldName = aggArg.slice(1);
1534
- const vals = groupDocs
1535
- .map((doc) => this.getNestedValue(doc, fieldName))
1536
- .filter((v) => v !== undefined && v !== null);
1537
- groupResult[field] =
1538
- vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
1539
- }
1540
- break;
1541
- }
1542
- case '$max': {
1543
- if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1544
- const fieldName = aggArg.slice(1);
1545
- const vals = groupDocs
1546
- .map((doc) => this.getNestedValue(doc, fieldName))
1547
- .filter((v) => v !== undefined && v !== null);
1548
- groupResult[field] =
1549
- vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
1550
- }
1551
- break;
1552
- }
1553
- case '$push': {
1554
- if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1555
- const fieldName = aggArg.slice(1);
1556
- groupResult[field] = groupDocs.map((doc) => this.getNestedValue(doc, fieldName));
1557
- }
1558
- else {
1559
- groupResult[field] = groupDocs.map(() => aggArg);
1560
- }
1561
- break;
1562
- }
1563
- case '$first': {
1564
- if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1565
- const fieldName = aggArg.slice(1);
1566
- groupResult[field] =
1567
- groupDocs.length > 0 ? this.getNestedValue(groupDocs[0], fieldName) : null;
1568
- }
1569
- break;
1570
- }
1571
- case '$last': {
1572
- if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1573
- const fieldName = aggArg.slice(1);
1574
- groupResult[field] =
1575
- groupDocs.length > 0
1576
- ? this.getNestedValue(groupDocs[groupDocs.length - 1], fieldName)
1577
- : null;
1578
- }
1579
- break;
1580
- }
1581
- case '$count': {
1582
- groupResult[field] = groupDocs.length;
1583
- break;
1575
+ const groupResult = { _id: keyValues.get(keyStr) };
1576
+ for (const [field, aggOp, aggArg] of accumulators) {
1577
+ const vals = () => groupDocs.map((doc) => evaluateExpression(doc, aggArg));
1578
+ switch (aggOp) {
1579
+ case '$sum':
1580
+ groupResult[field] = vals().reduce((sum, v) => sum + (typeof v === 'number' ? v : 0), 0);
1581
+ break;
1582
+ case '$avg': {
1583
+ const nums = vals().filter((v) => typeof v === 'number');
1584
+ groupResult[field] =
1585
+ nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
1586
+ break;
1587
+ }
1588
+ case '$min': {
1589
+ const v = vals().filter((x) => x !== undefined && x !== null);
1590
+ groupResult[field] = v.length > 0 ? v.reduce((a, b) => (a < b ? a : b)) : null;
1591
+ break;
1592
+ }
1593
+ case '$max': {
1594
+ const v = vals().filter((x) => x !== undefined && x !== null);
1595
+ groupResult[field] = v.length > 0 ? v.reduce((a, b) => (a > b ? a : b)) : null;
1596
+ break;
1597
+ }
1598
+ case '$push':
1599
+ groupResult[field] = vals().filter((v) => v !== undefined);
1600
+ break;
1601
+ case '$addToSet': {
1602
+ const seen = new Set();
1603
+ const out = [];
1604
+ for (const v of vals()) {
1605
+ if (v === undefined)
1606
+ continue;
1607
+ const k = v instanceof Date ? `d:${v.getTime()}` : JSON.stringify(v);
1608
+ if (seen.has(k))
1609
+ continue;
1610
+ seen.add(k);
1611
+ out.push(v);
1584
1612
  }
1613
+ groupResult[field] = out;
1614
+ break;
1615
+ }
1616
+ case '$first': {
1617
+ const v = groupDocs.length > 0 ? evaluateExpression(groupDocs[0], aggArg) : null;
1618
+ groupResult[field] = v === undefined ? null : v;
1619
+ break;
1585
1620
  }
1621
+ case '$last': {
1622
+ const v = groupDocs.length > 0
1623
+ ? evaluateExpression(groupDocs[groupDocs.length - 1], aggArg)
1624
+ : null;
1625
+ groupResult[field] = v === undefined ? null : v;
1626
+ break;
1627
+ }
1628
+ case '$count':
1629
+ groupResult[field] = groupDocs.length;
1630
+ break;
1586
1631
  }
1587
1632
  }
1588
1633
  results.push(groupResult);
1589
1634
  }
1590
1635
  return results;
1591
1636
  }
1637
+ /**
1638
+ * Applies a $lookup stage (equality match against another collection in the same database).
1639
+ * @private
1640
+ */
1641
+ async applyLookupStage(docs, spec) {
1642
+ const { from, localField, foreignField, as } = spec;
1643
+ if ('pipeline' in spec || 'let' in spec) {
1644
+ throw new Error('$lookup with pipeline/let is not supported; use localField/foreignField');
1645
+ }
1646
+ if (typeof from !== 'string' ||
1647
+ typeof localField !== 'string' ||
1648
+ typeof foreignField !== 'string' ||
1649
+ typeof as !== 'string') {
1650
+ throw new Error('$lookup requires string from, localField, foreignField and as');
1651
+ }
1652
+ const foreign = (await new MongoLiteCollection(this.db, from, this.options)
1653
+ .find({})
1654
+ .toArray());
1655
+ const key = (v) => JSON.stringify(v === undefined ? null : v);
1656
+ return docs.map((doc) => {
1657
+ const local = getPath(doc, localField);
1658
+ const localKeys = new Set((Array.isArray(local) ? (local.length > 0 ? local : [null]) : [local]).map(key));
1659
+ const matches = foreign.filter((f) => {
1660
+ const fv = getPath(f, foreignField);
1661
+ const fvs = Array.isArray(fv) ? [...fv, fv] : [fv];
1662
+ return fvs.some((x) => localKeys.has(key(x)));
1663
+ });
1664
+ const out = { ...doc };
1665
+ this.setNestedValue(out, as, matches);
1666
+ return out;
1667
+ });
1668
+ }
1592
1669
  /**
1593
1670
  * Applies a $project stage to a document.
1594
1671
  * @private
@@ -1602,16 +1679,18 @@ export class MongoLiteCollection {
1602
1679
  // for mode-determination purposes, matching real $project behavior.
1603
1680
  const modeInput = {};
1604
1681
  for (const [key, value] of Object.entries(projection)) {
1605
- modeInput[key] = typeof value === 'string' && value.startsWith('$') ? 1 : value;
1682
+ modeInput[key] = isComputedProjection(value) ? 1 : value;
1606
1683
  }
1607
1684
  const mode = resolveProjectionMode(modeInput);
1608
- const base = applyProjectionToDocument(doc, projection);
1685
+ const base = applyProjectionToDocument(doc, modeInput);
1609
1686
  if (mode !== 'include')
1610
1687
  return base;
1611
1688
  const result = { ...base };
1612
1689
  for (const [field, val] of Object.entries(projection)) {
1613
- if (typeof val === 'string' && val.startsWith('$')) {
1614
- result[field] = this.getNestedValue(doc, val.slice(1));
1690
+ if (isComputedProjection(val)) {
1691
+ const computed = evaluateExpression(doc, val);
1692
+ if (computed !== undefined)
1693
+ this.setNestedValue(result, field, computed);
1615
1694
  }
1616
1695
  }
1617
1696
  return result;
@@ -1639,6 +1718,11 @@ export class MongoLiteCollection {
1639
1718
  return false;
1640
1719
  continue;
1641
1720
  }
1721
+ if (key === '$expr') {
1722
+ if (!evaluateExprFilter(doc, value))
1723
+ return false;
1724
+ continue;
1725
+ }
1642
1726
  if (key.startsWith('$'))
1643
1727
  continue;
1644
1728
  const docValue = this.getNestedValue(doc, key);