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.
@@ -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
+ }