sqlite-zod-orm 3.0.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 +314 -0
- package/dist/satidb.js +26 -0
- package/package.json +55 -0
- package/src/ast.ts +107 -0
- package/src/build.ts +23 -0
- package/src/proxy-query.ts +308 -0
- package/src/query-builder.ts +398 -0
- package/src/satidb.ts +1153 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
type ASTNode, type WhereCallback, type TypedColumnProxy, type FunctionProxy, type Operators,
|
|
4
|
+
compileAST, wrapNode, createColumnProxy, createFunctionProxy, op,
|
|
5
|
+
} from './ast';
|
|
6
|
+
|
|
7
|
+
// ---------- Internal Query Object (IQO) ----------
|
|
8
|
+
|
|
9
|
+
type OrderDirection = 'asc' | 'desc';
|
|
10
|
+
type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in';
|
|
11
|
+
|
|
12
|
+
interface WhereCondition {
|
|
13
|
+
field: string;
|
|
14
|
+
operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN';
|
|
15
|
+
value: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface IQO {
|
|
19
|
+
selects: string[];
|
|
20
|
+
wheres: WhereCondition[];
|
|
21
|
+
whereAST: ASTNode | null;
|
|
22
|
+
limit: number | null;
|
|
23
|
+
offset: number | null;
|
|
24
|
+
orderBy: { field: string; direction: OrderDirection }[];
|
|
25
|
+
includes: string[];
|
|
26
|
+
raw: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------- Operator mapping ----------
|
|
30
|
+
|
|
31
|
+
const OPERATOR_MAP: Record<WhereOperator, string> = {
|
|
32
|
+
$gt: '>',
|
|
33
|
+
$gte: '>=',
|
|
34
|
+
$lt: '<',
|
|
35
|
+
$lte: '<=',
|
|
36
|
+
$ne: '!=',
|
|
37
|
+
$in: 'IN',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---------- SQL Compilation ----------
|
|
41
|
+
|
|
42
|
+
function transformValueForStorage(value: any): any {
|
|
43
|
+
if (value instanceof Date) return value.toISOString();
|
|
44
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function compileIQO(tableName: string, iqo: IQO): { sql: string; params: any[] } {
|
|
49
|
+
const params: any[] = [];
|
|
50
|
+
|
|
51
|
+
// SELECT clause
|
|
52
|
+
const selectClause = iqo.selects.length > 0
|
|
53
|
+
? iqo.selects.map(s => `${tableName}.${s}`).join(', ')
|
|
54
|
+
: `${tableName}.*`;
|
|
55
|
+
|
|
56
|
+
let sql = `SELECT ${selectClause} FROM ${tableName}`;
|
|
57
|
+
|
|
58
|
+
// WHERE clause — AST-based takes precedence if set
|
|
59
|
+
if (iqo.whereAST) {
|
|
60
|
+
const compiled = compileAST(iqo.whereAST);
|
|
61
|
+
sql += ` WHERE ${compiled.sql}`;
|
|
62
|
+
params.push(...compiled.params);
|
|
63
|
+
} else if (iqo.wheres.length > 0) {
|
|
64
|
+
const whereParts: string[] = [];
|
|
65
|
+
for (const w of iqo.wheres) {
|
|
66
|
+
if (w.operator === 'IN') {
|
|
67
|
+
const arr = w.value as any[];
|
|
68
|
+
if (arr.length === 0) {
|
|
69
|
+
whereParts.push('1 = 0');
|
|
70
|
+
} else {
|
|
71
|
+
const placeholders = arr.map(() => '?').join(', ');
|
|
72
|
+
whereParts.push(`${w.field} IN (${placeholders})`);
|
|
73
|
+
params.push(...arr.map(transformValueForStorage));
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
whereParts.push(`${w.field} ${w.operator} ?`);
|
|
77
|
+
params.push(transformValueForStorage(w.value));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
sql += ` WHERE ${whereParts.join(' AND ')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ORDER BY clause
|
|
84
|
+
if (iqo.orderBy.length > 0) {
|
|
85
|
+
const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
|
|
86
|
+
sql += ` ORDER BY ${parts.join(', ')}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// LIMIT
|
|
90
|
+
if (iqo.limit !== null) {
|
|
91
|
+
sql += ` LIMIT ${iqo.limit}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// OFFSET
|
|
95
|
+
if (iqo.offset !== null) {
|
|
96
|
+
sql += ` OFFSET ${iqo.offset}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { sql, params };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- QueryBuilder Class ----------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A Fluent Query Builder that accumulates query state via chaining
|
|
106
|
+
* and only executes when a terminal method is called (.all(), .get())
|
|
107
|
+
* or when it is `await`-ed (thenable).
|
|
108
|
+
*
|
|
109
|
+
* Supports two WHERE styles:
|
|
110
|
+
* - Object-style: `.where({ name: 'Alice', age: { $gt: 18 } })`
|
|
111
|
+
* - Callback-style (AST): `.where((c, f, op) => op.and(op.eq(c.name, 'Alice'), op.gt(c.age, 18)))`
|
|
112
|
+
*/
|
|
113
|
+
export class QueryBuilder<T extends Record<string, any>> {
|
|
114
|
+
private iqo: IQO;
|
|
115
|
+
private tableName: string;
|
|
116
|
+
private executor: (sql: string, params: any[], raw: boolean) => any[];
|
|
117
|
+
private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
|
|
118
|
+
|
|
119
|
+
constructor(
|
|
120
|
+
tableName: string,
|
|
121
|
+
executor: (sql: string, params: any[], raw: boolean) => any[],
|
|
122
|
+
singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
|
|
123
|
+
) {
|
|
124
|
+
this.tableName = tableName;
|
|
125
|
+
this.executor = executor;
|
|
126
|
+
this.singleExecutor = singleExecutor;
|
|
127
|
+
this.iqo = {
|
|
128
|
+
selects: [],
|
|
129
|
+
wheres: [],
|
|
130
|
+
whereAST: null,
|
|
131
|
+
limit: null,
|
|
132
|
+
offset: null,
|
|
133
|
+
orderBy: [],
|
|
134
|
+
includes: [],
|
|
135
|
+
raw: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Specify which columns to select.
|
|
141
|
+
* If called with no arguments, defaults to `*`.
|
|
142
|
+
*/
|
|
143
|
+
select(...cols: (keyof T & string)[]): this {
|
|
144
|
+
this.iqo.selects.push(...cols);
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Add WHERE conditions. Two calling styles:
|
|
150
|
+
*
|
|
151
|
+
* **Object-style** (simple equality and operators):
|
|
152
|
+
* ```ts
|
|
153
|
+
* .where({ name: 'Alice' })
|
|
154
|
+
* .where({ age: { $gt: 18 } })
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* **Callback-style** (AST-based, full SQL expression power):
|
|
158
|
+
* ```ts
|
|
159
|
+
* .where((c, f, op) => op.and(
|
|
160
|
+
* op.eq(f.lower(c.name), 'alice'),
|
|
161
|
+
* op.gt(c.age, 18)
|
|
162
|
+
* ))
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
where(criteriaOrCallback: Partial<Record<keyof T & string, any>> | WhereCallback<T>): this {
|
|
166
|
+
if (typeof criteriaOrCallback === 'function') {
|
|
167
|
+
// Callback-style: evaluate with proxies to produce AST
|
|
168
|
+
const ast = (criteriaOrCallback as WhereCallback<T>)(
|
|
169
|
+
createColumnProxy<T>(),
|
|
170
|
+
createFunctionProxy(),
|
|
171
|
+
op,
|
|
172
|
+
);
|
|
173
|
+
// If we already have an AST, AND them together
|
|
174
|
+
if (this.iqo.whereAST) {
|
|
175
|
+
this.iqo.whereAST = { type: 'operator', op: 'AND', left: this.iqo.whereAST, right: ast };
|
|
176
|
+
} else {
|
|
177
|
+
this.iqo.whereAST = ast;
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// Object-style: parse into IQO conditions
|
|
181
|
+
for (const [key, value] of Object.entries(criteriaOrCallback)) {
|
|
182
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
183
|
+
for (const [opKey, operand] of Object.entries(value)) {
|
|
184
|
+
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
185
|
+
if (!sqlOp) throw new Error(`Unsupported query operator: '${opKey}' on field '${key}'.`);
|
|
186
|
+
this.iqo.wheres.push({
|
|
187
|
+
field: key,
|
|
188
|
+
operator: sqlOp as WhereCondition['operator'],
|
|
189
|
+
value: operand,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
this.iqo.wheres.push({ field: key, operator: '=', value });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Set the maximum number of rows to return. */
|
|
201
|
+
limit(n: number): this {
|
|
202
|
+
this.iqo.limit = n;
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Set the offset for pagination. */
|
|
207
|
+
offset(n: number): this {
|
|
208
|
+
this.iqo.offset = n;
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Add ORDER BY clauses.
|
|
214
|
+
* ```ts
|
|
215
|
+
* .orderBy('name', 'asc')
|
|
216
|
+
* .orderBy('createdAt', 'desc')
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
orderBy(field: keyof T & string, direction: OrderDirection = 'asc'): this {
|
|
220
|
+
this.iqo.orderBy.push({ field, direction });
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Skip Zod parsing and return raw SQLite row objects. */
|
|
225
|
+
raw(): this {
|
|
226
|
+
this.iqo.raw = true;
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- Terminal / Execution Methods ----------
|
|
231
|
+
|
|
232
|
+
/** Execute the query and return all matching rows. */
|
|
233
|
+
all(): T[] {
|
|
234
|
+
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
235
|
+
return this.executor(sql, params, this.iqo.raw);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Execute the query and return the first matching row, or null. */
|
|
239
|
+
get(): T | null {
|
|
240
|
+
this.iqo.limit = 1;
|
|
241
|
+
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
242
|
+
return this.singleExecutor(sql, params, this.iqo.raw);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Execute the query and return the count of matching rows. */
|
|
246
|
+
count(): number {
|
|
247
|
+
const params: any[] = [];
|
|
248
|
+
let sql = `SELECT COUNT(*) as count FROM ${this.tableName}`;
|
|
249
|
+
|
|
250
|
+
// WHERE — AST takes precedence
|
|
251
|
+
if (this.iqo.whereAST) {
|
|
252
|
+
const compiled = compileAST(this.iqo.whereAST);
|
|
253
|
+
sql += ` WHERE ${compiled.sql}`;
|
|
254
|
+
params.push(...compiled.params);
|
|
255
|
+
} else if (this.iqo.wheres.length > 0) {
|
|
256
|
+
const whereParts: string[] = [];
|
|
257
|
+
for (const w of this.iqo.wheres) {
|
|
258
|
+
if (w.operator === 'IN') {
|
|
259
|
+
const arr = w.value as any[];
|
|
260
|
+
if (arr.length === 0) {
|
|
261
|
+
whereParts.push('1 = 0');
|
|
262
|
+
} else {
|
|
263
|
+
const placeholders = arr.map(() => '?').join(', ');
|
|
264
|
+
whereParts.push(`${w.field} IN (${placeholders})`);
|
|
265
|
+
params.push(...arr.map(transformValueForStorage));
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
whereParts.push(`${w.field} ${w.operator} ?`);
|
|
269
|
+
params.push(transformValueForStorage(w.value));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
sql += ` WHERE ${whereParts.join(' AND ')}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const results = this.executor(sql, params, true);
|
|
276
|
+
return (results[0] as any)?.count ?? 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------- Subscribe (Smart Polling) ----------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Subscribe to query result changes using smart interval-based polling.
|
|
283
|
+
*
|
|
284
|
+
* Instead of re-fetching all rows every tick, it runs a lightweight
|
|
285
|
+
* fingerprint query (`SELECT COUNT(*), MAX(id)`) with the same WHERE clause.
|
|
286
|
+
* The full query is only re-executed when the fingerprint changes.
|
|
287
|
+
*
|
|
288
|
+
* ```ts
|
|
289
|
+
* const unsub = db.messages.select()
|
|
290
|
+
* .where({ groupId: 1 })
|
|
291
|
+
* .orderBy('id', 'desc')
|
|
292
|
+
* .limit(20)
|
|
293
|
+
* .subscribe((rows) => {
|
|
294
|
+
* console.log('Messages updated:', rows);
|
|
295
|
+
* }, { interval: 1000 });
|
|
296
|
+
*
|
|
297
|
+
* // Later: stop listening
|
|
298
|
+
* unsub();
|
|
299
|
+
* ```
|
|
300
|
+
*
|
|
301
|
+
* @param callback Called with the full result set whenever the data changes.
|
|
302
|
+
* @param options `interval` in ms (default 500). Set `immediate` to false to skip the first call.
|
|
303
|
+
* @returns An unsubscribe function that clears the polling interval.
|
|
304
|
+
*/
|
|
305
|
+
subscribe(
|
|
306
|
+
callback: (rows: T[]) => void,
|
|
307
|
+
options: { interval?: number; immediate?: boolean } = {},
|
|
308
|
+
): () => void {
|
|
309
|
+
const { interval = 500, immediate = true } = options;
|
|
310
|
+
|
|
311
|
+
// Build the fingerprint SQL (COUNT + MAX(id)) using the same WHERE
|
|
312
|
+
const fingerprintSQL = this.buildFingerprintSQL();
|
|
313
|
+
let lastFingerprint: string | null = null;
|
|
314
|
+
|
|
315
|
+
const poll = () => {
|
|
316
|
+
try {
|
|
317
|
+
// Run lightweight fingerprint check
|
|
318
|
+
const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
|
|
319
|
+
const fpRow = fpRows[0] as any;
|
|
320
|
+
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}`;
|
|
321
|
+
|
|
322
|
+
if (currentFingerprint !== lastFingerprint) {
|
|
323
|
+
lastFingerprint = currentFingerprint;
|
|
324
|
+
// Fingerprint changed → re-execute the full query
|
|
325
|
+
const rows = this.all();
|
|
326
|
+
callback(rows);
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Silently skip on error (table might be in transition)
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Immediate first execution
|
|
334
|
+
if (immediate) {
|
|
335
|
+
poll();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const timer = setInterval(poll, interval);
|
|
339
|
+
|
|
340
|
+
// Return unsubscribe function
|
|
341
|
+
return () => {
|
|
342
|
+
clearInterval(timer);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Build a lightweight fingerprint query (COUNT + MAX(id)) that shares the same WHERE clause. */
|
|
347
|
+
private buildFingerprintSQL(): { sql: string; params: any[] } {
|
|
348
|
+
const params: any[] = [];
|
|
349
|
+
let sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}`;
|
|
350
|
+
|
|
351
|
+
if (this.iqo.whereAST) {
|
|
352
|
+
const compiled = compileAST(this.iqo.whereAST);
|
|
353
|
+
sql += ` WHERE ${compiled.sql}`;
|
|
354
|
+
params.push(...compiled.params);
|
|
355
|
+
} else if (this.iqo.wheres.length > 0) {
|
|
356
|
+
const whereParts: string[] = [];
|
|
357
|
+
for (const w of this.iqo.wheres) {
|
|
358
|
+
if (w.operator === 'IN') {
|
|
359
|
+
const arr = w.value as any[];
|
|
360
|
+
if (arr.length === 0) {
|
|
361
|
+
whereParts.push('1 = 0');
|
|
362
|
+
} else {
|
|
363
|
+
const placeholders = arr.map(() => '?').join(', ');
|
|
364
|
+
whereParts.push(`${w.field} IN (${placeholders})`);
|
|
365
|
+
params.push(...arr.map(transformValueForStorage));
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
whereParts.push(`${w.field} ${w.operator} ?`);
|
|
369
|
+
params.push(transformValueForStorage(w.value));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
sql += ` WHERE ${whereParts.join(' AND ')}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { sql, params };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------- Thenable (async/await support) ----------
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Implementing `.then()` makes the builder a "Thenable".
|
|
382
|
+
* This means you can `await` a query builder directly:
|
|
383
|
+
* ```ts
|
|
384
|
+
* const users = await db.users.select().where({ level: 10 });
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
then<TResult1 = T[], TResult2 = never>(
|
|
388
|
+
onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
389
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
390
|
+
): Promise<TResult1 | TResult2> {
|
|
391
|
+
try {
|
|
392
|
+
const result = this.all();
|
|
393
|
+
return Promise.resolve(result).then(onfulfilled, onrejected);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
return Promise.reject(err).then(onfulfilled, onrejected);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|