@semics-tech/mongolite 1.0.0 → 1.1.0

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.
@@ -0,0 +1,760 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FindCursor = void 0;
4
+ const projection_js_1 = require("../utils/projection.js");
5
+ /**
6
+ * Safely parses JSON data with fallback mechanisms for malformed JSON.
7
+ * @param jsonString The JSON string to parse
8
+ * @param context Optional context for error messages and recovery
9
+ * @returns The parsed object or a fallback object
10
+ */
11
+ function safeJsonParse(jsonString, context) {
12
+ if (!jsonString || typeof jsonString !== 'string') {
13
+ console.warn(`Invalid JSON string${context ? ` in ${context}` : ''}: not a string or empty`);
14
+ return {};
15
+ }
16
+ try {
17
+ return JSON.parse(jsonString);
18
+ }
19
+ catch (error) {
20
+ // Log the error for debugging
21
+ console.error(`JSON parse error${context ? ` in ${context}` : ''}: ${error instanceof Error ? error.message : 'Unknown error'}`);
22
+ console.error(`Malformed JSON string: ${jsonString.substring(0, 500)}${jsonString.length > 500 ? '...' : ''}`);
23
+ // Attempt to recover from common JSON corruption issues
24
+ try {
25
+ // Try to fix common escaping issues
26
+ let fixedJson = jsonString;
27
+ // Fix double-escaped quotes
28
+ fixedJson = fixedJson.replace(/\\"/g, '"');
29
+ // Fix improperly escaped backslashes
30
+ fixedJson = fixedJson.replace(/\\\\/g, '\\');
31
+ // Try parsing the fixed JSON
32
+ const recovered = JSON.parse(fixedJson);
33
+ console.warn(`Successfully recovered malformed JSON${context ? ` in ${context}` : ''}`);
34
+ return recovered;
35
+ }
36
+ catch (recoveryError) {
37
+ console.error(`JSON recovery failed${context ? ` in ${context}` : ''}: ${recoveryError instanceof Error ? recoveryError.message : 'Unknown error'}`);
38
+ // Last resort: return an empty object with a special marker
39
+ return {
40
+ __mongoLiteCorrupted: true,
41
+ __originalData: jsonString,
42
+ __error: error instanceof Error ? error.message : 'Unknown JSON parse error',
43
+ };
44
+ }
45
+ }
46
+ }
47
+ /**
48
+ * Helper function to convert JavaScript values to SQLite-compatible values.
49
+ * This ensures that boolean values are converted to 0/1 integers that SQLite can handle.
50
+ * Date objects are converted to ISO strings for SQLite storage and comparison.
51
+ * @param value The value to convert
52
+ * @returns A SQLite-compatible value
53
+ */
54
+ function toSQLiteValue(value) {
55
+ if (typeof value === 'boolean') {
56
+ return value ? 1 : 0;
57
+ }
58
+ if (value instanceof Date) {
59
+ return value.toISOString();
60
+ }
61
+ return value;
62
+ }
63
+ /**
64
+ * Returns true for values that json_extract() returns as a nested JSON structure
65
+ * (plain objects/arrays) rather than as an unwrapped scalar. Date instances are
66
+ * scalars from SQLite's point of view (stored as ISO strings), not nested objects.
67
+ */
68
+ function isPlainObjectOrArray(value) {
69
+ return typeof value === 'object' && value !== null && !(value instanceof Date);
70
+ }
71
+ /**
72
+ * Builds a comparable value for a `json_extract(...) = ?` condition against an
73
+ * array element. json_extract() unwraps scalar leaf values (strings, numbers,
74
+ * booleans, ISO date strings) but returns nested objects/arrays as JSON text, so
75
+ * only those need to be JSON-stringified for comparison.
76
+ */
77
+ function toJsonExtractComparableValue(value) {
78
+ if (isPlainObjectOrArray(value)) {
79
+ return JSON.stringify(value);
80
+ }
81
+ return toSQLiteValue(value);
82
+ }
83
+ /**
84
+ * Helper function to recursively restore Date objects from JSON-parsed data.
85
+ * Converts ISO date strings back to Date objects.
86
+ * @param obj The object to process
87
+ * @returns The object with Date strings converted back to Date objects
88
+ */
89
+ function restoreDates(obj) {
90
+ if (obj === null || obj === undefined) {
91
+ return obj;
92
+ }
93
+ if (typeof obj === 'string') {
94
+ // Check if string is an ISO date string
95
+ const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
96
+ if (isoDateRegex.test(obj)) {
97
+ const date = new Date(obj);
98
+ // Verify it's a valid date and the string represents the same date
99
+ if (!isNaN(date.getTime()) && date.toISOString() === obj) {
100
+ return date;
101
+ }
102
+ }
103
+ return obj;
104
+ }
105
+ if (Array.isArray(obj)) {
106
+ return obj.map((item) => restoreDates(item));
107
+ }
108
+ if (typeof obj === 'object') {
109
+ const result = {};
110
+ for (const [key, value] of Object.entries(obj)) {
111
+ result[key] = restoreDates(value);
112
+ }
113
+ return result;
114
+ }
115
+ return obj;
116
+ }
117
+ /**
118
+ * Represents a cursor for find operations, allowing chaining of limit, skip, and sort.
119
+ */
120
+ class FindCursor {
121
+ constructor(db, collectionName, initialFilter, options = {}) {
122
+ this.db = db;
123
+ this.collectionName = collectionName;
124
+ this.options = options;
125
+ this.limitCount = null;
126
+ this.skipCount = null;
127
+ this.sortCriteria = null;
128
+ this.projectionFields = null;
129
+ this.queryParts = this.buildSelectQuery(initialFilter);
130
+ }
131
+ parseJsonPath(path) {
132
+ return `'$.${path.replace(/\./g, '.')}'`;
133
+ }
134
+ /**
135
+ * Helper method to build $in and $nin conditions for both array and non-array fields
136
+ */
137
+ buildArrayComparisonCondition(field, value, operator, params, elementPrefix = 'data') {
138
+ const jsonPath = elementPrefix === 'data'
139
+ ? `json_extract(${elementPrefix}, ${this.parseJsonPath(field)})`
140
+ : `json_extract(${elementPrefix}.value, '$.${field}')`;
141
+ // For Date values, use simple comparison without json_each to avoid JSON serialization issues
142
+ const hasDate = value.some((v) => v instanceof Date);
143
+ if (hasDate) {
144
+ const operator_symbol = operator === '$in' ? '=' : '!=';
145
+ const joiner = operator === '$in' ? ' OR ' : ' AND ';
146
+ const conditions = value.map(() => `${jsonPath} ${operator_symbol} ?`).join(joiner);
147
+ params.push(...value.map((v) => toSQLiteValue(v)));
148
+ if (operator === '$in') {
149
+ return `(${conditions})`;
150
+ }
151
+ else {
152
+ // For $nin with non-array fields, also include NULL
153
+ return `(${jsonPath} IS NULL OR ${conditions})`;
154
+ }
155
+ }
156
+ // For non-Date values, handle both array and non-array fields
157
+ const arrayPath = this.parseJsonPath(field);
158
+ const isArrayCheck = `json_type(json_extract(${elementPrefix}, ${arrayPath})) = 'array'`;
159
+ const placeholders = value.map(() => '?').join(',');
160
+ let arrayCondition;
161
+ let nonArrayCondition;
162
+ if (operator === '$in') {
163
+ arrayCondition = `EXISTS (SELECT 1 FROM json_each(json_extract(${elementPrefix}, ${arrayPath})) WHERE json_each.value IN (${placeholders}))`;
164
+ nonArrayCondition = `(${jsonPath} IN (${placeholders}))`;
165
+ }
166
+ else {
167
+ // $nin
168
+ arrayCondition = `NOT EXISTS (SELECT 1 FROM json_each(json_extract(${elementPrefix}, ${arrayPath})) WHERE json_each.value IN (${placeholders}))`;
169
+ nonArrayCondition = `(${jsonPath} NOT IN (${placeholders}) OR ${jsonPath} IS NULL)`;
170
+ }
171
+ // Push parameters for both array and non-array conditions
172
+ params.push(...value.map((v) => toSQLiteValue(v))); // for array condition
173
+ params.push(...value.map((v) => toSQLiteValue(v))); // for non-array condition
174
+ return `(
175
+ (${isArrayCheck} AND ${arrayCondition}) OR
176
+ (json_type(json_extract(${elementPrefix}, ${arrayPath})) != 'array' AND ${nonArrayCondition})
177
+ )`;
178
+ }
179
+ /**
180
+ * Helper method to generate a comparison condition
181
+ */
182
+ buildComparisonCondition(field, operator, value, params, elementPrefix = 'data') {
183
+ // Handle _id specially as it's a column, not in the JSON
184
+ if (field === '_id' && elementPrefix === 'data') {
185
+ switch (operator) {
186
+ case '$eq':
187
+ params.push(toSQLiteValue(value));
188
+ return '_id = ?';
189
+ case '$ne':
190
+ params.push(toSQLiteValue(value));
191
+ return '_id <> ?';
192
+ case '$in':
193
+ if (Array.isArray(value) && value.length > 0) {
194
+ // Convert each value to SQLite-compatible type
195
+ params.push(...value.map((v) => toSQLiteValue(v)));
196
+ return `_id IN (${value.map(() => '?').join(',')})`;
197
+ }
198
+ return '1=0'; // Empty array, nothing matches
199
+ case '$nin':
200
+ if (Array.isArray(value) && value.length > 0) {
201
+ // Convert each value to SQLite-compatible type
202
+ params.push(...value.map((v) => toSQLiteValue(v)));
203
+ return `_id NOT IN (${value.map(() => '?').join(',')})`;
204
+ }
205
+ return '1=1'; // Empty array, everything matches
206
+ default:
207
+ // Handle other operators that might be applied to _id
208
+ return '1=0'; // Unsupported operator for _id
209
+ }
210
+ }
211
+ // For JSON fields
212
+ const jsonPath = elementPrefix === 'data'
213
+ ? `json_extract(${elementPrefix}, ${this.parseJsonPath(field)})`
214
+ : `json_extract(${elementPrefix}.value, '$.${field}')`;
215
+ switch (operator) {
216
+ case '$eq':
217
+ if (value === null) {
218
+ return `${jsonPath} IS NULL`;
219
+ }
220
+ // Handle Date objects with exact equality
221
+ if (value instanceof Date) {
222
+ params.push(toSQLiteValue(value));
223
+ return `${jsonPath} = ?`;
224
+ }
225
+ // Handle string matching for equality and array matching as par mongodb client
226
+ params.push(toSQLiteValue(value));
227
+ params.push(toSQLiteValue(value));
228
+ return `(${jsonPath} = ? OR ${jsonPath} LIKE '%' || ? || '%')`; // For string matching
229
+ case '$ne':
230
+ if (value === null) {
231
+ return `${jsonPath} IS NOT NULL`;
232
+ }
233
+ params.push(toSQLiteValue(value));
234
+ // In MongoDB, $ne matches documents where the field doesn't exist OR has a different value
235
+ // SQLite's NULL != ? returns NULL (falsy), so we need to explicitly include NULL
236
+ return `(${jsonPath} IS NULL OR ${jsonPath} != ?)`;
237
+ case '$gt':
238
+ params.push(toSQLiteValue(value));
239
+ return `${jsonPath} > ?`;
240
+ case '$gte':
241
+ params.push(toSQLiteValue(value));
242
+ return `${jsonPath} >= ?`;
243
+ case '$lt':
244
+ params.push(toSQLiteValue(value));
245
+ return `${jsonPath} < ?`;
246
+ case '$lte':
247
+ params.push(toSQLiteValue(value));
248
+ return `${jsonPath} <= ?`;
249
+ case '$exists':
250
+ return value ? `${jsonPath} IS NOT NULL` : `${jsonPath} IS NULL`;
251
+ case '$in':
252
+ if (Array.isArray(value) && value.length > 0) {
253
+ return this.buildArrayComparisonCondition(field, value, '$in', params, elementPrefix);
254
+ }
255
+ return '1=0'; // Empty array, nothing matches
256
+ case '$nin':
257
+ if (Array.isArray(value) && value.length > 0) {
258
+ return this.buildArrayComparisonCondition(field, value, '$nin', params, elementPrefix);
259
+ }
260
+ return '1=1'; // Empty array, everything matches
261
+ case '$options':
262
+ // Consumed by $regex handling in buildWhereClause; treat standalone $options as unsupported
263
+ return '1=0';
264
+ case '$regex': {
265
+ // When called from buildComparisonCondition directly (no $options context),
266
+ // extract flags from a RegExp value if provided
267
+ let src;
268
+ let flags = '';
269
+ if (value instanceof RegExp) {
270
+ src = value.source;
271
+ flags = value.flags;
272
+ }
273
+ else if (typeof value === 'string') {
274
+ src = value;
275
+ }
276
+ else {
277
+ return '1=0';
278
+ }
279
+ const textExpr = `CAST(${jsonPath} AS TEXT)`;
280
+ if (flags) {
281
+ params.push(src, flags);
282
+ return `regexp_flags(?, ?, ${textExpr}) = 1`;
283
+ }
284
+ else {
285
+ params.push(src);
286
+ return `regexp(?, ${textExpr}) = 1`;
287
+ }
288
+ }
289
+ case '$size':
290
+ // Matches arrays with exactly N elements
291
+ params.push(value);
292
+ return `json_array_length(${jsonPath}) = ?`;
293
+ case '$type': {
294
+ // Map BSON type numbers/names to SQLite json_type() values
295
+ const mapBsonType = (bsonType) => {
296
+ const typeMap = {
297
+ 1: ['real', 'integer'],
298
+ 2: ['text'],
299
+ 3: ['object'],
300
+ 4: ['array'],
301
+ 8: ['true', 'false'],
302
+ 9: ['text'],
303
+ 10: ['null'],
304
+ 16: ['integer'],
305
+ 18: ['integer'],
306
+ double: ['real', 'integer'],
307
+ string: ['text'],
308
+ object: ['object'],
309
+ array: ['array'],
310
+ bool: ['true', 'false'],
311
+ boolean: ['true', 'false'],
312
+ date: ['text'],
313
+ null: ['null'],
314
+ int: ['integer'],
315
+ long: ['integer'],
316
+ number: ['real', 'integer'],
317
+ };
318
+ return typeMap[bsonType] ?? [];
319
+ };
320
+ const typeArgs = Array.isArray(value) ? value : [value];
321
+ const allSqlTypes = typeArgs.flatMap((t) => mapBsonType(t));
322
+ const uniqueTypes = [...new Set(allSqlTypes)];
323
+ if (uniqueTypes.length === 0)
324
+ return '1=0';
325
+ // Use json_type(json, path) form which correctly identifies type of JSON values
326
+ const typePath = elementPrefix === 'data'
327
+ ? `json_type(${elementPrefix}, '$.${field}')`
328
+ : `json_type(${elementPrefix}.value, '$.${field}')`;
329
+ const typeConds = uniqueTypes.map(() => `${typePath} = ?`).join(' OR ');
330
+ params.push(...uniqueTypes);
331
+ return uniqueTypes.length === 1 ? `${typePath} = ?` : `(${typeConds})`;
332
+ }
333
+ case '$mod': {
334
+ // Matches where field % divisor === remainder
335
+ if (Array.isArray(value) && value.length === 2) {
336
+ params.push(value[0], value[1]);
337
+ return `(CAST(${jsonPath} AS INTEGER) % ? = ?)`;
338
+ }
339
+ return '1=0';
340
+ }
341
+ default:
342
+ return '1=0'; // Unsupported operator
343
+ }
344
+ }
345
+ /**
346
+ * Builds a regex condition with optional flags from $options.
347
+ */
348
+ buildRegexCondition(field, value, options, params, elementPrefix = 'data') {
349
+ let src;
350
+ let flags = typeof options === 'string' ? options : '';
351
+ if (value instanceof RegExp) {
352
+ src = value.source;
353
+ flags = value.flags || flags;
354
+ }
355
+ else if (typeof value === 'string') {
356
+ src = value;
357
+ }
358
+ else {
359
+ return '1=0';
360
+ }
361
+ const jsonPath = elementPrefix === 'data'
362
+ ? `json_extract(${elementPrefix}, ${this.parseJsonPath(field)})`
363
+ : `json_extract(${elementPrefix}.value, '$.${field}')`;
364
+ const textExpr = `CAST(${jsonPath} AS TEXT)`;
365
+ if (flags) {
366
+ params.push(src, flags);
367
+ return `regexp_flags(?, ?, ${textExpr}) = 1`;
368
+ }
369
+ else {
370
+ params.push(src);
371
+ return `regexp(?, ${textExpr}) = 1`;
372
+ }
373
+ }
374
+ /**
375
+ * Builds a subquery for array operations like $all and $elemMatch
376
+ */
377
+ buildArraySubquery(field, operator, value, params) {
378
+ const arrayPath = this.parseJsonPath(field);
379
+ const arrayTypeCheck = `json_type(json_extract(data, ${arrayPath})) = 'array'`;
380
+ if (operator === '$all') {
381
+ if (Array.isArray(value) && value.length > 0) {
382
+ const allConditions = value
383
+ .map((item) => {
384
+ if (typeof item === 'object' &&
385
+ item !== null &&
386
+ !Array.isArray(item) &&
387
+ !(item instanceof Date)) {
388
+ // For object elements, we need to check if any array element matches all properties
389
+ const objectConditions = [];
390
+ for (const [key, val] of Object.entries(item)) {
391
+ // For nested objects in arrays, we need to match the full object
392
+ params.push(toJsonExtractComparableValue(val));
393
+ objectConditions.push(`json_extract(json_each.value, '$.${key}') = ?`);
394
+ }
395
+ const condition = objectConditions.join(' AND ');
396
+ return `EXISTS (SELECT 1 FROM json_each(json_extract(data, ${arrayPath})) WHERE ${condition})`;
397
+ }
398
+ else {
399
+ // For simple values, use direct comparison
400
+ params.push(toSQLiteValue(item));
401
+ return `EXISTS (SELECT 1 FROM json_each(json_extract(data, ${arrayPath})) WHERE json_each.value = ?)`;
402
+ }
403
+ })
404
+ .join(' AND ');
405
+ return `(${arrayTypeCheck} AND ${allConditions})`;
406
+ }
407
+ return '1=0'; // Empty $all array, nothing matches
408
+ }
409
+ else if (operator === '$in') {
410
+ // Handle $in with array values that may contain objects or scalars
411
+ if (Array.isArray(value) && value.length > 0) {
412
+ const hasObjects = value.some((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date));
413
+ if (hasObjects) {
414
+ // When $in has objects, match if any array element matches any of the objects
415
+ const orConditions = value
416
+ .map((item) => {
417
+ if (typeof item === 'object' &&
418
+ item !== null &&
419
+ !Array.isArray(item) &&
420
+ !(item instanceof Date)) {
421
+ // For object elements in $in, check if array element matches all properties
422
+ const objectConditions = [];
423
+ for (const [key, val] of Object.entries(item)) {
424
+ params.push(toJsonExtractComparableValue(val));
425
+ objectConditions.push(`json_extract(json_each.value, '$.${key}') = ?`);
426
+ }
427
+ const condition = objectConditions.join(' AND ');
428
+ return `EXISTS (SELECT 1 FROM json_each(json_extract(data, ${arrayPath})) WHERE ${condition})`;
429
+ }
430
+ else {
431
+ // For scalar values
432
+ params.push(toSQLiteValue(item));
433
+ return `EXISTS (SELECT 1 FROM json_each(json_extract(data, ${arrayPath})) WHERE json_each.value = ?)`;
434
+ }
435
+ })
436
+ .join(' OR ');
437
+ return `(${arrayTypeCheck} AND (${orConditions}))`;
438
+ }
439
+ else {
440
+ // All scalar values - use the existing logic for scalar comparison
441
+ return this.buildArrayComparisonCondition(field, value, '$in', params);
442
+ }
443
+ }
444
+ return '1=0'; // Empty $in array, nothing matches
445
+ }
446
+ else if (operator === '$elemMatch') {
447
+ let elemMatchSubquery = `EXISTS (
448
+ SELECT 1
449
+ FROM json_each(json_extract(data, ${arrayPath})) as array_elements
450
+ WHERE `;
451
+ if (typeof value === 'object' && value !== null) {
452
+ const conditions = this.buildObjectConditions(value, params, 'array_elements');
453
+ elemMatchSubquery += conditions;
454
+ }
455
+ else {
456
+ // Simple equality check for the entire element
457
+ params.push(toSQLiteValue(value));
458
+ elemMatchSubquery += `array_elements.value = ?`;
459
+ }
460
+ elemMatchSubquery += ')';
461
+ return `${arrayTypeCheck} AND ${elemMatchSubquery}`;
462
+ }
463
+ return '1=0'; // Unsupported array operator
464
+ }
465
+ /**
466
+ * Builds conditions for an object with potentially nested operators
467
+ */
468
+ buildObjectConditions(obj, params, elementPrefix = 'data') {
469
+ const conditions = [];
470
+ for (const [key, value] of Object.entries(obj)) {
471
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
472
+ // Check if this is an operator object
473
+ const operatorKeys = Object.keys(value);
474
+ const isOperatorObject = operatorKeys.every((k) => k.startsWith('$'));
475
+ if (isOperatorObject) {
476
+ // This is an object with operators like { age: { $gt: 30 } }
477
+ const opConditions = operatorKeys.map((op) => this.buildComparisonCondition(key, op, value[op], params, elementPrefix));
478
+ conditions.push(`(${opConditions.join(' AND ')})`);
479
+ }
480
+ else {
481
+ // This is a nested object, handle recursively
482
+ // In this implementation, we'd need to handle dot notation
483
+ // which might require more complex logic
484
+ conditions.push('1=0'); // Placeholder for nested object handling
485
+ }
486
+ }
487
+ else {
488
+ // Simple equality
489
+ conditions.push(this.buildComparisonCondition(key, '$eq', value, params, elementPrefix));
490
+ }
491
+ }
492
+ return conditions.length > 0 ? conditions.join(' AND ') : '1=1';
493
+ }
494
+ /**
495
+ * Builds the SQL condition(s) for a set of operators applied to one field, e.g.
496
+ * `{ age: { $gt: 30, $lt: 50 } }`. Recurses for `$not`, so a negated operator set on the same
497
+ * field (`{ age: { $not: { $gt: 30 } } }`) gets wrapped in `NOT (...)` rather than falling
498
+ * through to buildComparisonCondition's unsupported-operator default.
499
+ */
500
+ buildFieldOperatorConditions(field, ops, params) {
501
+ const regexOptions = ops['$options'];
502
+ return Object.entries(ops)
503
+ .filter(([op]) => op !== '$options') // $options is consumed alongside $regex
504
+ .map(([op, opValue]) => {
505
+ if (op === '$not') {
506
+ return `NOT (${this.buildFieldOperatorConditions(field, opValue, params)})`;
507
+ }
508
+ if (op === '$elemMatch' || op === '$all' || op === '$in') {
509
+ // Check if $in has objects that need array subquery handling
510
+ if (op === '$in' &&
511
+ Array.isArray(opValue) &&
512
+ opValue.some((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date))) {
513
+ return this.buildArraySubquery(field, op, opValue, params);
514
+ }
515
+ if (op === '$elemMatch' || op === '$all') {
516
+ return this.buildArraySubquery(field, op, opValue, params);
517
+ }
518
+ }
519
+ if (op === '$regex') {
520
+ return this.buildRegexCondition(field, opValue, regexOptions, params);
521
+ }
522
+ return this.buildComparisonCondition(field, op, opValue, params);
523
+ })
524
+ .join(' AND ');
525
+ }
526
+ buildWhereClause(filter, params) {
527
+ const conditions = [];
528
+ for (const key of Object.keys(filter)) {
529
+ const value = filter[key];
530
+ // Handle logical operators
531
+ if (key === '$and' && filter.$and) {
532
+ const andConditions = filter.$and
533
+ .map((subFilter) => `(${this.buildWhereClause(subFilter, params)})`)
534
+ .join(' AND ');
535
+ conditions.push(`(${andConditions})`);
536
+ }
537
+ else if (key === '$or' && filter.$or) {
538
+ const orConditions = filter.$or
539
+ .map((subFilter) => `(${this.buildWhereClause(subFilter, params)})`)
540
+ .join(' OR ');
541
+ conditions.push(`(${orConditions})`);
542
+ }
543
+ else if (key === '$nor' && filter.$nor) {
544
+ const norConditions = filter.$nor
545
+ .map((subFilter) => `(${this.buildWhereClause(subFilter, params)})`)
546
+ .join(' OR ');
547
+ conditions.push(`NOT (${norConditions})`);
548
+ }
549
+ else if (key === '$not' && filter.$not) {
550
+ // Create a nested filter with the $not contents
551
+ const nestedFilter = {
552
+ [Object.keys(filter.$not)[0]]: filter.$not[Object.keys(filter.$not)[0]],
553
+ };
554
+ const notClause = this.buildWhereClause(nestedFilter, params);
555
+ conditions.push(`NOT (${notClause})`);
556
+ }
557
+ // Handle text search
558
+ else if (key === '$text' && filter.$text) {
559
+ const textSearch = filter.$text;
560
+ const search = textSearch.$search;
561
+ if (typeof search === 'string' && search.trim() !== '') {
562
+ conditions.push(`data LIKE ?`);
563
+ params.push(`%${search}%`); // Simple LIKE search
564
+ }
565
+ else {
566
+ conditions.push('1=0'); // No valid search term, nothing matches
567
+ }
568
+ }
569
+ // Handle field existence checks
570
+ else if (key === '$exists' && filter.$exists) {
571
+ const existsConditions = Object.entries(filter.$exists)
572
+ .map(([field, exists]) => this.buildComparisonCondition(field, '$exists', exists, params))
573
+ .join(' AND ');
574
+ conditions.push(`(${existsConditions})`);
575
+ }
576
+ // Handle array operations
577
+ else if ((key === '$all' || key === '$elemMatch') && filter[key]) {
578
+ const arrayConditions = Object.entries(filter[key])
579
+ .map(([field, value]) => this.buildArraySubquery(field, key, value, params))
580
+ .join(' AND ');
581
+ conditions.push(`(${arrayConditions})`);
582
+ }
583
+ // Handle regular field conditions
584
+ else if (!key.startsWith('$')) {
585
+ // It's a field path
586
+ if (key === '_id') {
587
+ if (typeof value === 'string') {
588
+ conditions.push(this.buildComparisonCondition('_id', '$eq', value, params));
589
+ }
590
+ else if (typeof value === 'object' && value !== null) {
591
+ // Handle operators on _id
592
+ const opConditions = Object.entries(value)
593
+ .map(([op, opValue]) => this.buildComparisonCondition('_id', op, opValue, params))
594
+ .join(' AND ');
595
+ conditions.push(`(${opConditions})`);
596
+ }
597
+ }
598
+ else {
599
+ // Regular field with JSON path
600
+ if (value instanceof Date) {
601
+ // Handle Date objects for simple equality
602
+ const jsonPath = this.parseJsonPath(key);
603
+ conditions.push(`json_extract(data, ${jsonPath}) = ?`);
604
+ params.push(toSQLiteValue(value));
605
+ }
606
+ else if (value instanceof RegExp) {
607
+ // Support bare RegExp values: { field: /pattern/i }
608
+ conditions.push(this.buildRegexCondition(key, value, value.flags, params));
609
+ }
610
+ else if (typeof value === 'object' && value !== null) {
611
+ // Check if any key is an operator
612
+ const hasOperators = Object.keys(value).some((k) => k.startsWith('$'));
613
+ if (hasOperators) {
614
+ conditions.push(`(${this.buildFieldOperatorConditions(key, value, params)})`);
615
+ }
616
+ else {
617
+ // This is an object equality check (exact match)
618
+ const jsonPath = this.parseJsonPath(key);
619
+ conditions.push(`json_extract(data, ${jsonPath}) = ?`);
620
+ params.push(JSON.stringify(value));
621
+ }
622
+ }
623
+ else {
624
+ // Simple equality check
625
+ conditions.push(this.buildComparisonCondition(key, '$eq', value, params));
626
+ }
627
+ }
628
+ }
629
+ }
630
+ return conditions.length > 0 ? conditions.join(' AND ') : '1=1';
631
+ }
632
+ buildSelectQuery(filter) {
633
+ const params = [];
634
+ const whereClause = this.buildWhereClause(filter, params);
635
+ const sql = `SELECT _id, data FROM "${this.collectionName}" WHERE ${whereClause}`;
636
+ if (this.options.verbose) {
637
+ console.log(`Building SQL query for collection "${this.collectionName}"`);
638
+ console.log(`Filter: ${JSON.stringify(filter)}`);
639
+ console.log(`Where Clause: ${whereClause}`);
640
+ console.log(`SQL Query: ${sql}`);
641
+ console.log(`Parameters: ${JSON.stringify(params)}`);
642
+ }
643
+ return { sql, params };
644
+ }
645
+ /**
646
+ * Specifies the maximum number of documents the cursor will return.
647
+ * @param count The number of documents to limit to.
648
+ * @returns The `FindCursor` instance for chaining.
649
+ */
650
+ limit(count) {
651
+ if (count < 0)
652
+ throw new Error('Limit must be a non-negative number.');
653
+ this.limitCount = count;
654
+ return this;
655
+ }
656
+ /**
657
+ * Specifies the number of documents to skip.
658
+ * @param count The number of documents to skip.
659
+ * @returns The `FindCursor` instance for chaining.
660
+ */
661
+ skip(count) {
662
+ if (count < 0)
663
+ throw new Error('Skip must be a non-negative number.');
664
+ this.skipCount = count;
665
+ return this;
666
+ }
667
+ /**
668
+ * Specifies the sorting order for the documents.
669
+ * @param sortCriteria An object defining sort order (e.g., `{ age: -1, name: 1 }`).
670
+ * @returns The `FindCursor` instance for chaining.
671
+ */
672
+ sort(sortCriteria) {
673
+ this.sortCriteria = sortCriteria;
674
+ return this;
675
+ }
676
+ /**
677
+ * Specifies the fields to return (projection).
678
+ * @param projection An object where keys are field names and values are 1 (include) or 0 (exclude).
679
+ * `_id` is included by default unless explicitly excluded.
680
+ * @returns The `FindCursor` instance for chaining.
681
+ * @remarks When using projections, the returned documents may have undefined fields at runtime,
682
+ * though the TypeScript type will still be `T`. Consumers should be aware that projected-out
683
+ * fields will be undefined.
684
+ */
685
+ project(projection) {
686
+ this.projectionFields = projection;
687
+ return this;
688
+ }
689
+ /**
690
+ * Applies projection to a document, returning only the specified fields.
691
+ * @param doc The full document to apply projection to
692
+ * @returns The projected document. When a projection is applied, some fields may be undefined
693
+ * at runtime despite the return type being `T`. This trade-off provides better developer
694
+ * experience for the common case (no projection) while maintaining type compatibility.
695
+ * @private
696
+ */
697
+ applyProjection(doc) {
698
+ return (0, projection_js_1.applyProjectionToDocument)(doc, this.projectionFields);
699
+ }
700
+ /**
701
+ * Executes the query and returns all matching documents as an array.
702
+ * @returns A promise that resolves to an array of documents.
703
+ */
704
+ async toArray() {
705
+ let finalSql = this.queryParts.sql;
706
+ const finalParams = [...this.queryParts.params];
707
+ if (this.sortCriteria) {
708
+ const sortClauses = Object.entries(this.sortCriteria).map(([field, order]) => {
709
+ if (field === '_id') {
710
+ return `_id ${order === 1 ? 'ASC' : 'DESC'}`;
711
+ }
712
+ return `json_extract(data, ${this.parseJsonPath(field)}) ${order === 1 ? 'ASC' : 'DESC'}`;
713
+ });
714
+ if (sortClauses.length > 0) {
715
+ finalSql += ` ORDER BY ${sortClauses.join(', ')}`;
716
+ }
717
+ }
718
+ if (this.limitCount !== null) {
719
+ finalSql += ` LIMIT ?`;
720
+ finalParams.push(this.limitCount);
721
+ }
722
+ if (this.skipCount !== null) {
723
+ if (this.limitCount === null) {
724
+ // SQLite requires a LIMIT if OFFSET is used.
725
+ // Use a very large number if no limit is specified.
726
+ finalSql += ` LIMIT -1`; // Or a large number like 999999999
727
+ }
728
+ finalSql += ` OFFSET ?`;
729
+ finalParams.push(this.skipCount);
730
+ }
731
+ // Handle malformed json in the database
732
+ const rows = await this.db.all(finalSql, finalParams);
733
+ return rows.map((row) => {
734
+ const parsedData = safeJsonParse(row.data, `findCursor toArray for document ${row._id}`);
735
+ const restoredData = restoreDates(parsedData);
736
+ const doc = { _id: row._id, ...restoredData };
737
+ return this.applyProjection(doc);
738
+ });
739
+ }
740
+ /**
741
+ * Executes the query and returns the first matching document.
742
+ * @returns A promise that resolves to the first matching document or null if no matches are found.
743
+ * @throws Error if the query fails.
744
+ */
745
+ async first() {
746
+ const results = await this.limit(1).toArray();
747
+ return results.length > 0 ? results[0] : null;
748
+ }
749
+ /**
750
+ * Executes the query and returns a count of matching documents.
751
+ * @returns A promise that resolves to the count of matching documents.
752
+ */
753
+ async count() {
754
+ const whereClause = this.buildWhereClause({}, []);
755
+ const countSql = `SELECT COUNT(*) as count FROM "${this.collectionName}" WHERE ${whereClause}`;
756
+ const result = await this.db.get(countSql);
757
+ return result?.count ?? 0; // Return 0 if result is undefined
758
+ }
759
+ }
760
+ exports.FindCursor = FindCursor;