@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.
- package/dist/index.d.ts +23 -0
- package/dist/index.js +401 -0
- package/package.json +33 -0
- package/src/index.ts +2 -0
- package/src/integration.test.ts +169 -0
- package/src/mango-to-sql.test.ts +239 -0
- package/src/mango-to-sql.ts +274 -0
- package/src/mock-sqlite.ts +566 -0
- package/src/rx-storage-sqlite.ts +24 -0
- package/src/storage-instance.test.ts +265 -0
- package/src/storage-instance.ts +333 -0
- package/src/types.ts +9 -0
|
@@ -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
|
+
}
|