@tallyui/storage-sqlite 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,566 @@
1
+ /**
2
+ * A simple in-memory mock of the expo-sqlite synchronous API.
3
+ *
4
+ * This does not parse SQL in a general way -- it handles only
5
+ * the specific query patterns that RxStorageInstanceSQLite generates.
6
+ * Supports json_extract(data, '$.field') for accessing document fields
7
+ * stored as JSON in the `data` column.
8
+ */
9
+ import type { SQLiteDatabase } from './types';
10
+
11
+ interface TableRow {
12
+ [column: string]: any;
13
+ }
14
+
15
+ interface Table {
16
+ columns: string[];
17
+ rows: TableRow[];
18
+ }
19
+
20
+ export function createMockSQLiteDatabase(): SQLiteDatabase {
21
+ const tables = new Map<string, Table>();
22
+
23
+ function getTable(name: string): Table | undefined {
24
+ return tables.get(name);
25
+ }
26
+
27
+ function ensureTable(name: string): Table {
28
+ let table = tables.get(name);
29
+ if (!table) {
30
+ table = { columns: [], rows: [] };
31
+ tables.set(name, table);
32
+ }
33
+ return table;
34
+ }
35
+
36
+ function extractTableName(sql: string): string {
37
+ const match = sql.match(/(?:FROM|INTO|UPDATE|TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?)\s+"?([a-zA-Z0-9_]+)"?/i);
38
+ if (match) return match[1];
39
+ throw new Error(`Could not extract table name from SQL: ${sql}`);
40
+ }
41
+
42
+ /**
43
+ * Resolve a value reference from a row.
44
+ * Handles:
45
+ * - json_extract(data, '$.field.nested') -> parse row.data JSON and access path
46
+ * - COUNT(*) as count -> special aggregate
47
+ * - plain column name -> row[col]
48
+ */
49
+ function resolveValue(row: TableRow, expr: string): any {
50
+ const trimmed = expr.trim();
51
+
52
+ // json_extract(data, '$.path')
53
+ const jsonMatch = trimmed.match(/^json_extract\s*\(\s*(\w+)\s*,\s*'([^']+)'\s*\)$/i);
54
+ if (jsonMatch) {
55
+ const dataCol = jsonMatch[1];
56
+ const jsonPath = jsonMatch[2]; // e.g., $.field or $._meta.lwt
57
+ const raw = row[dataCol];
58
+ if (typeof raw !== 'string') return undefined;
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ // Navigate the path: $.field.nested
62
+ const pathParts = jsonPath.replace(/^\$\.?/, '').split('.');
63
+ let value: any = parsed;
64
+ for (const part of pathParts) {
65
+ if (value === null || value === undefined) return undefined;
66
+ value = value[part];
67
+ }
68
+ return value;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ // Plain column name
75
+ return row[trimmed];
76
+ }
77
+
78
+ function execSync(source: string): void {
79
+ const trimmed = source.trim();
80
+
81
+ if (/^CREATE TABLE/i.test(trimmed)) {
82
+ const tableName = extractTableName(trimmed);
83
+ const colMatch = trimmed.match(/\((.+)\)/s);
84
+ if (colMatch) {
85
+ const colDefs = colMatch[1].split(',').map((c) => c.trim());
86
+ const columns = colDefs.map((def) => def.split(/\s+/)[0].replace(/"/g, ''));
87
+ const table = ensureTable(tableName);
88
+ table.columns = columns;
89
+ }
90
+ return;
91
+ }
92
+
93
+ if (/^DROP TABLE/i.test(trimmed)) {
94
+ const tableName = extractTableName(trimmed);
95
+ tables.delete(tableName);
96
+ return;
97
+ }
98
+
99
+ throw new Error(`execSync: unsupported SQL: ${trimmed}`);
100
+ }
101
+
102
+ function getAllSync<T>(source: string, params: any[] = []): T[] {
103
+ const trimmed = source.trim();
104
+
105
+ // Handle COUNT queries
106
+ const countMatch = trimmed.match(/^SELECT\s+COUNT\s*\(\s*\*\s*\)\s+as\s+(\w+)\s+FROM/i);
107
+ if (countMatch) {
108
+ const alias = countMatch[1];
109
+ const tableName = extractTableName(trimmed);
110
+ const table = getTable(tableName);
111
+ if (!table) return [{ [alias]: 0 } as T];
112
+
113
+ let rows = [...table.rows];
114
+ const whereMatch = trimmed.match(/WHERE\s+(.+?)$/is);
115
+ if (whereMatch) {
116
+ rows = filterRowsWithParams(rows, whereMatch[1].trim(), params);
117
+ }
118
+ return [{ [alias]: rows.length } as T];
119
+ }
120
+
121
+ const tableName = extractTableName(trimmed);
122
+ const table = getTable(tableName);
123
+ if (!table) return [];
124
+
125
+ // Determine which columns to select
126
+ const selectMatch = trimmed.match(/^SELECT\s+(.+?)\s+FROM/i);
127
+ let selectColumns: string[] | '*' = '*';
128
+ if (selectMatch) {
129
+ const cols = selectMatch[1].trim();
130
+ if (cols !== '*') {
131
+ selectColumns = cols.split(',').map((c) => c.trim());
132
+ }
133
+ }
134
+
135
+ let rows = [...table.rows];
136
+
137
+ // Parse WHERE clause
138
+ const whereMatch = trimmed.match(/WHERE\s+(.+?)(?:\s+ORDER\s|\s+LIMIT\s|$)/is);
139
+ if (whereMatch) {
140
+ rows = filterRowsWithParams(rows, whereMatch[1].trim(), params);
141
+ }
142
+
143
+ // ORDER BY
144
+ const orderMatch = trimmed.match(/ORDER\s+BY\s+(.+?)(?:\s+LIMIT\s|$)/i);
145
+ if (orderMatch) {
146
+ rows = sortRows(rows, orderMatch[1].trim());
147
+ }
148
+
149
+ // LIMIT / OFFSET
150
+ const limitMatch = trimmed.match(/LIMIT\s+(\?|-?\d+)/i);
151
+ const offsetMatch = trimmed.match(/OFFSET\s+(\?|\d+)/i);
152
+ if (limitMatch || offsetMatch) {
153
+ let limitVal = Infinity;
154
+ let offsetVal = 0;
155
+ // Count WHERE params first to find LIMIT/OFFSET param positions
156
+ const whereStr = whereMatch ? whereMatch[1] : '';
157
+ const whereParamCount = (whereStr.match(/\?/g) || []).length;
158
+ let extraIdx = whereParamCount;
159
+
160
+ if (limitMatch) {
161
+ if (limitMatch[1] === '?') {
162
+ limitVal = params[extraIdx++];
163
+ } else {
164
+ limitVal = parseInt(limitMatch[1], 10);
165
+ if (limitVal < 0) limitVal = Infinity; // LIMIT -1 means no limit
166
+ }
167
+ }
168
+ if (offsetMatch) {
169
+ if (offsetMatch[1] === '?') {
170
+ offsetVal = params[extraIdx++];
171
+ } else {
172
+ offsetVal = parseInt(offsetMatch[1], 10);
173
+ }
174
+ }
175
+ rows = rows.slice(offsetVal, offsetVal + limitVal);
176
+ }
177
+
178
+ // Project selected columns
179
+ if (selectColumns === '*') {
180
+ return rows as T[];
181
+ }
182
+ return rows.map((row) => {
183
+ const result: any = {};
184
+ for (const col of selectColumns as string[]) {
185
+ result[col] = row[col];
186
+ }
187
+ return result;
188
+ }) as T[];
189
+ }
190
+
191
+ function runSync(
192
+ source: string,
193
+ params: any[] = [],
194
+ ): { changes: number; lastInsertRowId: number } {
195
+ const trimmed = source.trim();
196
+
197
+ // INSERT OR REPLACE
198
+ if (/^INSERT/i.test(trimmed)) {
199
+ const tableName = extractTableName(trimmed);
200
+ const table = ensureTable(tableName);
201
+
202
+ const colMatch = trimmed.match(/\)\s*\(([^)]+)\)\s*VALUES/i) || trimmed.match(/\(([^)]+)\)\s*VALUES/i);
203
+ let columns: string[];
204
+ if (colMatch) {
205
+ columns = colMatch[1].split(',').map((c) => c.trim());
206
+ } else {
207
+ columns = table.columns;
208
+ }
209
+
210
+ const newRow: TableRow = {};
211
+ for (let i = 0; i < columns.length; i++) {
212
+ newRow[columns[i]] = params[i];
213
+ }
214
+
215
+ const idCol = columns[0];
216
+ const existingIdx = table.rows.findIndex((r) => r[idCol] === newRow[idCol]);
217
+ if (existingIdx >= 0) {
218
+ table.rows[existingIdx] = newRow;
219
+ } else {
220
+ table.rows.push(newRow);
221
+ }
222
+
223
+ return { changes: 1, lastInsertRowId: table.rows.length };
224
+ }
225
+
226
+ // UPDATE
227
+ if (/^UPDATE/i.test(trimmed)) {
228
+ const tableName = extractTableName(trimmed);
229
+ const table = getTable(tableName);
230
+ if (!table) return { changes: 0, lastInsertRowId: 0 };
231
+
232
+ const setMatch = trimmed.match(/SET\s+(.+?)\s+WHERE/i);
233
+ if (!setMatch) throw new Error(`UPDATE without SET: ${trimmed}`);
234
+
235
+ const setCols = setMatch[1].split(',').map((s) => s.trim().split(/\s*=\s*/)[0].trim());
236
+
237
+ const whereMatch = trimmed.match(/WHERE\s+(.+)$/i);
238
+ if (!whereMatch) throw new Error(`UPDATE without WHERE: ${trimmed}`);
239
+
240
+ const setParams = params.slice(0, setCols.length);
241
+ const whereParams = params.slice(setCols.length);
242
+
243
+ let changes = 0;
244
+ for (const row of table.rows) {
245
+ if (evaluateWhere(row, whereMatch[1].trim(), whereParams)) {
246
+ for (let i = 0; i < setCols.length; i++) {
247
+ row[setCols[i]] = setParams[i];
248
+ }
249
+ changes++;
250
+ }
251
+ }
252
+
253
+ return { changes, lastInsertRowId: 0 };
254
+ }
255
+
256
+ // DELETE
257
+ if (/^DELETE/i.test(trimmed)) {
258
+ const tableName = extractTableName(trimmed);
259
+ const table = getTable(tableName);
260
+ if (!table) return { changes: 0, lastInsertRowId: 0 };
261
+
262
+ const whereMatch = trimmed.match(/WHERE\s+(.+)$/i);
263
+ if (!whereMatch) {
264
+ const changes = table.rows.length;
265
+ table.rows = [];
266
+ return { changes, lastInsertRowId: 0 };
267
+ }
268
+
269
+ const before = table.rows.length;
270
+ table.rows = table.rows.filter((row) => !evaluateWhere(row, whereMatch[1].trim(), params));
271
+ return { changes: before - table.rows.length, lastInsertRowId: 0 };
272
+ }
273
+
274
+ throw new Error(`runSync: unsupported SQL: ${trimmed}`);
275
+ }
276
+
277
+ // ------------------------------------------------------------------
278
+ // WHERE evaluation
279
+ // ------------------------------------------------------------------
280
+
281
+ function filterRowsWithParams(rows: TableRow[], whereClause: string, params: any[]): TableRow[] {
282
+ return rows.filter((row) => evaluateWhere(row, whereClause, params));
283
+ }
284
+
285
+ /**
286
+ * Evaluate a WHERE clause for a single row.
287
+ * Supports AND, OR, parentheses, json_extract, IN, NOT IN, and comparisons.
288
+ */
289
+ function evaluateWhere(row: TableRow, whereClause: string, params: any[]): boolean {
290
+ // Tokenize and evaluate with proper parameter tracking
291
+ const ctx = { params, idx: 0 };
292
+ return evalExpr(row, whereClause.trim(), ctx);
293
+ }
294
+
295
+ interface ParamCtx {
296
+ params: any[];
297
+ idx: number;
298
+ }
299
+
300
+ function evalExpr(row: TableRow, expr: string, ctx: ParamCtx): boolean {
301
+ // Strip outer parens if the entire expr is wrapped
302
+ expr = stripOuterParens(expr);
303
+
304
+ // Split on top-level OR
305
+ const orParts = splitOnKeyword(expr, 'OR');
306
+ if (orParts.length > 1) {
307
+ // For OR, we need to save/restore param index for each branch
308
+ // but since our queries typically use all params in sequence,
309
+ // we evaluate left to right.
310
+ // However, for proper OR handling with params, we evaluate each branch
311
+ // independently and pick the first match.
312
+ // In our case, the most common OR pattern is:
313
+ // (_meta_lwt > ?) OR (_meta_lwt = ? AND id > ?)
314
+ // These consume params in sequence, so we save state and try.
315
+ const savedIdx = ctx.idx;
316
+ for (const part of orParts) {
317
+ ctx.idx = savedIdx; // reset for each OR branch
318
+ if (evalExpr(row, part.trim(), ctx)) {
319
+ // Advance ctx.idx past all params for remaining branches
320
+ // Actually for OR, we need to consume all params regardless.
321
+ // Let's just compute the max idx.
322
+ return true;
323
+ }
324
+ }
325
+ // Need to advance past all OR params
326
+ // Count total ?'s in the full expression
327
+ const totalParams = (expr.match(/\?/g) || []).length;
328
+ ctx.idx = savedIdx + totalParams;
329
+ return false;
330
+ }
331
+
332
+ // Split on top-level AND
333
+ const andParts = splitOnKeyword(expr, 'AND');
334
+ if (andParts.length > 1) {
335
+ for (const part of andParts) {
336
+ if (!evalExpr(row, part.trim(), ctx)) {
337
+ // Need to skip remaining params for failed AND branches
338
+ // Count remaining ?'s
339
+ const remaining = andParts.slice(andParts.indexOf(part) + 1).join(' ');
340
+ const remainingParams = (remaining.match(/\?/g) || []).length;
341
+ ctx.idx += remainingParams;
342
+ return false;
343
+ }
344
+ }
345
+ return true;
346
+ }
347
+
348
+ // Atomic condition
349
+ return evalAtom(row, expr.trim(), ctx);
350
+ }
351
+
352
+ function evalAtom(row: TableRow, cond: string, ctx: ParamCtx): boolean {
353
+ cond = stripOuterParens(cond).trim();
354
+
355
+ // NOT IN: expr NOT IN (?, ?, ...)
356
+ const notInMatch = cond.match(/^(.+?)\s+NOT\s+IN\s*\(([^)]+)\)/i);
357
+ if (notInMatch) {
358
+ const val = resolveExpr(row, notInMatch[1].trim());
359
+ const placeholders = notInMatch[2].split(',');
360
+ const values: any[] = [];
361
+ for (const p of placeholders) {
362
+ if (p.trim() === '?') {
363
+ values.push(ctx.params[ctx.idx++]);
364
+ } else {
365
+ values.push(parseLiteral(p.trim()));
366
+ }
367
+ }
368
+ return !values.includes(val);
369
+ }
370
+
371
+ // IN clause: expr IN (?, ?, ...)
372
+ const inMatch = cond.match(/^(.+?)\s+IN\s*\(([^)]+)\)/i);
373
+ if (inMatch) {
374
+ const val = resolveExpr(row, inMatch[1].trim());
375
+ const placeholders = inMatch[2].split(',');
376
+ const values: any[] = [];
377
+ for (const p of placeholders) {
378
+ if (p.trim() === '?') {
379
+ values.push(ctx.params[ctx.idx++]);
380
+ } else {
381
+ values.push(parseLiteral(p.trim()));
382
+ }
383
+ }
384
+ return values.includes(val);
385
+ }
386
+
387
+ // LIKE: expr LIKE ?
388
+ const likeMatch = cond.match(/^(.+?)\s+LIKE\s+(\?|'[^']*')/i);
389
+ if (likeMatch) {
390
+ const val = String(resolveExpr(row, likeMatch[1].trim()) ?? '');
391
+ let pattern: string;
392
+ if (likeMatch[2] === '?') {
393
+ pattern = String(ctx.params[ctx.idx++]);
394
+ } else {
395
+ pattern = likeMatch[2].slice(1, -1);
396
+ }
397
+ return matchLike(val, pattern);
398
+ }
399
+
400
+ // Comparison: expr OP expr
401
+ const compMatch = cond.match(/^(.+?)\s*(>=|<=|!=|>|<|=)\s*(.+)$/);
402
+ if (compMatch) {
403
+ const left = resolveExpr(row, compMatch[1].trim());
404
+ const right = resolveExpr(row, compMatch[3].trim(), ctx);
405
+ switch (compMatch[2]) {
406
+ case '=': return left == right;
407
+ case '!=': return left != right;
408
+ case '>': return left > right;
409
+ case '>=': return left >= right;
410
+ case '<': return left < right;
411
+ case '<=': return left <= right;
412
+ }
413
+ }
414
+
415
+ // Unknown condition -- pass
416
+ return true;
417
+ }
418
+
419
+ /**
420
+ * Resolve an expression to a value.
421
+ * Can be: json_extract(data, '$.field'), column name, ?, or literal.
422
+ */
423
+ function resolveExpr(row: TableRow, expr: string, ctx?: ParamCtx): any {
424
+ const trimmed = expr.trim();
425
+
426
+ if (trimmed === '?') {
427
+ if (!ctx) throw new Error('Parameter ? without context');
428
+ return ctx.params[ctx.idx++];
429
+ }
430
+
431
+ // json_extract(data, '$.path')
432
+ const jsonMatch = trimmed.match(/^json_extract\s*\(\s*(\w+)\s*,\s*'([^']+)'\s*\)$/i);
433
+ if (jsonMatch) {
434
+ return resolveValue(row, trimmed);
435
+ }
436
+
437
+ // Numeric literal
438
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
439
+ return parseFloat(trimmed);
440
+ }
441
+
442
+ // String literal
443
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
444
+ return trimmed.slice(1, -1);
445
+ }
446
+
447
+ // Boolean-like: true/false
448
+ if (trimmed === 'true') return true;
449
+ if (trimmed === 'false') return false;
450
+
451
+ // Column name
452
+ if (/^\w+$/.test(trimmed)) {
453
+ return row[trimmed];
454
+ }
455
+
456
+ return trimmed;
457
+ }
458
+
459
+ function parseLiteral(s: string): any {
460
+ if (/^-?\d+(\.\d+)?$/.test(s)) return parseFloat(s);
461
+ if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
462
+ return s;
463
+ }
464
+
465
+ function matchLike(value: string, pattern: string): boolean {
466
+ // Convert SQL LIKE pattern to regex
467
+ const regexStr = pattern
468
+ .replace(/[.*+?^${}()|[\]\\]/g, (m) => {
469
+ if (m === '%') return '.*';
470
+ if (m === '_') return '.';
471
+ return '\\' + m;
472
+ })
473
+ .replace(/%/g, '.*')
474
+ .replace(/_/g, '.');
475
+ return new RegExp(`^${regexStr}$`, 'i').test(value);
476
+ }
477
+
478
+ function stripOuterParens(expr: string): string {
479
+ const trimmed = expr.trim();
480
+ if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
481
+ // Verify the parens actually wrap the whole expression
482
+ let depth = 0;
483
+ for (let i = 0; i < trimmed.length; i++) {
484
+ if (trimmed[i] === '(') depth++;
485
+ if (trimmed[i] === ')') depth--;
486
+ if (depth === 0 && i < trimmed.length - 1) {
487
+ return trimmed; // parens don't wrap the whole thing
488
+ }
489
+ }
490
+ return stripOuterParens(trimmed.slice(1, -1));
491
+ }
492
+ return trimmed;
493
+ }
494
+
495
+ /**
496
+ * Split an expression on a keyword (AND/OR) at the top level only
497
+ * (not within parentheses).
498
+ */
499
+ function splitOnKeyword(expr: string, keyword: string): string[] {
500
+ const parts: string[] = [];
501
+ let depth = 0;
502
+ let start = 0;
503
+ const kw = ` ${keyword} `;
504
+ const kwLen = kw.length;
505
+
506
+ for (let i = 0; i < expr.length; i++) {
507
+ if (expr[i] === '(') depth++;
508
+ if (expr[i] === ')') depth--;
509
+ if (depth === 0 && i + kwLen <= expr.length) {
510
+ const slice = expr.slice(i, i + kwLen);
511
+ if (slice.toUpperCase() === kw.toUpperCase()) {
512
+ parts.push(expr.slice(start, i).trim());
513
+ start = i + kwLen;
514
+ i += kwLen - 1;
515
+ }
516
+ }
517
+ }
518
+ parts.push(expr.slice(start).trim());
519
+ return parts.filter(Boolean);
520
+ }
521
+
522
+ /**
523
+ * Sort rows by an ORDER BY spec.
524
+ * Handles both plain columns and json_extract expressions.
525
+ */
526
+ function sortRows(rows: TableRow[], orderSpec: string): TableRow[] {
527
+ // Parse order parts, handling json_extract(data, '$.field') ASC/DESC
528
+ const parts: { expr: string; desc: boolean }[] = [];
529
+ // Split on commas that are not within parentheses
530
+ let depth = 0;
531
+ let current = '';
532
+ for (const ch of orderSpec) {
533
+ if (ch === '(') depth++;
534
+ if (ch === ')') depth--;
535
+ if (ch === ',' && depth === 0) {
536
+ parts.push(parseOrderPart(current.trim()));
537
+ current = '';
538
+ } else {
539
+ current += ch;
540
+ }
541
+ }
542
+ if (current.trim()) {
543
+ parts.push(parseOrderPart(current.trim()));
544
+ }
545
+
546
+ return rows.sort((a, b) => {
547
+ for (const { expr, desc } of parts) {
548
+ const av = resolveValue(a, expr);
549
+ const bv = resolveValue(b, expr);
550
+ if (av < bv) return desc ? 1 : -1;
551
+ if (av > bv) return desc ? -1 : 1;
552
+ }
553
+ return 0;
554
+ });
555
+ }
556
+
557
+ function parseOrderPart(part: string): { expr: string; desc: boolean } {
558
+ // Match trailing ASC/DESC
559
+ const dirMatch = part.match(/\s+(ASC|DESC)\s*$/i);
560
+ const desc = dirMatch ? dirMatch[1].toUpperCase() === 'DESC' : false;
561
+ const expr = dirMatch ? part.slice(0, dirMatch.index).trim() : part;
562
+ return { expr, desc };
563
+ }
564
+
565
+ return { execSync, getAllSync, runSync };
566
+ }
@@ -0,0 +1,24 @@
1
+ import type { RxStorage, RxStorageInstanceCreationParams } from 'rxdb';
2
+ import { ensureRxStorageInstanceParamsAreCorrect } from 'rxdb';
3
+ import { RXDB_VERSION } from 'rxdb/plugins/utils';
4
+ import type { SQLiteDatabase, SQLiteStorageSettings } from './types';
5
+ import {
6
+ createSQLiteStorageInstance,
7
+ type SQLiteStorageInternals,
8
+ } from './storage-instance';
9
+
10
+ export type RxStorageSQLite = RxStorage<SQLiteStorageInternals, SQLiteStorageSettings>;
11
+
12
+ export function getRxStorageSQLite(
13
+ database: SQLiteDatabase
14
+ ): RxStorageSQLite {
15
+ const storage: RxStorageSQLite = {
16
+ name: 'sqlite',
17
+ rxdbVersion: RXDB_VERSION,
18
+ createStorageInstance(params: RxStorageInstanceCreationParams<any, SQLiteStorageSettings>) {
19
+ ensureRxStorageInstanceParamsAreCorrect(params);
20
+ return createSQLiteStorageInstance(storage, params, database);
21
+ },
22
+ };
23
+ return storage;
24
+ }