flatsql 1.1.0 → 1.4.4

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.
@@ -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
- // Supports: SELECT columns FROM table WHERE column = value
74
- // SELECT columns FROM table WHERE column BETWEEN min AND max
75
- // SELECT * FROM table
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.where) {
95
- if (parsed.where.operator === '=') {
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 normalized = sql.trim().replace(/\s+/g, ' ');
157
- // SELECT query
158
- const selectMatch = normalized.match(/^SELECT\s+(.+?)\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?(?:\s+LIMIT\s+(\d+))?$/i);
159
- if (selectMatch) {
160
- const columns = selectMatch[1].split(',').map(c => c.trim());
161
- const table = selectMatch[2];
162
- const whereClause = selectMatch[3];
163
- const limit = selectMatch[5] === undefined ? undefined : Number(selectMatch[5]);
164
- let where;
165
- if (whereClause) {
166
- // Parse BETWEEN
167
- const betweenMatch = whereClause.match(/^(\w+)\s+BETWEEN\s+(.+?)\s+AND\s+(.+)$/i);
168
- if (betweenMatch) {
169
- where = {
170
- column: betweenMatch[1],
171
- operator: 'BETWEEN',
172
- minValue: this.parseValue(betweenMatch[2]),
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
- return {
189
- type: 'SELECT',
190
- table,
191
- columns,
192
- where,
193
- limit,
194
- };
195
- }
196
- // Security: Don't expose SQL in error message (could contain sensitive data)
197
- throw new Error('SQL parse error: unsupported query syntax');
198
- }
199
- parseValue(str) {
200
- str = str.trim();
201
- // String literal
202
- if ((str.startsWith("'") && str.endsWith("'")) || (str.startsWith('"') && str.endsWith('"'))) {
203
- return str.slice(1, -1);
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