@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.
- package/LICENSE +1 -1
- package/README.md +38 -0
- package/dist/adapters/node-sqlite.d.ts +61 -0
- package/dist/adapters/node-sqlite.js +196 -0
- package/dist/adapters/node-sqlite.js.map +1 -0
- package/dist/better-sqlite3.d.ts +49 -0
- package/dist/better-sqlite3.js +60 -0
- package/dist/better-sqlite3.js.map +1 -0
- package/dist/bin/mongolite-debug.js +0 -0
- package/dist/cjs/adapters/browser.js +106 -0
- package/dist/cjs/adapters/cloudflare.js +97 -0
- package/dist/cjs/adapters/node-sqlite.js +199 -0
- package/dist/cjs/better-sqlite3.js +80 -0
- package/dist/cjs/bin/mongolite-debug.js +152 -0
- package/dist/cjs/changeStream.js +341 -0
- package/dist/cjs/cloudflare.js +59 -0
- package/dist/cjs/collection.js +1709 -0
- package/dist/cjs/cursors/findCursor.js +760 -0
- package/dist/cjs/db.js +260 -0
- package/dist/cjs/debugger/query-debugger.js +297 -0
- package/dist/cjs/index.js +65 -0
- package/dist/cjs/mongo-client.js +58 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/utils/indexing.js +45 -0
- package/dist/cjs/utils/projection.js +101 -0
- package/dist/cjs/utils/queryOperators.js +142 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/package.json +20 -10
|
@@ -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;
|