@semics-tech/mongolite 1.4.0 → 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.
- package/README.md +24 -20
- package/dist/bin/mongolite.js +3 -3
- package/dist/bin/mongolite.js.map +1 -1
- package/dist/cjs/bin/mongolite.js +3 -3
- package/dist/cjs/collection.js +215 -135
- package/dist/cjs/cursors/findCursor.js +58 -5
- package/dist/cjs/utils/aggregation.js +272 -0
- package/dist/cjs/utils/queryOperators.js +49 -0
- package/dist/collection.d.ts +9 -1
- package/dist/collection.js +215 -135
- package/dist/collection.js.map +1 -1
- package/dist/cursors/findCursor.d.ts +7 -1
- package/dist/cursors/findCursor.js +58 -5
- package/dist/cursors/findCursor.js.map +1 -1
- package/dist/utils/aggregation.d.ts +22 -0
- package/dist/utils/aggregation.js +265 -0
- package/dist/utils/aggregation.js.map +1 -0
- package/dist/utils/queryOperators.d.ts +1 -1
- package/dist/utils/queryOperators.js +49 -0
- package/dist/utils/queryOperators.js.map +1 -1
- package/package.json +2 -2
package/dist/collection.js
CHANGED
|
@@ -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:
|
|
1070
|
+
return { acknowledged: true, deletedCount: result.changes ? 1 : 0 };
|
|
1065
1071
|
}
|
|
1066
1072
|
/**
|
|
1067
1073
|
* Deletes multiple documents matching the filter.
|
|
@@ -1096,11 +1102,7 @@ export class MongoLiteCollection {
|
|
|
1096
1102
|
*/
|
|
1097
1103
|
async countDocuments(filter = {}) {
|
|
1098
1104
|
await this.ensureTable();
|
|
1099
|
-
|
|
1100
|
-
const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForCount);
|
|
1101
|
-
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
|
|
1102
|
-
const result = await this.db.get(countSql, paramsForCount);
|
|
1103
|
-
return result?.count || 0;
|
|
1105
|
+
return this.find(filter).count();
|
|
1104
1106
|
}
|
|
1105
1107
|
/**
|
|
1106
1108
|
* Finds rows whose stored `data` column is not valid JSON.
|
|
@@ -1343,7 +1345,10 @@ export class MongoLiteCollection {
|
|
|
1343
1345
|
}
|
|
1344
1346
|
/**
|
|
1345
1347
|
* Executes an aggregation pipeline on the collection.
|
|
1346
|
-
* Supports: $match, $project, $sort, $limit, $skip, $count, $group
|
|
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.
|
|
1347
1352
|
* @param pipeline An array of pipeline stage documents.
|
|
1348
1353
|
* @returns An object with a toArray() method that returns the aggregation result.
|
|
1349
1354
|
*/
|
|
@@ -1364,8 +1369,20 @@ export class MongoLiteCollection {
|
|
|
1364
1369
|
// Only push the initial $match down to SQL when it is the very first stage.
|
|
1365
1370
|
// Applying a $match that appears after other stages (e.g. after $unwind/$group) too
|
|
1366
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
|
+
}
|
|
1367
1381
|
const firstStage = pipeline[0];
|
|
1368
|
-
|
|
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);
|
|
1369
1386
|
if (firstIsMatch) {
|
|
1370
1387
|
const matchFilter = firstStage.$match;
|
|
1371
1388
|
const docs = await this.find(matchFilter).toArray();
|
|
@@ -1393,9 +1410,43 @@ export class MongoLiteCollection {
|
|
|
1393
1410
|
results = results.map((doc) => this.applyAggregateProjection(doc, projection));
|
|
1394
1411
|
break;
|
|
1395
1412
|
}
|
|
1396
|
-
case '$addFields':
|
|
1397
|
-
|
|
1398
|
-
|
|
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']);
|
|
1399
1450
|
break;
|
|
1400
1451
|
}
|
|
1401
1452
|
case '$sort': {
|
|
@@ -1437,22 +1488,43 @@ export class MongoLiteCollection {
|
|
|
1437
1488
|
break;
|
|
1438
1489
|
}
|
|
1439
1490
|
case '$unwind': {
|
|
1440
|
-
const
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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;
|
|
1444
1500
|
const unwound = [];
|
|
1445
1501
|
for (const doc of results) {
|
|
1446
|
-
const arrayVal =
|
|
1447
|
-
if (Array.isArray(arrayVal)) {
|
|
1448
|
-
|
|
1449
|
-
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 };
|
|
1450
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;
|
|
1451
1519
|
unwound.push(newDoc);
|
|
1452
1520
|
}
|
|
1453
1521
|
}
|
|
1454
|
-
else
|
|
1455
|
-
|
|
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);
|
|
1456
1528
|
}
|
|
1457
1529
|
}
|
|
1458
1530
|
results = unwound;
|
|
@@ -1469,130 +1541,131 @@ export class MongoLiteCollection {
|
|
|
1469
1541
|
applyGroupStage(docs, groupSpec) {
|
|
1470
1542
|
const groups = new Map();
|
|
1471
1543
|
const idSpec = groupSpec['_id'];
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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`);
|
|
1479
1551
|
}
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
if (typeof expr === 'string' && expr.startsWith('$')) {
|
|
1484
|
-
keyObj[k] = this.getNestedValue(doc, expr.slice(1));
|
|
1485
|
-
}
|
|
1486
|
-
else {
|
|
1487
|
-
keyObj[k] = expr;
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
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`);
|
|
1491
1555
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1556
|
+
if (!SUPPORTED_ACCUMULATORS.includes(keys[0])) {
|
|
1557
|
+
throw new Error(`Unknown or unsupported $group accumulator operator: ${keys[0]}`);
|
|
1494
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;
|
|
1495
1566
|
const keyStr = JSON.stringify(groupKey);
|
|
1496
1567
|
if (!groups.has(keyStr)) {
|
|
1497
1568
|
groups.set(keyStr, []);
|
|
1569
|
+
keyValues.set(keyStr, groupKey);
|
|
1498
1570
|
}
|
|
1499
1571
|
groups.get(keyStr).push(doc);
|
|
1500
1572
|
}
|
|
1501
1573
|
const results = [];
|
|
1502
1574
|
for (const [keyStr, groupDocs] of groups) {
|
|
1503
|
-
const groupResult = { _id:
|
|
1504
|
-
for (const [field,
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
if (
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
.filter((v) => v !== undefined && v !== null);
|
|
1541
|
-
groupResult[field] =
|
|
1542
|
-
vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
|
|
1543
|
-
}
|
|
1544
|
-
break;
|
|
1545
|
-
}
|
|
1546
|
-
case '$max': {
|
|
1547
|
-
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1548
|
-
const fieldName = aggArg.slice(1);
|
|
1549
|
-
const vals = groupDocs
|
|
1550
|
-
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1551
|
-
.filter((v) => v !== undefined && v !== null);
|
|
1552
|
-
groupResult[field] =
|
|
1553
|
-
vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
|
|
1554
|
-
}
|
|
1555
|
-
break;
|
|
1556
|
-
}
|
|
1557
|
-
case '$push': {
|
|
1558
|
-
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1559
|
-
const fieldName = aggArg.slice(1);
|
|
1560
|
-
groupResult[field] = groupDocs.map((doc) => this.getNestedValue(doc, fieldName));
|
|
1561
|
-
}
|
|
1562
|
-
else {
|
|
1563
|
-
groupResult[field] = groupDocs.map(() => aggArg);
|
|
1564
|
-
}
|
|
1565
|
-
break;
|
|
1566
|
-
}
|
|
1567
|
-
case '$first': {
|
|
1568
|
-
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1569
|
-
const fieldName = aggArg.slice(1);
|
|
1570
|
-
groupResult[field] =
|
|
1571
|
-
groupDocs.length > 0 ? this.getNestedValue(groupDocs[0], fieldName) : null;
|
|
1572
|
-
}
|
|
1573
|
-
break;
|
|
1574
|
-
}
|
|
1575
|
-
case '$last': {
|
|
1576
|
-
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1577
|
-
const fieldName = aggArg.slice(1);
|
|
1578
|
-
groupResult[field] =
|
|
1579
|
-
groupDocs.length > 0
|
|
1580
|
-
? this.getNestedValue(groupDocs[groupDocs.length - 1], fieldName)
|
|
1581
|
-
: null;
|
|
1582
|
-
}
|
|
1583
|
-
break;
|
|
1584
|
-
}
|
|
1585
|
-
case '$count': {
|
|
1586
|
-
groupResult[field] = groupDocs.length;
|
|
1587
|
-
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);
|
|
1588
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;
|
|
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;
|
|
1589
1627
|
}
|
|
1628
|
+
case '$count':
|
|
1629
|
+
groupResult[field] = groupDocs.length;
|
|
1630
|
+
break;
|
|
1590
1631
|
}
|
|
1591
1632
|
}
|
|
1592
1633
|
results.push(groupResult);
|
|
1593
1634
|
}
|
|
1594
1635
|
return results;
|
|
1595
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
|
+
}
|
|
1596
1669
|
/**
|
|
1597
1670
|
* Applies a $project stage to a document.
|
|
1598
1671
|
* @private
|
|
@@ -1606,16 +1679,18 @@ export class MongoLiteCollection {
|
|
|
1606
1679
|
// for mode-determination purposes, matching real $project behavior.
|
|
1607
1680
|
const modeInput = {};
|
|
1608
1681
|
for (const [key, value] of Object.entries(projection)) {
|
|
1609
|
-
modeInput[key] =
|
|
1682
|
+
modeInput[key] = isComputedProjection(value) ? 1 : value;
|
|
1610
1683
|
}
|
|
1611
1684
|
const mode = resolveProjectionMode(modeInput);
|
|
1612
|
-
const base = applyProjectionToDocument(doc,
|
|
1685
|
+
const base = applyProjectionToDocument(doc, modeInput);
|
|
1613
1686
|
if (mode !== 'include')
|
|
1614
1687
|
return base;
|
|
1615
1688
|
const result = { ...base };
|
|
1616
1689
|
for (const [field, val] of Object.entries(projection)) {
|
|
1617
|
-
if (
|
|
1618
|
-
|
|
1690
|
+
if (isComputedProjection(val)) {
|
|
1691
|
+
const computed = evaluateExpression(doc, val);
|
|
1692
|
+
if (computed !== undefined)
|
|
1693
|
+
this.setNestedValue(result, field, computed);
|
|
1619
1694
|
}
|
|
1620
1695
|
}
|
|
1621
1696
|
return result;
|
|
@@ -1643,6 +1718,11 @@ export class MongoLiteCollection {
|
|
|
1643
1718
|
return false;
|
|
1644
1719
|
continue;
|
|
1645
1720
|
}
|
|
1721
|
+
if (key === '$expr') {
|
|
1722
|
+
if (!evaluateExprFilter(doc, value))
|
|
1723
|
+
return false;
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1646
1726
|
if (key.startsWith('$'))
|
|
1647
1727
|
continue;
|
|
1648
1728
|
const docValue = this.getNestedValue(doc, key);
|