@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.
@@ -123,15 +123,16 @@ function restoreDates(obj) {
123
123
  * Represents a cursor for find operations, allowing chaining of limit, skip, and sort.
124
124
  */
125
125
  class FindCursor {
126
- constructor(db, collectionName, initialFilter, options = {}) {
126
+ constructor(db, collectionName, filter, options = {}) {
127
127
  this.db = db;
128
128
  this.collectionName = collectionName;
129
+ this.filter = filter;
129
130
  this.options = options;
130
131
  this.limitCount = null;
131
132
  this.skipCount = null;
132
133
  this.sortCriteria = null;
133
134
  this.projectionFields = null;
134
- this.queryParts = this.buildSelectQuery(initialFilter);
135
+ this.queryParts = this.buildSelectQuery(filter);
135
136
  }
136
137
  parseJsonPath(path) {
137
138
  return `'$.${path.replace(/\./g, '.')}'`;
@@ -494,7 +495,14 @@ class FindCursor {
494
495
  SELECT 1
495
496
  FROM json_each(${eachArg}) as array_elements
496
497
  WHERE `;
497
- if (typeof value === 'object' && value !== null) {
498
+ const valueKeys = typeof value === 'object' && value !== null && !Array.isArray(value)
499
+ ? Object.keys(value)
500
+ : [];
501
+ if (valueKeys.length > 0 && valueKeys.every((k) => k.startsWith('$') && k !== '$and')) {
502
+ // Scalar-element form: { $elemMatch: { $gt: 5, $lt: 10 } } / { $eq: 'x' }
503
+ elemMatchSubquery += this.buildScalarElemMatchConditions(value, params);
504
+ }
505
+ else if (typeof value === 'object' && value !== null) {
498
506
  const conditions = this.buildObjectConditions(value, params, 'array_elements');
499
507
  elemMatchSubquery += conditions;
500
508
  }
@@ -508,6 +516,50 @@ class FindCursor {
508
516
  }
509
517
  return '1=0'; // Unsupported array operator
510
518
  }
519
+ /**
520
+ * Builds conditions for $elemMatch applied to arrays of scalars, where each operator
521
+ * applies to the array element itself (`array_elements.value`).
522
+ */
523
+ buildScalarElemMatchConditions(ops, params) {
524
+ const col = 'array_elements.value';
525
+ const parts = [];
526
+ for (const [op, v] of Object.entries(ops)) {
527
+ switch (op) {
528
+ case '$eq':
529
+ params.push(toSQLiteValue(v));
530
+ parts.push(`${col} = ?`);
531
+ break;
532
+ case '$ne':
533
+ params.push(toSQLiteValue(v));
534
+ parts.push(`${col} <> ?`);
535
+ break;
536
+ case '$gt':
537
+ case '$gte':
538
+ case '$lt':
539
+ case '$lte': {
540
+ const sqlOp = { $gt: '>', $gte: '>=', $lt: '<', $lte: '<=' }[op];
541
+ params.push(toSQLiteValue(v));
542
+ parts.push(`${col} ${sqlOp} ?`);
543
+ break;
544
+ }
545
+ case '$in':
546
+ case '$nin': {
547
+ const list = Array.isArray(v) ? v : [];
548
+ if (list.length === 0) {
549
+ parts.push(op === '$in' ? '1=0' : '1=1');
550
+ }
551
+ else {
552
+ params.push(...list.map((x) => toSQLiteValue(x)));
553
+ parts.push(`${col} ${op === '$in' ? 'IN' : 'NOT IN'} (${list.map(() => '?').join(',')})`);
554
+ }
555
+ break;
556
+ }
557
+ default:
558
+ throw new Error(`Unsupported operator ${op} inside $elemMatch on scalar array elements`);
559
+ }
560
+ }
561
+ return parts.join(' AND ');
562
+ }
511
563
  /**
512
564
  * Builds conditions for an object with potentially nested operators
513
565
  */
@@ -840,9 +892,10 @@ class FindCursor {
840
892
  * @returns A promise that resolves to the count of matching documents.
841
893
  */
842
894
  async count() {
843
- const whereClause = this.buildWhereClause({}, []);
895
+ const params = [];
896
+ const whereClause = this.buildGuardedWhereClause(this.filter, params);
844
897
  const countSql = `SELECT COUNT(*) as count FROM "${this.collectionName}" WHERE ${whereClause}`;
845
- const result = await this.db.get(countSql);
898
+ const result = await this.db.get(countSql, params);
846
899
  return result?.count ?? 0; // Return 0 if result is undefined
847
900
  }
848
901
  }
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ /**
3
+ * In-memory aggregation expression evaluation, shared by $addFields, $project, $group,
4
+ * $replaceRoot and $match/$expr.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.SUPPORTED_ACCUMULATORS = exports.SUPPORTED_STAGES = void 0;
8
+ exports.getPath = getPath;
9
+ exports.isOperatorExpression = isOperatorExpression;
10
+ exports.evaluateExpression = evaluateExpression;
11
+ exports.evaluateExprFilter = evaluateExprFilter;
12
+ exports.filterUsesExpr = filterUsesExpr;
13
+ /** Safely reads a dotted path from a value. Returns undefined when any segment is missing. */
14
+ function getPath(obj, path) {
15
+ let current = obj;
16
+ for (const key of path.split('.')) {
17
+ if (current === null || current === undefined)
18
+ return undefined;
19
+ if (Array.isArray(current)) {
20
+ if (/^\d+$/.test(key)) {
21
+ current = current[Number(key)];
22
+ continue;
23
+ }
24
+ // Mongo maps the remaining path over array elements
25
+ return current.map((el) => getPath(el, key)).filter((v) => v !== undefined);
26
+ }
27
+ if (typeof current !== 'object')
28
+ return undefined;
29
+ current = current[key];
30
+ }
31
+ return current;
32
+ }
33
+ function isPlainObject(v) {
34
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
35
+ }
36
+ /** True if `v` is an expression operator object such as `{ $add: [...] }`. */
37
+ function isOperatorExpression(v) {
38
+ if (!isPlainObject(v))
39
+ return false;
40
+ const keys = Object.keys(v);
41
+ return keys.length === 1 && keys[0].startsWith('$');
42
+ }
43
+ function eq(a, b) {
44
+ if (a instanceof Date && b instanceof Date)
45
+ return a.getTime() === b.getTime();
46
+ return JSON.stringify(a) === JSON.stringify(b);
47
+ }
48
+ function cmp(a, b) {
49
+ if (a instanceof Date && b instanceof Date)
50
+ return a.getTime() - b.getTime();
51
+ const x = a;
52
+ const y = b;
53
+ return x < y ? -1 : x > y ? 1 : 0;
54
+ }
55
+ function truthy(v) {
56
+ return !(v === false || v === null || v === undefined || v === 0);
57
+ }
58
+ function num(v) {
59
+ return typeof v === 'number' ? v : null;
60
+ }
61
+ /**
62
+ * Evaluates an aggregation expression against a document.
63
+ * Supports field paths ("$a.b"), $$ROOT/$$CURRENT, literals, arrays, nested objects and a
64
+ * common subset of operators. Unsupported operators throw.
65
+ */
66
+ function evaluateExpression(doc, expr) {
67
+ if (typeof expr === 'string') {
68
+ if (expr === '$$ROOT' || expr === '$$CURRENT')
69
+ return doc;
70
+ if (expr.startsWith('$$'))
71
+ throw new Error(`Unsupported aggregation variable: ${expr}`);
72
+ if (expr.startsWith('$'))
73
+ return getPath(doc, expr.slice(1));
74
+ return expr;
75
+ }
76
+ if (Array.isArray(expr))
77
+ return expr.map((e) => evaluateExpression(doc, e));
78
+ if (expr instanceof Date || !isPlainObject(expr))
79
+ return expr;
80
+ const keys = Object.keys(expr);
81
+ if (keys.length === 1 && keys[0].startsWith('$')) {
82
+ return evaluateOperator(doc, keys[0], expr[keys[0]]);
83
+ }
84
+ const out = {};
85
+ for (const [k, v] of Object.entries(expr)) {
86
+ if (k.startsWith('$')) {
87
+ throw new Error(`An object representing an expression must have exactly one field: ${k}`);
88
+ }
89
+ const val = evaluateExpression(doc, v);
90
+ if (val !== undefined)
91
+ out[k] = val;
92
+ }
93
+ return out;
94
+ }
95
+ function args(doc, raw) {
96
+ return (Array.isArray(raw) ? raw : [raw]).map((a) => evaluateExpression(doc, a));
97
+ }
98
+ function evaluateOperator(doc, op, raw) {
99
+ switch (op) {
100
+ case '$literal':
101
+ return raw;
102
+ case '$add': {
103
+ const vals = args(doc, raw);
104
+ if (vals.some((v) => v === null || v === undefined))
105
+ return null;
106
+ if (vals.some((v) => v instanceof Date)) {
107
+ return new Date(vals.reduce((s, v) => s + (v instanceof Date ? v.getTime() : v), 0));
108
+ }
109
+ return vals.reduce((s, v) => s + v, 0);
110
+ }
111
+ case '$subtract': {
112
+ const [a, b] = args(doc, raw);
113
+ if (a === null || a === undefined || b === null || b === undefined)
114
+ return null;
115
+ if (a instanceof Date && b instanceof Date)
116
+ return a.getTime() - b.getTime();
117
+ if (a instanceof Date)
118
+ return new Date(a.getTime() - b);
119
+ return a - b;
120
+ }
121
+ case '$multiply': {
122
+ const vals = args(doc, raw);
123
+ if (vals.some((v) => num(v) === null))
124
+ return null;
125
+ return vals.reduce((s, v) => s * v, 1);
126
+ }
127
+ case '$divide': {
128
+ const [a, b] = args(doc, raw);
129
+ if (num(a) === null || num(b) === null)
130
+ return null;
131
+ return a / b;
132
+ }
133
+ case '$mod': {
134
+ const [a, b] = args(doc, raw);
135
+ if (num(a) === null || num(b) === null)
136
+ return null;
137
+ return a % b;
138
+ }
139
+ case '$concat': {
140
+ const vals = args(doc, raw);
141
+ if (vals.some((v) => v === null || v === undefined))
142
+ return null;
143
+ return vals.join('');
144
+ }
145
+ case '$toUpper': {
146
+ const [v] = args(doc, raw);
147
+ return v === null || v === undefined ? '' : String(v).toUpperCase();
148
+ }
149
+ case '$toLower': {
150
+ const [v] = args(doc, raw);
151
+ return v === null || v === undefined ? '' : String(v).toLowerCase();
152
+ }
153
+ case '$eq': {
154
+ const [a, b] = args(doc, raw);
155
+ return eq(a, b);
156
+ }
157
+ case '$ne': {
158
+ const [a, b] = args(doc, raw);
159
+ return !eq(a, b);
160
+ }
161
+ case '$gt': {
162
+ const [a, b] = args(doc, raw);
163
+ return cmp(a, b) > 0;
164
+ }
165
+ case '$gte': {
166
+ const [a, b] = args(doc, raw);
167
+ return cmp(a, b) >= 0;
168
+ }
169
+ case '$lt': {
170
+ const [a, b] = args(doc, raw);
171
+ return cmp(a, b) < 0;
172
+ }
173
+ case '$lte': {
174
+ const [a, b] = args(doc, raw);
175
+ return cmp(a, b) <= 0;
176
+ }
177
+ case '$and':
178
+ return (Array.isArray(raw) ? raw : [raw]).every((e) => truthy(evaluateExpression(doc, e)));
179
+ case '$or':
180
+ return (Array.isArray(raw) ? raw : [raw]).some((e) => truthy(evaluateExpression(doc, e)));
181
+ case '$not': {
182
+ const [v] = args(doc, raw);
183
+ return !truthy(v);
184
+ }
185
+ case '$cond': {
186
+ let cond, thenE, elseE;
187
+ if (Array.isArray(raw))
188
+ [cond, thenE, elseE] = raw;
189
+ else {
190
+ const o = raw;
191
+ cond = o['if'];
192
+ thenE = o['then'];
193
+ elseE = o['else'];
194
+ }
195
+ return evaluateExpression(doc, truthy(evaluateExpression(doc, cond)) ? thenE : elseE);
196
+ }
197
+ case '$ifNull': {
198
+ const vals = Array.isArray(raw) ? raw : [raw];
199
+ for (let i = 0; i < vals.length - 1; i++) {
200
+ const v = evaluateExpression(doc, vals[i]);
201
+ if (v !== null && v !== undefined)
202
+ return v;
203
+ }
204
+ return evaluateExpression(doc, vals[vals.length - 1]);
205
+ }
206
+ case '$size': {
207
+ const [v] = args(doc, raw);
208
+ if (!Array.isArray(v))
209
+ throw new Error('The argument to $size must be an array');
210
+ return v.length;
211
+ }
212
+ case '$arrayElemAt': {
213
+ const [arr, idx] = args(doc, raw);
214
+ if (!Array.isArray(arr) || typeof idx !== 'number')
215
+ return null;
216
+ const i = idx < 0 ? arr.length + idx : idx;
217
+ return arr[i];
218
+ }
219
+ case '$first': {
220
+ const [arr] = args(doc, raw);
221
+ return Array.isArray(arr) ? arr[0] : null;
222
+ }
223
+ case '$last': {
224
+ const [arr] = args(doc, raw);
225
+ return Array.isArray(arr) ? arr[arr.length - 1] : null;
226
+ }
227
+ default:
228
+ throw new Error(`Unsupported aggregation expression operator: ${op}`);
229
+ }
230
+ }
231
+ /** Evaluates a $match `$expr` value to a boolean. */
232
+ function evaluateExprFilter(doc, expr) {
233
+ return truthy(evaluateExpression(doc, expr));
234
+ }
235
+ /** True if a $match filter uses $expr anywhere in its logical tree. */
236
+ function filterUsesExpr(filter) {
237
+ if (!isPlainObject(filter))
238
+ return false;
239
+ for (const [k, v] of Object.entries(filter)) {
240
+ if (k === '$expr')
241
+ return true;
242
+ if ((k === '$and' || k === '$or' || k === '$nor') && Array.isArray(v) && v.some(filterUsesExpr))
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+ exports.SUPPORTED_STAGES = [
248
+ '$match',
249
+ '$project',
250
+ '$addFields',
251
+ '$set',
252
+ '$sort',
253
+ '$limit',
254
+ '$skip',
255
+ '$count',
256
+ '$group',
257
+ '$unwind',
258
+ '$lookup',
259
+ '$replaceRoot',
260
+ '$replaceWith',
261
+ ];
262
+ exports.SUPPORTED_ACCUMULATORS = [
263
+ '$sum',
264
+ '$avg',
265
+ '$min',
266
+ '$max',
267
+ '$push',
268
+ '$addToSet',
269
+ '$first',
270
+ '$last',
271
+ '$count',
272
+ ];
@@ -25,6 +25,7 @@ exports.SUPPORTED_QUERY_OPERATORS = [
25
25
  '$size',
26
26
  '$type',
27
27
  '$mod',
28
+ '$elemMatch',
28
29
  ];
29
30
  function matchesBsonType(value, bsonType) {
30
31
  switch (bsonType) {
@@ -123,6 +124,14 @@ function evaluateOperator(op, docValue, opValue, regexOptions) {
123
124
  const [divisor, remainder] = opValue;
124
125
  return Math.trunc(docValue) % divisor === remainder;
125
126
  }
127
+ case '$elemMatch': {
128
+ if (!Array.isArray(docValue) || typeof opValue !== 'object' || opValue === null)
129
+ return false;
130
+ const spec = opValue;
131
+ const keys = Object.keys(spec);
132
+ const scalarForm = keys.length > 0 && keys.every((k) => k.startsWith('$') && !LOGICAL.has(k));
133
+ return docValue.some((el) => scalarForm ? evaluateOperators(el, spec) : matchesElementFilter(el, spec));
134
+ }
126
135
  case '$options':
127
136
  return true; // consumed alongside $regex, not an independent predicate
128
137
  default:
@@ -140,3 +149,43 @@ function evaluateOperators(docValue, ops) {
140
149
  }
141
150
  return true;
142
151
  }
152
+ const LOGICAL = new Set(['$and', '$or', '$nor']);
153
+ function getPathValue(obj, path) {
154
+ let cur = obj;
155
+ for (const k of path.split('.')) {
156
+ if (cur === null || typeof cur !== 'object')
157
+ return undefined;
158
+ cur = cur[k];
159
+ }
160
+ return cur;
161
+ }
162
+ /** Matches one array element (an object) against an $elemMatch sub-filter. */
163
+ function matchesElementFilter(el, filter) {
164
+ for (const [key, cond] of Object.entries(filter)) {
165
+ if ((key === '$and' || key === '$or' || key === '$nor') && Array.isArray(cond)) {
166
+ const results = cond.map((f) => matchesElementFilter(el, f));
167
+ if (key === '$and' && !results.every(Boolean))
168
+ return false;
169
+ if (key === '$or' && !results.some(Boolean))
170
+ return false;
171
+ if (key === '$nor' && results.some(Boolean))
172
+ return false;
173
+ continue;
174
+ }
175
+ const v = getPathValue(el, key);
176
+ const isOps = typeof cond === 'object' &&
177
+ cond !== null &&
178
+ !(cond instanceof RegExp) &&
179
+ !(cond instanceof Date) &&
180
+ !Array.isArray(cond) &&
181
+ Object.keys(cond).some((k) => k.startsWith('$'));
182
+ if (isOps) {
183
+ if (!evaluateOperators(v, cond))
184
+ return false;
185
+ }
186
+ else if (JSON.stringify(v) !== JSON.stringify(cond)) {
187
+ return false;
188
+ }
189
+ }
190
+ return true;
191
+ }
@@ -227,7 +227,10 @@ export declare class MongoLiteCollection<T extends DocumentWithId> {
227
227
  drop(): Promise<void>;
228
228
  /**
229
229
  * Executes an aggregation pipeline on the collection.
230
- * Supports: $match, $project, $sort, $limit, $skip, $count, $group, $unwind, $addFields.
230
+ * Supports: $match (incl. $expr), $project, $sort, $limit, $skip, $count, $group
231
+ * ($sum, $avg, $min, $max, $push, $addToSet, $first, $last, $count), $unwind, $addFields/$set,
232
+ * $replaceRoot/$replaceWith and $lookup (equality form, local collections).
233
+ * Unknown stages and accumulators throw.
231
234
  * @param pipeline An array of pipeline stage documents.
232
235
  * @returns An object with a toArray() method that returns the aggregation result.
233
236
  */
@@ -244,6 +247,11 @@ export declare class MongoLiteCollection<T extends DocumentWithId> {
244
247
  * @private
245
248
  */
246
249
  private applyGroupStage;
250
+ /**
251
+ * Applies a $lookup stage (equality match against another collection in the same database).
252
+ * @private
253
+ */
254
+ private applyLookupStage;
247
255
  /**
248
256
  * Applies a $project stage to a document.
249
257
  * @private