@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,239 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mangoQueryToSQL, buildQuerySQL, buildCountSQL } from './mango-to-sql';
3
+ import type { FilledMangoQuery } from 'rxdb';
4
+
5
+ function makeQuery(overrides: Partial<FilledMangoQuery<any>> = {}): FilledMangoQuery<any> {
6
+ return {
7
+ selector: {},
8
+ sort: [],
9
+ skip: 0,
10
+ ...overrides,
11
+ };
12
+ }
13
+
14
+ describe('mangoQueryToSQL', () => {
15
+ describe('basic selectors', () => {
16
+ it('should produce WHERE with only _deleted filter for empty selector', () => {
17
+ const result = mangoQueryToSQL(makeQuery());
18
+ expect(result.where).toBe(`WHERE json_extract(data, '$._deleted') = 0`);
19
+ expect(result.params).toEqual([]);
20
+ });
21
+
22
+ it('should handle equality ($eq) for a field', () => {
23
+ const result = mangoQueryToSQL(makeQuery({
24
+ selector: { name: 'Alice' } as any,
25
+ }));
26
+ expect(result.where).toContain(`json_extract(data, '$.name') = ?`);
27
+ expect(result.params).toContain('Alice');
28
+ });
29
+
30
+ it('should handle explicit $eq operator', () => {
31
+ const result = mangoQueryToSQL(makeQuery({
32
+ selector: { name: { $eq: 'Alice' } } as any,
33
+ }));
34
+ expect(result.where).toContain(`json_extract(data, '$.name') = ?`);
35
+ expect(result.params).toEqual(['Alice']);
36
+ });
37
+ });
38
+
39
+ describe('comparison operators', () => {
40
+ it('should handle $gt', () => {
41
+ const result = mangoQueryToSQL(makeQuery({
42
+ selector: { age: { $gt: 25 } } as any,
43
+ }));
44
+ expect(result.where).toContain(`json_extract(data, '$.age') > ?`);
45
+ expect(result.params).toContain(25);
46
+ });
47
+
48
+ it('should handle $gte', () => {
49
+ const result = mangoQueryToSQL(makeQuery({
50
+ selector: { age: { $gte: 25 } } as any,
51
+ }));
52
+ expect(result.where).toContain(`json_extract(data, '$.age') >= ?`);
53
+ expect(result.params).toContain(25);
54
+ });
55
+
56
+ it('should handle $lt', () => {
57
+ const result = mangoQueryToSQL(makeQuery({
58
+ selector: { age: { $lt: 30 } } as any,
59
+ }));
60
+ expect(result.where).toContain(`json_extract(data, '$.age') < ?`);
61
+ expect(result.params).toContain(30);
62
+ });
63
+
64
+ it('should handle $lte', () => {
65
+ const result = mangoQueryToSQL(makeQuery({
66
+ selector: { age: { $lte: 30 } } as any,
67
+ }));
68
+ expect(result.where).toContain(`json_extract(data, '$.age') <= ?`);
69
+ expect(result.params).toContain(30);
70
+ });
71
+
72
+ it('should handle $ne', () => {
73
+ const result = mangoQueryToSQL(makeQuery({
74
+ selector: { status: { $ne: 'inactive' } } as any,
75
+ }));
76
+ expect(result.where).toContain(`json_extract(data, '$.status') != ?`);
77
+ expect(result.params).toContain('inactive');
78
+ });
79
+ });
80
+
81
+ describe('array operators', () => {
82
+ it('should handle $in', () => {
83
+ const result = mangoQueryToSQL(makeQuery({
84
+ selector: { status: { $in: ['active', 'pending'] } } as any,
85
+ }));
86
+ expect(result.where).toContain(`json_extract(data, '$.status') IN (?, ?)`);
87
+ expect(result.params).toEqual(['active', 'pending']);
88
+ });
89
+
90
+ it('should handle empty $in as always-false', () => {
91
+ const result = mangoQueryToSQL(makeQuery({
92
+ selector: { status: { $in: [] } } as any,
93
+ }));
94
+ expect(result.where).toContain('0');
95
+ });
96
+
97
+ it('should handle $nin', () => {
98
+ const result = mangoQueryToSQL(makeQuery({
99
+ selector: { status: { $nin: ['deleted', 'archived'] } } as any,
100
+ }));
101
+ expect(result.where).toContain(`json_extract(data, '$.status') NOT IN (?, ?)`);
102
+ expect(result.params).toEqual(['deleted', 'archived']);
103
+ });
104
+ });
105
+
106
+ describe('regex', () => {
107
+ it('should convert $regex to LIKE with wildcards', () => {
108
+ const result = mangoQueryToSQL(makeQuery({
109
+ selector: { name: { $regex: '^Al' } } as any,
110
+ }));
111
+ expect(result.where).toContain(`json_extract(data, '$.name') LIKE ?`);
112
+ expect(result.params).toContain('Al%');
113
+ });
114
+
115
+ it('should handle unanchored regex with wrapping wildcards', () => {
116
+ const result = mangoQueryToSQL(makeQuery({
117
+ selector: { name: { $regex: 'lic' } } as any,
118
+ }));
119
+ expect(result.params).toContain('%lic%');
120
+ });
121
+
122
+ it('should handle end-anchored regex', () => {
123
+ const result = mangoQueryToSQL(makeQuery({
124
+ selector: { name: { $regex: 'ice$' } } as any,
125
+ }));
126
+ expect(result.params).toContain('%ice');
127
+ });
128
+ });
129
+
130
+ describe('logical operators', () => {
131
+ it('should handle $and', () => {
132
+ const result = mangoQueryToSQL(makeQuery({
133
+ selector: {
134
+ $and: [
135
+ { age: { $gte: 18 } },
136
+ { age: { $lte: 65 } },
137
+ ],
138
+ } as any,
139
+ }));
140
+ expect(result.where).toContain(`json_extract(data, '$.age') >= ?`);
141
+ expect(result.where).toContain(`json_extract(data, '$.age') <= ?`);
142
+ expect(result.where).toContain(' AND ');
143
+ expect(result.params).toEqual([18, 65]);
144
+ });
145
+
146
+ it('should handle $or', () => {
147
+ const result = mangoQueryToSQL(makeQuery({
148
+ selector: {
149
+ $or: [
150
+ { name: 'Alice' },
151
+ { name: 'Bob' },
152
+ ],
153
+ } as any,
154
+ }));
155
+ expect(result.where).toContain(`json_extract(data, '$.name') = ?`);
156
+ expect(result.where).toContain(' OR ');
157
+ expect(result.params).toEqual(['Alice', 'Bob']);
158
+ });
159
+ });
160
+
161
+ describe('sort', () => {
162
+ it('should generate ORDER BY clause for ascending', () => {
163
+ const result = mangoQueryToSQL(makeQuery({
164
+ sort: [{ name: 'asc' }] as any,
165
+ }));
166
+ expect(result.orderBy).toBe(`ORDER BY json_extract(data, '$.name') ASC`);
167
+ });
168
+
169
+ it('should generate ORDER BY clause for descending', () => {
170
+ const result = mangoQueryToSQL(makeQuery({
171
+ sort: [{ age: 'desc' }] as any,
172
+ }));
173
+ expect(result.orderBy).toBe(`ORDER BY json_extract(data, '$.age') DESC`);
174
+ });
175
+
176
+ it('should handle multiple sort fields', () => {
177
+ const result = mangoQueryToSQL(makeQuery({
178
+ sort: [{ name: 'asc' }, { age: 'desc' }] as any,
179
+ }));
180
+ expect(result.orderBy).toBe(
181
+ `ORDER BY json_extract(data, '$.name') ASC, json_extract(data, '$.age') DESC`
182
+ );
183
+ });
184
+ });
185
+
186
+ describe('limit and skip', () => {
187
+ it('should generate LIMIT clause', () => {
188
+ const result = mangoQueryToSQL(makeQuery({ limit: 10 }));
189
+ expect(result.limit).toBe('LIMIT ?');
190
+ expect(result.limitParams).toEqual([10]);
191
+ });
192
+
193
+ it('should generate LIMIT + OFFSET for skip', () => {
194
+ const result = mangoQueryToSQL(makeQuery({ limit: 10, skip: 5 }));
195
+ expect(result.limit).toBe('LIMIT ? OFFSET ?');
196
+ expect(result.limitParams).toEqual([10, 5]);
197
+ });
198
+
199
+ it('should generate LIMIT -1 OFFSET for skip without limit', () => {
200
+ const result = mangoQueryToSQL(makeQuery({ skip: 5 }));
201
+ expect(result.limit).toBe('LIMIT -1 OFFSET ?');
202
+ expect(result.limitParams).toEqual([5]);
203
+ });
204
+ });
205
+ });
206
+
207
+ describe('buildQuerySQL', () => {
208
+ it('should build a complete SELECT statement', () => {
209
+ const query = makeQuery({
210
+ selector: { name: 'Alice' } as any,
211
+ sort: [{ name: 'asc' }] as any,
212
+ limit: 10,
213
+ skip: 0,
214
+ });
215
+ const { sql, params } = buildQuerySQL('my_table', query);
216
+ expect(sql).toContain('SELECT data FROM "my_table"');
217
+ expect(sql).toContain('WHERE');
218
+ expect(sql).toContain('ORDER BY');
219
+ expect(sql).toContain('LIMIT');
220
+ expect(params).toContain('Alice');
221
+ });
222
+ });
223
+
224
+ describe('buildCountSQL', () => {
225
+ it('should build a COUNT statement without sort/limit/skip', () => {
226
+ const query = makeQuery({
227
+ selector: { age: { $gt: 20 } } as any,
228
+ sort: [{ name: 'asc' }] as any,
229
+ limit: 10,
230
+ skip: 5,
231
+ });
232
+ const { sql, params } = buildCountSQL('my_table', query);
233
+ expect(sql).toContain('SELECT COUNT(*) as count FROM "my_table"');
234
+ expect(sql).toContain('WHERE');
235
+ expect(sql).not.toContain('ORDER BY');
236
+ expect(sql).not.toContain('LIMIT');
237
+ expect(params).toContain(20);
238
+ });
239
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Translates RxDB Mango-style queries into SQL WHERE clauses
3
+ * that use SQLite's json_extract() to access document fields.
4
+ *
5
+ * Supported operators:
6
+ * Comparison: $eq (default), $gt, $gte, $lt, $lte, $ne
7
+ * Array: $in, $nin
8
+ * String: $regex
9
+ * Logical: $and, $or
10
+ *
11
+ * Plus: sort, limit, skip
12
+ */
13
+
14
+ import type { FilledMangoQuery, MangoQuerySelector } from 'rxdb';
15
+
16
+ export interface MangoSqlResult {
17
+ where: string;
18
+ params: any[];
19
+ orderBy: string;
20
+ limit: string;
21
+ limitParams: any[];
22
+ }
23
+
24
+ /**
25
+ * Convert a Mango selector + sort/skip/limit into SQL fragments.
26
+ * The returned `where` always starts with `WHERE ...` (including the
27
+ * mandatory `_deleted = 0` filter for non-deleted documents).
28
+ *
29
+ * All document field access goes through `json_extract(data, '$.fieldName')`.
30
+ */
31
+ export function mangoQueryToSQL<RxDocType>(
32
+ query: FilledMangoQuery<RxDocType>,
33
+ ): MangoSqlResult {
34
+ const params: any[] = [];
35
+ const conditions: string[] = [];
36
+
37
+ // Always filter out deleted docs -- RxDB's query() contract.
38
+ conditions.push(`json_extract(data, '$._deleted') = 0`);
39
+
40
+ // Process the selector
41
+ if (query.selector && Object.keys(query.selector).length > 0) {
42
+ const selectorSql = selectorToSQL(query.selector as Record<string, any>, params);
43
+ if (selectorSql) {
44
+ conditions.push(selectorSql);
45
+ }
46
+ }
47
+
48
+ const where = conditions.length > 0
49
+ ? `WHERE ${conditions.join(' AND ')}`
50
+ : '';
51
+
52
+ // ORDER BY
53
+ let orderBy = '';
54
+ if (query.sort && query.sort.length > 0) {
55
+ const orderParts = query.sort.map((sortPart) => {
56
+ const entries = Object.entries(sortPart);
57
+ if (entries.length === 0) return '';
58
+ const [field, direction] = entries[0];
59
+ const jsonPath = fieldToJsonExtract(field);
60
+ return `${jsonPath} ${direction === 'desc' ? 'DESC' : 'ASC'}`;
61
+ }).filter(Boolean);
62
+ if (orderParts.length > 0) {
63
+ orderBy = `ORDER BY ${orderParts.join(', ')}`;
64
+ }
65
+ }
66
+
67
+ // LIMIT / OFFSET
68
+ const limitParams: any[] = [];
69
+ let limit = '';
70
+ if (query.limit !== undefined && query.limit !== null) {
71
+ limit = 'LIMIT ?';
72
+ limitParams.push(query.limit);
73
+ }
74
+ if (query.skip && query.skip > 0) {
75
+ if (!limit) {
76
+ // SQLite requires LIMIT before OFFSET
77
+ limit = 'LIMIT -1';
78
+ }
79
+ limit += ' OFFSET ?';
80
+ limitParams.push(query.skip);
81
+ }
82
+
83
+ return { where, params, orderBy, limit, limitParams };
84
+ }
85
+
86
+ /**
87
+ * Build the full SQL SELECT query from mango query result.
88
+ */
89
+ export function buildQuerySQL<RxDocType>(
90
+ tableName: string,
91
+ query: FilledMangoQuery<RxDocType>,
92
+ ): { sql: string; params: any[] } {
93
+ const result = mangoQueryToSQL(query);
94
+ const allParams = [...result.params, ...result.limitParams];
95
+ const parts = [
96
+ `SELECT data FROM "${tableName}"`,
97
+ result.where,
98
+ result.orderBy,
99
+ result.limit,
100
+ ].filter(Boolean);
101
+ return { sql: parts.join(' '), params: allParams };
102
+ }
103
+
104
+ /**
105
+ * Build a COUNT SQL query from mango query result.
106
+ * Ignores sort, skip, and limit per RxDB's count() contract.
107
+ */
108
+ export function buildCountSQL<RxDocType>(
109
+ tableName: string,
110
+ query: FilledMangoQuery<RxDocType>,
111
+ ): { sql: string; params: any[] } {
112
+ const result = mangoQueryToSQL(query);
113
+ const parts = [
114
+ `SELECT COUNT(*) as count FROM "${tableName}"`,
115
+ result.where,
116
+ ].filter(Boolean);
117
+ return { sql: parts.join(' '), params: result.params };
118
+ }
119
+
120
+ // ------------------------------------------------------------------
121
+ // Internal helpers
122
+ // ------------------------------------------------------------------
123
+
124
+ function fieldToJsonExtract(field: string): string {
125
+ return `json_extract(data, '$.${field}')`;
126
+ }
127
+
128
+ function selectorToSQL(
129
+ selector: Record<string, any>,
130
+ params: any[],
131
+ ): string {
132
+ const conditions: string[] = [];
133
+
134
+ for (const [key, value] of Object.entries(selector)) {
135
+ if (key === '$and') {
136
+ const andConditions = (value as Record<string, any>[]).map(
137
+ (sub) => selectorToSQL(sub, params),
138
+ ).filter(Boolean);
139
+ if (andConditions.length > 0) {
140
+ conditions.push(`(${andConditions.join(' AND ')})`);
141
+ }
142
+ } else if (key === '$or') {
143
+ const orConditions = (value as Record<string, any>[]).map(
144
+ (sub) => selectorToSQL(sub, params),
145
+ ).filter(Boolean);
146
+ if (orConditions.length > 0) {
147
+ conditions.push(`(${orConditions.join(' OR ')})`);
148
+ }
149
+ } else {
150
+ // It's a field name with either a value or an operator object
151
+ const fieldSql = fieldConditionToSQL(key, value, params);
152
+ if (fieldSql) {
153
+ conditions.push(fieldSql);
154
+ }
155
+ }
156
+ }
157
+
158
+ return conditions.join(' AND ');
159
+ }
160
+
161
+ function fieldConditionToSQL(
162
+ field: string,
163
+ value: any,
164
+ params: any[],
165
+ ): string {
166
+ const jsonPath = fieldToJsonExtract(field);
167
+
168
+ // Plain value means $eq
169
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
170
+ params.push(value);
171
+ return `${jsonPath} = ?`;
172
+ }
173
+
174
+ // Object with operators
175
+ const conditions: string[] = [];
176
+ for (const [op, opValue] of Object.entries(value)) {
177
+ switch (op) {
178
+ case '$eq':
179
+ params.push(opValue);
180
+ conditions.push(`${jsonPath} = ?`);
181
+ break;
182
+ case '$ne':
183
+ params.push(opValue);
184
+ conditions.push(`${jsonPath} != ?`);
185
+ break;
186
+ case '$gt':
187
+ params.push(opValue);
188
+ conditions.push(`${jsonPath} > ?`);
189
+ break;
190
+ case '$gte':
191
+ params.push(opValue);
192
+ conditions.push(`${jsonPath} >= ?`);
193
+ break;
194
+ case '$lt':
195
+ params.push(opValue);
196
+ conditions.push(`${jsonPath} < ?`);
197
+ break;
198
+ case '$lte':
199
+ params.push(opValue);
200
+ conditions.push(`${jsonPath} <= ?`);
201
+ break;
202
+ case '$in': {
203
+ const arr = opValue as any[];
204
+ if (arr.length === 0) {
205
+ conditions.push('0'); // always false
206
+ } else {
207
+ const placeholders = arr.map(() => '?').join(', ');
208
+ params.push(...arr);
209
+ conditions.push(`${jsonPath} IN (${placeholders})`);
210
+ }
211
+ break;
212
+ }
213
+ case '$nin': {
214
+ const arr = opValue as any[];
215
+ if (arr.length === 0) {
216
+ // No exclusions, always true -- skip
217
+ } else {
218
+ const placeholders = arr.map(() => '?').join(', ');
219
+ params.push(...arr);
220
+ conditions.push(`${jsonPath} NOT IN (${placeholders})`);
221
+ }
222
+ break;
223
+ }
224
+ case '$regex': {
225
+ // SQLite does not natively support regex, but we can use LIKE for simple patterns
226
+ // or GLOB. For a more complete solution we'd need a regex extension.
227
+ // Here we convert simple regex patterns to LIKE patterns.
228
+ const regexStr = typeof opValue === 'string' ? opValue : (opValue as RegExp).source;
229
+ const likePattern = regexToLike(regexStr);
230
+ params.push(likePattern);
231
+ conditions.push(`${jsonPath} LIKE ?`);
232
+ break;
233
+ }
234
+ default:
235
+ // Unknown operator -- skip (RxDB might send $options etc.)
236
+ break;
237
+ }
238
+ }
239
+
240
+ return conditions.join(' AND ');
241
+ }
242
+
243
+ /**
244
+ * Convert a simple regex pattern to a SQLite LIKE pattern.
245
+ * Only handles basic patterns:
246
+ * ^foo -> foo%
247
+ * foo$ -> %foo
248
+ * ^foo$ -> foo
249
+ * foo -> %foo%
250
+ * .* -> %
251
+ */
252
+ function regexToLike(regex: string): string {
253
+ let pattern = regex;
254
+ let prefix = '%';
255
+ let suffix = '%';
256
+
257
+ if (pattern.startsWith('^')) {
258
+ prefix = '';
259
+ pattern = pattern.slice(1);
260
+ }
261
+ if (pattern.endsWith('$')) {
262
+ suffix = '';
263
+ pattern = pattern.slice(0, -1);
264
+ }
265
+
266
+ // Replace .* with %
267
+ pattern = pattern.replace(/\.\*/g, '%');
268
+ // Replace . with _
269
+ pattern = pattern.replace(/\./g, '_');
270
+ // Escape existing % and _ that aren't our wildcards
271
+ // (This is approximate -- a real implementation would need more care)
272
+
273
+ return `${prefix}${pattern}${suffix}`;
274
+ }