flatsql 1.1.0 → 1.3.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/README.md +42 -4
- package/dist/core/database.d.ts +41 -2
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +745 -128
- package/dist/core/database.js.map +1 -1
- package/package.json +1 -1
- package/wasm/flatsql-wasi-noeh.wasm +0 -0
- package/wasm/flatsql-wasi.wasm +0 -0
- package/wasm/flatsql.js +1 -1
- package/wasm/flatsql.wasm +0 -0
- package/wasm/index.d.ts +14 -0
- package/wasm/index.js +33 -0
- package/wasm/standalone.d.ts +14 -0
- package/wasm/standalone.js +36 -0
package/dist/core/database.js
CHANGED
|
@@ -69,10 +69,22 @@ export class FlatSQLDatabase {
|
|
|
69
69
|
getStorageBytes() {
|
|
70
70
|
return this.storage.getCurrentSize();
|
|
71
71
|
}
|
|
72
|
-
// Execute a simple SQL query
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
72
|
+
// Execute a simple SQL query.
|
|
73
|
+
//
|
|
74
|
+
// Supported grammar:
|
|
75
|
+
// SELECT <columns> FROM <table> [WHERE <expr>] [ORDER BY <col> [ASC|DESC], ...] [LIMIT <n>]
|
|
76
|
+
//
|
|
77
|
+
// <expr> supports AND / OR / NOT / parentheses over these predicates:
|
|
78
|
+
// col = != <> < > <= >= value
|
|
79
|
+
// col [NOT] BETWEEN low AND high
|
|
80
|
+
// col [NOT] IN (v1, v2, ...)
|
|
81
|
+
// col [NOT] LIKE 'pattern' -- % and _ wildcards, ASCII case-insensitive (SQLite default)
|
|
82
|
+
// col IS [NOT] NULL
|
|
83
|
+
//
|
|
84
|
+
// FAIL-CLOSED CONTRACT: a WHERE or ORDER BY clause that this grammar cannot
|
|
85
|
+
// parse throws. It must NEVER degrade into an unfiltered full-table scan --
|
|
86
|
+
// silently returning every row to a caller that asked for a subset is a
|
|
87
|
+
// wrong answer that no consumer can detect.
|
|
76
88
|
query(sql) {
|
|
77
89
|
// Security: Check SQL length to prevent ReDoS
|
|
78
90
|
if (sql.length > this.maxSqlLength) {
|
|
@@ -90,41 +102,9 @@ export class FlatSQLDatabase {
|
|
|
90
102
|
if (!tableStore) {
|
|
91
103
|
throw new Error(`Table not found: ${parsed.table}`);
|
|
92
104
|
}
|
|
93
|
-
let records;
|
|
94
|
-
if (parsed.
|
|
95
|
-
|
|
96
|
-
if (tableStore.hasIndex(parsed.where.column)) {
|
|
97
|
-
records = tableStore.findByIndex(parsed.where.column, parsed.where.value);
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
// Fallback to scan when filter column is not indexed.
|
|
101
|
-
records = tableStore.scanAll().filter(record => {
|
|
102
|
-
const value = record.fields.get(parsed.where.column);
|
|
103
|
-
return this.evaluateCondition(value, '=', parsed.where.value);
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
else if (parsed.where.operator === 'BETWEEN') {
|
|
108
|
-
if (tableStore.hasIndex(parsed.where.column)) {
|
|
109
|
-
records = tableStore.findByRange(parsed.where.column, parsed.where.minValue, parsed.where.maxValue);
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
records = tableStore.scanAll().filter(record => {
|
|
113
|
-
const value = record.fields.get(parsed.where.column);
|
|
114
|
-
return value >= parsed.where.minValue && value <= parsed.where.maxValue;
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
// Full scan with filter
|
|
120
|
-
records = tableStore.scanAll().filter(record => {
|
|
121
|
-
const value = record.fields.get(parsed.where.column);
|
|
122
|
-
return this.evaluateCondition(value, parsed.where.operator, parsed.where.value);
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
records = tableStore.scanAll();
|
|
105
|
+
let records = selectRecords(tableStore, parsed.where);
|
|
106
|
+
if (parsed.orderBy !== undefined) {
|
|
107
|
+
records = sortRecords(records, parsed.orderBy);
|
|
128
108
|
}
|
|
129
109
|
if (parsed.limit !== undefined) {
|
|
130
110
|
records = records.slice(0, parsed.limit);
|
|
@@ -153,77 +133,40 @@ export class FlatSQLDatabase {
|
|
|
153
133
|
};
|
|
154
134
|
}
|
|
155
135
|
parseSQL(sql) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
maxValue: this.parseValue(betweenMatch[3]),
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
// Parse simple condition: column op value
|
|
178
|
-
const condMatch = whereClause.match(/^(\w+)\s*(=|!=|<>|<|>|<=|>=)\s*(.+)$/);
|
|
179
|
-
if (condMatch) {
|
|
180
|
-
where = {
|
|
181
|
-
column: condMatch[1],
|
|
182
|
-
operator: condMatch[2],
|
|
183
|
-
value: this.parseValue(condMatch[3]),
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
}
|
|
136
|
+
const clauses = splitSelectStatement(sql.trim());
|
|
137
|
+
if (!clauses) {
|
|
138
|
+
// Security: Don't expose SQL in error message (could contain sensitive data)
|
|
139
|
+
throw new Error('SQL parse error: unsupported query syntax');
|
|
140
|
+
}
|
|
141
|
+
const columns = splitTopLevel(clauses.columns, ',').map(c => c.trim());
|
|
142
|
+
if (columns.length === 0 || columns.some(c => c.length === 0)) {
|
|
143
|
+
throw new Error('SQL parse error: empty column in SELECT list');
|
|
144
|
+
}
|
|
145
|
+
if (!/^\w+$/.test(clauses.table)) {
|
|
146
|
+
throw new Error('SQL parse error: unsupported FROM target');
|
|
147
|
+
}
|
|
148
|
+
let limit;
|
|
149
|
+
if (clauses.limit !== undefined) {
|
|
150
|
+
if (!/^\d+$/.test(clauses.limit.trim())) {
|
|
151
|
+
// Fail closed: an unparsed LIMIT must not silently return every row.
|
|
152
|
+
throw new Error('SQL parse error: LIMIT requires a non-negative integer');
|
|
187
153
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
// Integer (check for BigInt needs)
|
|
206
|
-
if (/^-?\d+$/.test(str)) {
|
|
207
|
-
const num = BigInt(str);
|
|
208
|
-
// Security: Use BigInt for values exceeding safe integer range
|
|
209
|
-
if (num > BigInt(Number.MAX_SAFE_INTEGER) || num < BigInt(Number.MIN_SAFE_INTEGER)) {
|
|
210
|
-
return num;
|
|
211
|
-
}
|
|
212
|
-
return Number(str);
|
|
213
|
-
}
|
|
214
|
-
// Float
|
|
215
|
-
if (/^-?\d+\.\d+$/.test(str)) {
|
|
216
|
-
return parseFloat(str);
|
|
217
|
-
}
|
|
218
|
-
// Boolean
|
|
219
|
-
if (str.toLowerCase() === 'true')
|
|
220
|
-
return true;
|
|
221
|
-
if (str.toLowerCase() === 'false')
|
|
222
|
-
return false;
|
|
223
|
-
// NULL
|
|
224
|
-
if (str.toLowerCase() === 'null')
|
|
225
|
-
return null;
|
|
226
|
-
return str;
|
|
154
|
+
limit = Number(clauses.limit.trim());
|
|
155
|
+
}
|
|
156
|
+
// Fail closed: parseWhereClause / parseOrderByClause throw on anything the
|
|
157
|
+
// grammar does not understand. Neither may fall back to "no filter".
|
|
158
|
+
const where = clauses.where === undefined ? undefined : parseWhereClause(clauses.where);
|
|
159
|
+
const orderBy = clauses.orderBy === undefined
|
|
160
|
+
? undefined
|
|
161
|
+
: parseOrderByClause(clauses.orderBy);
|
|
162
|
+
return {
|
|
163
|
+
type: 'SELECT',
|
|
164
|
+
table: clauses.table,
|
|
165
|
+
columns,
|
|
166
|
+
where,
|
|
167
|
+
orderBy,
|
|
168
|
+
limit,
|
|
169
|
+
};
|
|
227
170
|
}
|
|
228
171
|
// Security: Rate limiting enforcement
|
|
229
172
|
enforceRateLimit() {
|
|
@@ -236,25 +179,6 @@ export class FlatSQLDatabase {
|
|
|
236
179
|
}
|
|
237
180
|
this.queryTimestamps.push(now);
|
|
238
181
|
}
|
|
239
|
-
evaluateCondition(value, operator, target) {
|
|
240
|
-
switch (operator) {
|
|
241
|
-
case '=':
|
|
242
|
-
return value === target;
|
|
243
|
-
case '!=':
|
|
244
|
-
case '<>':
|
|
245
|
-
return value !== target;
|
|
246
|
-
case '<':
|
|
247
|
-
return value < target;
|
|
248
|
-
case '>':
|
|
249
|
-
return value > target;
|
|
250
|
-
case '<=':
|
|
251
|
-
return value <= target;
|
|
252
|
-
case '>=':
|
|
253
|
-
return value >= target;
|
|
254
|
-
default:
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
182
|
// Get table definition
|
|
259
183
|
getTableDef(tableName) {
|
|
260
184
|
return this.tables.get(tableName)?.getTableDef();
|
|
@@ -280,4 +204,697 @@ export class FlatSQLDatabase {
|
|
|
280
204
|
}));
|
|
281
205
|
}
|
|
282
206
|
}
|
|
207
|
+
/** Keywords that terminate the clause preceding them, in statement order. */
|
|
208
|
+
const SELECT_CLAUSE_KEYWORDS = ['FROM', 'WHERE', 'ORDER BY', 'LIMIT'];
|
|
209
|
+
/**
|
|
210
|
+
* Walk `sql` and report every top-level (unquoted, paren-depth 0) occurrence of
|
|
211
|
+
* a clause keyword. Returns undefined if the string has unbalanced quotes or
|
|
212
|
+
* parentheses, which callers treat as a parse failure.
|
|
213
|
+
*/
|
|
214
|
+
function scanTopLevel(sql, onKeyword, onDelimiter, delimiters) {
|
|
215
|
+
let depth = 0;
|
|
216
|
+
let i = 0;
|
|
217
|
+
while (i < sql.length) {
|
|
218
|
+
const ch = sql[i];
|
|
219
|
+
if (ch === "'" || ch === '"') {
|
|
220
|
+
const quote = ch;
|
|
221
|
+
i++;
|
|
222
|
+
let closed = false;
|
|
223
|
+
while (i < sql.length) {
|
|
224
|
+
if (sql[i] === quote) {
|
|
225
|
+
// Doubled quote is an escaped quote, not a terminator.
|
|
226
|
+
if (sql[i + 1] === quote) {
|
|
227
|
+
i += 2;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
i++;
|
|
231
|
+
closed = true;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
if (!closed) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (ch === '(') {
|
|
242
|
+
depth++;
|
|
243
|
+
i++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === ')') {
|
|
247
|
+
depth--;
|
|
248
|
+
if (depth < 0) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
i++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (depth === 0 && delimiters && onDelimiter && delimiters.includes(ch)) {
|
|
255
|
+
onDelimiter(ch, i);
|
|
256
|
+
i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (depth === 0 && onKeyword && isWordBoundary(sql, i)) {
|
|
260
|
+
let matched;
|
|
261
|
+
for (const keyword of SELECT_CLAUSE_KEYWORDS) {
|
|
262
|
+
const end = matchKeywordAt(sql, i, keyword);
|
|
263
|
+
if (end !== -1) {
|
|
264
|
+
matched = keyword;
|
|
265
|
+
onKeyword(keyword, i, end);
|
|
266
|
+
i = end;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (matched) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
i++;
|
|
275
|
+
}
|
|
276
|
+
return depth === 0;
|
|
277
|
+
}
|
|
278
|
+
function isWordBoundary(sql, index) {
|
|
279
|
+
if (index === 0) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return !/[A-Za-z0-9_$]/.test(sql[index - 1]);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Match `keyword` at `index` case-insensitively, allowing any run of
|
|
286
|
+
* whitespace between the words of a multi-word keyword ("ORDER BY").
|
|
287
|
+
* Returns the index just past the keyword, or -1.
|
|
288
|
+
*/
|
|
289
|
+
function matchKeywordAt(sql, index, keyword) {
|
|
290
|
+
let i = index;
|
|
291
|
+
const words = keyword.split(' ');
|
|
292
|
+
for (let w = 0; w < words.length; w++) {
|
|
293
|
+
if (w > 0) {
|
|
294
|
+
let sawSpace = false;
|
|
295
|
+
while (i < sql.length && /\s/.test(sql[i])) {
|
|
296
|
+
i++;
|
|
297
|
+
sawSpace = true;
|
|
298
|
+
}
|
|
299
|
+
if (!sawSpace) {
|
|
300
|
+
return -1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const word = words[w];
|
|
304
|
+
if (sql.slice(i, i + word.length).toUpperCase() !== word) {
|
|
305
|
+
return -1;
|
|
306
|
+
}
|
|
307
|
+
i += word.length;
|
|
308
|
+
}
|
|
309
|
+
// The keyword must be followed by a boundary, so "FROMAGE" is not "FROM".
|
|
310
|
+
if (i < sql.length && /[A-Za-z0-9_$]/.test(sql[i])) {
|
|
311
|
+
return -1;
|
|
312
|
+
}
|
|
313
|
+
return i;
|
|
314
|
+
}
|
|
315
|
+
function splitSelectStatement(sql) {
|
|
316
|
+
const selectEnd = matchKeywordAt(sql, 0, 'SELECT');
|
|
317
|
+
if (selectEnd === -1) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
const marks = [];
|
|
321
|
+
const balanced = scanTopLevel(sql, (keyword, start, end) => {
|
|
322
|
+
marks.push({ keyword, start, end });
|
|
323
|
+
});
|
|
324
|
+
if (!balanced) {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
// Each clause keyword may appear at most once, and only in statement order.
|
|
328
|
+
let previousRank = -1;
|
|
329
|
+
for (const mark of marks) {
|
|
330
|
+
const rank = SELECT_CLAUSE_KEYWORDS.indexOf(mark.keyword);
|
|
331
|
+
if (rank <= previousRank) {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
previousRank = rank;
|
|
335
|
+
}
|
|
336
|
+
if (marks.length === 0 || marks[0].keyword !== 'FROM') {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const sliceUntilNext = (markIndex) => {
|
|
340
|
+
const start = marks[markIndex].end;
|
|
341
|
+
const end = markIndex + 1 < marks.length ? marks[markIndex + 1].start : sql.length;
|
|
342
|
+
return sql.slice(start, end).trim();
|
|
343
|
+
};
|
|
344
|
+
const clauses = {
|
|
345
|
+
columns: sql.slice(selectEnd, marks[0].start).trim(),
|
|
346
|
+
table: sliceUntilNext(0),
|
|
347
|
+
};
|
|
348
|
+
if (clauses.columns.length === 0) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
for (let m = 1; m < marks.length; m++) {
|
|
352
|
+
const text = sliceUntilNext(m);
|
|
353
|
+
if (marks[m].keyword === 'WHERE') {
|
|
354
|
+
clauses.where = text;
|
|
355
|
+
}
|
|
356
|
+
else if (marks[m].keyword === 'ORDER BY') {
|
|
357
|
+
clauses.orderBy = text;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
clauses.limit = text;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return clauses;
|
|
364
|
+
}
|
|
365
|
+
/** Split on a delimiter that is outside quotes and at paren depth 0. */
|
|
366
|
+
function splitTopLevel(text, delimiter) {
|
|
367
|
+
const parts = [];
|
|
368
|
+
let last = 0;
|
|
369
|
+
const balanced = scanTopLevel(text, undefined, (_delimiter, index) => {
|
|
370
|
+
parts.push(text.slice(last, index));
|
|
371
|
+
last = index + 1;
|
|
372
|
+
}, [delimiter]);
|
|
373
|
+
if (!balanced) {
|
|
374
|
+
throw new Error('SQL parse error: unbalanced quotes or parentheses');
|
|
375
|
+
}
|
|
376
|
+
parts.push(text.slice(last));
|
|
377
|
+
return parts;
|
|
378
|
+
}
|
|
379
|
+
function tokenizeWhere(input) {
|
|
380
|
+
const tokens = [];
|
|
381
|
+
let i = 0;
|
|
382
|
+
while (i < input.length) {
|
|
383
|
+
const ch = input[i];
|
|
384
|
+
if (/\s/.test(ch)) {
|
|
385
|
+
i++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (ch === "'" || ch === '"') {
|
|
389
|
+
const quote = ch;
|
|
390
|
+
let value = '';
|
|
391
|
+
let closed = false;
|
|
392
|
+
i++;
|
|
393
|
+
while (i < input.length) {
|
|
394
|
+
if (input[i] === quote) {
|
|
395
|
+
if (input[i + 1] === quote) {
|
|
396
|
+
value += quote;
|
|
397
|
+
i += 2;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
i++;
|
|
401
|
+
closed = true;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
value += input[i];
|
|
405
|
+
i++;
|
|
406
|
+
}
|
|
407
|
+
if (!closed) {
|
|
408
|
+
throw new Error('SQL parse error: unterminated string literal in WHERE');
|
|
409
|
+
}
|
|
410
|
+
tokens.push({ type: 'string', value });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (ch === '(' || ch === ')' || ch === ',') {
|
|
414
|
+
tokens.push({ type: 'punc', value: ch });
|
|
415
|
+
i++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const two = input.slice(i, i + 2);
|
|
419
|
+
if (two === '<=' || two === '>=' || two === '<>' || two === '!=') {
|
|
420
|
+
tokens.push({ type: 'op', value: two });
|
|
421
|
+
i += 2;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (ch === '=' || ch === '<' || ch === '>') {
|
|
425
|
+
tokens.push({ type: 'op', value: ch });
|
|
426
|
+
i++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (/[0-9]/.test(ch) || (ch === '-' && /[0-9]/.test(input[i + 1] ?? ''))) {
|
|
430
|
+
let j = ch === '-' ? i + 1 : i;
|
|
431
|
+
while (j < input.length && /[0-9]/.test(input[j])) {
|
|
432
|
+
j++;
|
|
433
|
+
}
|
|
434
|
+
let isFloat = false;
|
|
435
|
+
if (input[j] === '.' && /[0-9]/.test(input[j + 1] ?? '')) {
|
|
436
|
+
isFloat = true;
|
|
437
|
+
j++;
|
|
438
|
+
while (j < input.length && /[0-9]/.test(input[j])) {
|
|
439
|
+
j++;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if ((input[j] === 'e' || input[j] === 'E') && /[0-9+-]/.test(input[j + 1] ?? '')) {
|
|
443
|
+
isFloat = true;
|
|
444
|
+
j += 2;
|
|
445
|
+
while (j < input.length && /[0-9]/.test(input[j])) {
|
|
446
|
+
j++;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const raw = input.slice(i, j);
|
|
450
|
+
if (isFloat) {
|
|
451
|
+
tokens.push({ type: 'number', value: parseFloat(raw) });
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
// Security: preserve exactness beyond the safe integer range.
|
|
455
|
+
const asBigInt = BigInt(raw);
|
|
456
|
+
const exceedsSafe = asBigInt > BigInt(Number.MAX_SAFE_INTEGER) ||
|
|
457
|
+
asBigInt < BigInt(Number.MIN_SAFE_INTEGER);
|
|
458
|
+
tokens.push({ type: 'number', value: exceedsSafe ? asBigInt : Number(raw) });
|
|
459
|
+
}
|
|
460
|
+
i = j;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
464
|
+
let j = i;
|
|
465
|
+
while (j < input.length && /[A-Za-z0-9_$.]/.test(input[j])) {
|
|
466
|
+
j++;
|
|
467
|
+
}
|
|
468
|
+
tokens.push({ type: 'word', value: input.slice(i, j) });
|
|
469
|
+
i = j;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
// Fail closed. An unrecognised character is never ignored.
|
|
473
|
+
throw new Error('SQL parse error: unexpected character in WHERE clause');
|
|
474
|
+
}
|
|
475
|
+
return tokens;
|
|
476
|
+
}
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// WHERE parser (recursive descent)
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
class WhereParser {
|
|
481
|
+
tokens;
|
|
482
|
+
pos = 0;
|
|
483
|
+
constructor(tokens) {
|
|
484
|
+
this.tokens = tokens;
|
|
485
|
+
}
|
|
486
|
+
parse() {
|
|
487
|
+
const node = this.parseOr();
|
|
488
|
+
if (this.pos < this.tokens.length) {
|
|
489
|
+
throw new Error(`SQL parse error: unexpected token after WHERE expression: ${this.describe(this.peek())}`);
|
|
490
|
+
}
|
|
491
|
+
return node;
|
|
492
|
+
}
|
|
493
|
+
peek() {
|
|
494
|
+
return this.tokens[this.pos];
|
|
495
|
+
}
|
|
496
|
+
describe(token) {
|
|
497
|
+
if (!token) {
|
|
498
|
+
return 'end of clause';
|
|
499
|
+
}
|
|
500
|
+
// Security: never echo literal values; identifiers and operators are
|
|
501
|
+
// schema-level names, which the engine already surfaces in other errors.
|
|
502
|
+
switch (token.type) {
|
|
503
|
+
case 'word':
|
|
504
|
+
return token.value;
|
|
505
|
+
case 'op':
|
|
506
|
+
case 'punc':
|
|
507
|
+
return token.value;
|
|
508
|
+
case 'string':
|
|
509
|
+
return 'string literal';
|
|
510
|
+
case 'number':
|
|
511
|
+
return 'numeric literal';
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** Consume the next token if it is the given keyword. */
|
|
515
|
+
acceptKeyword(keyword) {
|
|
516
|
+
const token = this.peek();
|
|
517
|
+
if (token && token.type === 'word' && token.value.toUpperCase() === keyword) {
|
|
518
|
+
this.pos++;
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
expectKeyword(keyword) {
|
|
524
|
+
if (!this.acceptKeyword(keyword)) {
|
|
525
|
+
throw new Error(`SQL parse error: expected ${keyword}, found ${this.describe(this.peek())}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
acceptPunc(value) {
|
|
529
|
+
const token = this.peek();
|
|
530
|
+
if (token && token.type === 'punc' && token.value === value) {
|
|
531
|
+
this.pos++;
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
expectPunc(value) {
|
|
537
|
+
if (!this.acceptPunc(value)) {
|
|
538
|
+
throw new Error(`SQL parse error: expected '${value}', found ${this.describe(this.peek())}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
parseOr() {
|
|
542
|
+
const children = [this.parseAnd()];
|
|
543
|
+
while (this.acceptKeyword('OR')) {
|
|
544
|
+
children.push(this.parseAnd());
|
|
545
|
+
}
|
|
546
|
+
return children.length === 1 ? children[0] : { kind: 'or', children };
|
|
547
|
+
}
|
|
548
|
+
parseAnd() {
|
|
549
|
+
const children = [this.parseNot()];
|
|
550
|
+
while (this.acceptKeyword('AND')) {
|
|
551
|
+
children.push(this.parseNot());
|
|
552
|
+
}
|
|
553
|
+
return children.length === 1 ? children[0] : { kind: 'and', children };
|
|
554
|
+
}
|
|
555
|
+
parseNot() {
|
|
556
|
+
if (this.acceptKeyword('NOT')) {
|
|
557
|
+
return { kind: 'not', child: this.parseNot() };
|
|
558
|
+
}
|
|
559
|
+
return this.parsePrimary();
|
|
560
|
+
}
|
|
561
|
+
parsePrimary() {
|
|
562
|
+
if (this.acceptPunc('(')) {
|
|
563
|
+
const node = this.parseOr();
|
|
564
|
+
this.expectPunc(')');
|
|
565
|
+
return node;
|
|
566
|
+
}
|
|
567
|
+
return this.parsePredicate();
|
|
568
|
+
}
|
|
569
|
+
parsePredicate() {
|
|
570
|
+
const token = this.peek();
|
|
571
|
+
if (!token || token.type !== 'word') {
|
|
572
|
+
throw new Error(`SQL parse error: expected a column name, found ${this.describe(token)}`);
|
|
573
|
+
}
|
|
574
|
+
const upper = token.value.toUpperCase();
|
|
575
|
+
if (upper === 'AND' || upper === 'OR' || upper === 'NOT') {
|
|
576
|
+
throw new Error(`SQL parse error: expected a column name, found ${upper}`);
|
|
577
|
+
}
|
|
578
|
+
const column = token.value;
|
|
579
|
+
this.pos++;
|
|
580
|
+
if (this.acceptKeyword('IS')) {
|
|
581
|
+
const negated = this.acceptKeyword('NOT');
|
|
582
|
+
this.expectKeyword('NULL');
|
|
583
|
+
return { kind: 'isNull', column, negated };
|
|
584
|
+
}
|
|
585
|
+
const negated = this.acceptKeyword('NOT');
|
|
586
|
+
if (this.acceptKeyword('BETWEEN')) {
|
|
587
|
+
const minValue = this.parseLiteral();
|
|
588
|
+
this.expectKeyword('AND');
|
|
589
|
+
const maxValue = this.parseLiteral();
|
|
590
|
+
return { kind: 'between', column, minValue, maxValue, negated };
|
|
591
|
+
}
|
|
592
|
+
if (this.acceptKeyword('IN')) {
|
|
593
|
+
this.expectPunc('(');
|
|
594
|
+
const values = [];
|
|
595
|
+
if (!this.acceptPunc(')')) {
|
|
596
|
+
values.push(this.parseLiteral());
|
|
597
|
+
while (this.acceptPunc(',')) {
|
|
598
|
+
values.push(this.parseLiteral());
|
|
599
|
+
}
|
|
600
|
+
this.expectPunc(')');
|
|
601
|
+
}
|
|
602
|
+
return { kind: 'in', column, values, negated };
|
|
603
|
+
}
|
|
604
|
+
if (this.acceptKeyword('LIKE')) {
|
|
605
|
+
const pattern = this.parseLiteral();
|
|
606
|
+
if (typeof pattern !== 'string') {
|
|
607
|
+
throw new Error('SQL parse error: LIKE requires a string pattern');
|
|
608
|
+
}
|
|
609
|
+
return { kind: 'like', column, matcher: likeToRegExp(pattern), negated };
|
|
610
|
+
}
|
|
611
|
+
if (negated) {
|
|
612
|
+
throw new Error(`SQL parse error: expected BETWEEN, IN or LIKE after NOT, found ${this.describe(this.peek())}`);
|
|
613
|
+
}
|
|
614
|
+
const opToken = this.peek();
|
|
615
|
+
if (!opToken || opToken.type !== 'op') {
|
|
616
|
+
throw new Error(`SQL parse error: expected a comparison operator, found ${this.describe(opToken)}`);
|
|
617
|
+
}
|
|
618
|
+
this.pos++;
|
|
619
|
+
return { kind: 'cmp', column, operator: opToken.value, value: this.parseLiteral() };
|
|
620
|
+
}
|
|
621
|
+
parseLiteral() {
|
|
622
|
+
const token = this.peek();
|
|
623
|
+
if (!token) {
|
|
624
|
+
throw new Error('SQL parse error: expected a value, found end of clause');
|
|
625
|
+
}
|
|
626
|
+
if (token.type === 'string' || token.type === 'number') {
|
|
627
|
+
this.pos++;
|
|
628
|
+
return token.value;
|
|
629
|
+
}
|
|
630
|
+
if (token.type === 'word') {
|
|
631
|
+
const upper = token.value.toUpperCase();
|
|
632
|
+
if (upper === 'TRUE') {
|
|
633
|
+
this.pos++;
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
if (upper === 'FALSE') {
|
|
637
|
+
this.pos++;
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
if (upper === 'NULL') {
|
|
641
|
+
this.pos++;
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Bare identifiers are not values: column-to-column comparison is not
|
|
646
|
+
// supported, and silently treating one as a string would be a wrong answer.
|
|
647
|
+
throw new Error(`SQL parse error: expected a literal value, found ${this.describe(token)}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function parseWhereClause(clause) {
|
|
651
|
+
const trimmed = clause.trim();
|
|
652
|
+
if (trimmed.length === 0) {
|
|
653
|
+
throw new Error('SQL parse error: empty WHERE clause');
|
|
654
|
+
}
|
|
655
|
+
return new WhereParser(tokenizeWhere(trimmed)).parse();
|
|
656
|
+
}
|
|
657
|
+
function parseOrderByClause(clause) {
|
|
658
|
+
const terms = splitTopLevel(clause, ',')
|
|
659
|
+
.map(part => part.trim())
|
|
660
|
+
.filter(part => part.length > 0);
|
|
661
|
+
if (terms.length === 0) {
|
|
662
|
+
throw new Error('SQL parse error: empty ORDER BY clause');
|
|
663
|
+
}
|
|
664
|
+
return terms.map(term => {
|
|
665
|
+
const match = term.match(/^([A-Za-z_][A-Za-z0-9_$.]*)(?:\s+(ASC|DESC))?$/i);
|
|
666
|
+
if (!match) {
|
|
667
|
+
// Fail closed: an ORDER BY the engine cannot honour is an error, not an
|
|
668
|
+
// unordered result set.
|
|
669
|
+
throw new Error('SQL parse error: unsupported ORDER BY term');
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
column: match[1],
|
|
673
|
+
descending: (match[2] ?? '').toUpperCase() === 'DESC',
|
|
674
|
+
};
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Translate a SQL LIKE pattern into an anchored RegExp.
|
|
679
|
+
*
|
|
680
|
+
* `%` matches any run of characters, `_` matches exactly one. Matching is ASCII
|
|
681
|
+
* case-insensitive, the SQLite default. Every other character is escaped, so a
|
|
682
|
+
* pattern can never inject regex syntax, and runs of `%` are collapsed so the
|
|
683
|
+
* compiled expression cannot backtrack pathologically.
|
|
684
|
+
*/
|
|
685
|
+
function likeToRegExp(pattern) {
|
|
686
|
+
let source = '^';
|
|
687
|
+
let previousWasWildcard = false;
|
|
688
|
+
for (const ch of pattern) {
|
|
689
|
+
if (ch === '%') {
|
|
690
|
+
if (!previousWasWildcard) {
|
|
691
|
+
source += '[\\s\\S]*';
|
|
692
|
+
previousWasWildcard = true;
|
|
693
|
+
}
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
previousWasWildcard = false;
|
|
697
|
+
if (ch === '_') {
|
|
698
|
+
source += '[\\s\\S]';
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
source += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return new RegExp(`${source}$`, 'i');
|
|
705
|
+
}
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// Evaluation
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
function getRecordValue(record, column) {
|
|
710
|
+
if (column === '_rowid') {
|
|
711
|
+
return record.rowid;
|
|
712
|
+
}
|
|
713
|
+
if (column === '_offset') {
|
|
714
|
+
return record.offset;
|
|
715
|
+
}
|
|
716
|
+
return record.fields.get(column);
|
|
717
|
+
}
|
|
718
|
+
function isMissing(value) {
|
|
719
|
+
return value === null || value === undefined;
|
|
720
|
+
}
|
|
721
|
+
/** Numeric-aware equality that bridges the BigInt/Number split in stored fields. */
|
|
722
|
+
function valuesEqual(a, b) {
|
|
723
|
+
if (typeof a === 'bigint' || typeof b === 'bigint') {
|
|
724
|
+
if (typeof a === 'number' && !Number.isInteger(a)) {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
if (typeof b === 'number' && !Number.isInteger(b)) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
if ((typeof a === 'bigint' || typeof a === 'number') &&
|
|
731
|
+
(typeof b === 'bigint' || typeof b === 'number')) {
|
|
732
|
+
return BigInt(a) === BigInt(b);
|
|
733
|
+
}
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
return a === b;
|
|
737
|
+
}
|
|
738
|
+
/** Ordering comparison; returns undefined when the values are not comparable. */
|
|
739
|
+
function compareValues(a, b) {
|
|
740
|
+
if (isMissing(a) || isMissing(b)) {
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
if (typeof a === 'bigint' || typeof b === 'bigint') {
|
|
744
|
+
if ((typeof a === 'bigint' || typeof a === 'number') &&
|
|
745
|
+
(typeof b === 'bigint' || typeof b === 'number')) {
|
|
746
|
+
const left = typeof a === 'bigint' ? a : BigInt(Math.trunc(a));
|
|
747
|
+
const right = typeof b === 'bigint' ? b : BigInt(Math.trunc(b));
|
|
748
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
749
|
+
}
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
753
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
754
|
+
}
|
|
755
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
756
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
757
|
+
}
|
|
758
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
759
|
+
return a === b ? 0 : a ? 1 : -1;
|
|
760
|
+
}
|
|
761
|
+
return undefined;
|
|
762
|
+
}
|
|
763
|
+
function evaluateWhere(record, node) {
|
|
764
|
+
switch (node.kind) {
|
|
765
|
+
case 'and':
|
|
766
|
+
return node.children.every(child => evaluateWhere(record, child));
|
|
767
|
+
case 'or':
|
|
768
|
+
return node.children.some(child => evaluateWhere(record, child));
|
|
769
|
+
case 'not':
|
|
770
|
+
return !evaluateWhere(record, node.child);
|
|
771
|
+
case 'isNull': {
|
|
772
|
+
const missing = isMissing(getRecordValue(record, node.column));
|
|
773
|
+
return node.negated ? !missing : missing;
|
|
774
|
+
}
|
|
775
|
+
case 'cmp': {
|
|
776
|
+
const value = getRecordValue(record, node.column);
|
|
777
|
+
if (node.operator === '=') {
|
|
778
|
+
return valuesEqual(value, node.value);
|
|
779
|
+
}
|
|
780
|
+
if (node.operator === '!=' || node.operator === '<>') {
|
|
781
|
+
// NULL is not "not equal" to anything, matching SQL's NULL semantics
|
|
782
|
+
// closely enough that a filtered result never includes unknowns.
|
|
783
|
+
if (isMissing(value)) {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
return !valuesEqual(value, node.value);
|
|
787
|
+
}
|
|
788
|
+
const ordering = compareValues(value, node.value);
|
|
789
|
+
if (ordering === undefined) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
switch (node.operator) {
|
|
793
|
+
case '<':
|
|
794
|
+
return ordering < 0;
|
|
795
|
+
case '>':
|
|
796
|
+
return ordering > 0;
|
|
797
|
+
case '<=':
|
|
798
|
+
return ordering <= 0;
|
|
799
|
+
case '>=':
|
|
800
|
+
return ordering >= 0;
|
|
801
|
+
}
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
case 'between': {
|
|
805
|
+
const value = getRecordValue(record, node.column);
|
|
806
|
+
const low = compareValues(value, node.minValue);
|
|
807
|
+
const high = compareValues(value, node.maxValue);
|
|
808
|
+
if (low === undefined || high === undefined) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
const inRange = low >= 0 && high <= 0;
|
|
812
|
+
return node.negated ? !inRange : inRange;
|
|
813
|
+
}
|
|
814
|
+
case 'in': {
|
|
815
|
+
const value = getRecordValue(record, node.column);
|
|
816
|
+
if (isMissing(value)) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
const found = node.values.some(candidate => valuesEqual(value, candidate));
|
|
820
|
+
return node.negated ? !found : found;
|
|
821
|
+
}
|
|
822
|
+
case 'like': {
|
|
823
|
+
const value = getRecordValue(record, node.column);
|
|
824
|
+
if (isMissing(value)) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
const matched = node.matcher.test(String(value));
|
|
828
|
+
return node.negated ? !matched : matched;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
// Planning
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
/**
|
|
836
|
+
* Pick an indexed seek that is guaranteed to be a superset of the rows the
|
|
837
|
+
* predicate can match. Only conjunctive (AND) context is safe: under OR, a row
|
|
838
|
+
* may match through the other branch, so the seek would lose rows.
|
|
839
|
+
*/
|
|
840
|
+
function planIndexSeek(tableStore, node) {
|
|
841
|
+
if (node.kind === 'cmp' && node.operator === '=' && tableStore.hasIndex(node.column)) {
|
|
842
|
+
return { records: tableStore.findByIndex(node.column, node.value), exact: true };
|
|
843
|
+
}
|
|
844
|
+
if (node.kind === 'between' && !node.negated && tableStore.hasIndex(node.column)) {
|
|
845
|
+
return {
|
|
846
|
+
records: tableStore.findByRange(node.column, node.minValue, node.maxValue),
|
|
847
|
+
exact: true,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
if (node.kind === 'and') {
|
|
851
|
+
for (const child of node.children) {
|
|
852
|
+
const seek = planIndexSeek(tableStore, child);
|
|
853
|
+
if (seek) {
|
|
854
|
+
// The remaining conjuncts still have to be checked.
|
|
855
|
+
return { records: seek.records, exact: false };
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
function selectRecords(tableStore, where) {
|
|
862
|
+
if (!where) {
|
|
863
|
+
return tableStore.scanAll();
|
|
864
|
+
}
|
|
865
|
+
const seek = planIndexSeek(tableStore, where);
|
|
866
|
+
if (seek?.exact) {
|
|
867
|
+
return seek.records;
|
|
868
|
+
}
|
|
869
|
+
const candidates = seek ? seek.records : tableStore.scanAll();
|
|
870
|
+
return candidates.filter(record => evaluateWhere(record, where));
|
|
871
|
+
}
|
|
872
|
+
function sortRecords(records, terms) {
|
|
873
|
+
// Decorate with the original position so the sort is stable across engines.
|
|
874
|
+
return records
|
|
875
|
+
.map((record, index) => ({ record, index }))
|
|
876
|
+
.sort((left, right) => {
|
|
877
|
+
for (const term of terms) {
|
|
878
|
+
const a = getRecordValue(left.record, term.column);
|
|
879
|
+
const b = getRecordValue(right.record, term.column);
|
|
880
|
+
// SQLite orders NULL first ascending; mirror that.
|
|
881
|
+
const aMissing = isMissing(a);
|
|
882
|
+
const bMissing = isMissing(b);
|
|
883
|
+
if (aMissing || bMissing) {
|
|
884
|
+
if (aMissing && bMissing) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const nullsFirst = aMissing ? -1 : 1;
|
|
888
|
+
return term.descending ? -nullsFirst : nullsFirst;
|
|
889
|
+
}
|
|
890
|
+
const ordering = compareValues(a, b);
|
|
891
|
+
if (ordering === undefined || ordering === 0) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
return term.descending ? -ordering : ordering;
|
|
895
|
+
}
|
|
896
|
+
return left.index - right.index;
|
|
897
|
+
})
|
|
898
|
+
.map(entry => entry.record);
|
|
899
|
+
}
|
|
283
900
|
//# sourceMappingURL=database.js.map
|