sqlite-zod-orm 3.9.0 → 3.11.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.js +242 -72
- package/package.json +1 -1
- package/src/builder.ts +399 -0
- package/src/context.ts +9 -0
- package/src/crud.ts +44 -3
- package/src/database.ts +53 -3
- package/src/helpers.ts +11 -0
- package/src/index.ts +1 -1
- package/src/iqo.ts +198 -0
- package/src/proxy.ts +276 -0
- package/src/query.ts +23 -736
- package/src/types.ts +26 -3
package/src/builder.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* builder.ts — Fluent QueryBuilder class
|
|
3
|
+
*
|
|
4
|
+
* Accumulates query state via chaining and executes when a
|
|
5
|
+
* terminal method (.all(), .get(), .count()) is called.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type ASTNode, type WhereCallback, type TypedColumnProxy, type FunctionProxy, type Operators,
|
|
10
|
+
createColumnProxy, createFunctionProxy, op,
|
|
11
|
+
} from './ast';
|
|
12
|
+
import {
|
|
13
|
+
type IQO, type WhereCondition, type WhereOperator, type OrderDirection,
|
|
14
|
+
OPERATOR_MAP, compileIQO,
|
|
15
|
+
} from './iqo';
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// QueryBuilder Class
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A Fluent Query Builder that accumulates query state via chaining
|
|
23
|
+
* and only executes when a terminal method is called (.all(), .get())
|
|
24
|
+
* or when it is `await`-ed (thenable).
|
|
25
|
+
*
|
|
26
|
+
* Supports two WHERE styles:
|
|
27
|
+
* - Object-style: `.where({ name: 'Alice', age: { $gt: 18 } })`
|
|
28
|
+
* - Callback-style (AST): `.where((c, f, op) => op.and(op.eq(c.name, 'Alice'), op.gt(c.age, 18)))`
|
|
29
|
+
*/
|
|
30
|
+
export class QueryBuilder<T extends Record<string, any>> {
|
|
31
|
+
private iqo: IQO;
|
|
32
|
+
private tableName: string;
|
|
33
|
+
private executor: (sql: string, params: any[], raw: boolean) => any[];
|
|
34
|
+
private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
|
|
35
|
+
private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
|
|
36
|
+
private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
|
|
37
|
+
private eagerLoader: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
tableName: string,
|
|
41
|
+
executor: (sql: string, params: any[], raw: boolean) => any[],
|
|
42
|
+
singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
|
|
43
|
+
joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
|
|
44
|
+
conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
|
|
45
|
+
eagerLoader?: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null,
|
|
46
|
+
) {
|
|
47
|
+
this.tableName = tableName;
|
|
48
|
+
this.executor = executor;
|
|
49
|
+
this.singleExecutor = singleExecutor;
|
|
50
|
+
this.joinResolver = joinResolver ?? null;
|
|
51
|
+
this.conditionResolver = conditionResolver ?? null;
|
|
52
|
+
this.eagerLoader = eagerLoader ?? null;
|
|
53
|
+
this.iqo = {
|
|
54
|
+
selects: [],
|
|
55
|
+
wheres: [],
|
|
56
|
+
whereOrs: [],
|
|
57
|
+
whereAST: null,
|
|
58
|
+
joins: [],
|
|
59
|
+
groupBy: [],
|
|
60
|
+
having: [],
|
|
61
|
+
limit: null,
|
|
62
|
+
offset: null,
|
|
63
|
+
orderBy: [],
|
|
64
|
+
includes: [],
|
|
65
|
+
raw: false,
|
|
66
|
+
distinct: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Specify which columns to select. If called with no arguments, defaults to `*`. */
|
|
71
|
+
select(...cols: (keyof T & string)[]): this {
|
|
72
|
+
this.iqo.selects.push(...cols);
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Add WHERE conditions. Two calling styles:
|
|
78
|
+
*
|
|
79
|
+
* **Object-style** (simple equality and operators):
|
|
80
|
+
* ```ts
|
|
81
|
+
* .where({ name: 'Alice' })
|
|
82
|
+
* .where({ age: { $gt: 18 } })
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* **Callback-style** (AST-based, full SQL expression power):
|
|
86
|
+
* ```ts
|
|
87
|
+
* .where((c, f, op) => op.and(
|
|
88
|
+
* op.eq(f.lower(c.name), 'alice'),
|
|
89
|
+
* op.gt(c.age, 18)
|
|
90
|
+
* ))
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
where(criteriaOrCallback: (Partial<Record<keyof T & string, any>> & { $or?: Partial<Record<keyof T & string, any>>[] }) | WhereCallback<T>): this {
|
|
94
|
+
if (typeof criteriaOrCallback === 'function') {
|
|
95
|
+
const ast = (criteriaOrCallback as WhereCallback<T>)(
|
|
96
|
+
createColumnProxy<T>(),
|
|
97
|
+
createFunctionProxy(),
|
|
98
|
+
op,
|
|
99
|
+
);
|
|
100
|
+
if (this.iqo.whereAST) {
|
|
101
|
+
this.iqo.whereAST = { type: 'operator', op: 'AND', left: this.iqo.whereAST, right: ast };
|
|
102
|
+
} else {
|
|
103
|
+
this.iqo.whereAST = ast;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const resolved = this.conditionResolver
|
|
107
|
+
? this.conditionResolver(criteriaOrCallback as Record<string, any>)
|
|
108
|
+
: criteriaOrCallback;
|
|
109
|
+
|
|
110
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
111
|
+
if (key === '$or' && Array.isArray(value)) {
|
|
112
|
+
const orConditions: WhereCondition[] = [];
|
|
113
|
+
for (const branch of value as Record<string, any>[]) {
|
|
114
|
+
const resolvedBranch = this.conditionResolver
|
|
115
|
+
? this.conditionResolver(branch)
|
|
116
|
+
: branch;
|
|
117
|
+
for (const [bKey, bValue] of Object.entries(resolvedBranch)) {
|
|
118
|
+
if (typeof bValue === 'object' && bValue !== null && !Array.isArray(bValue) && !(bValue instanceof Date)) {
|
|
119
|
+
for (const [opKey, operand] of Object.entries(bValue)) {
|
|
120
|
+
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
121
|
+
if (sqlOp) orConditions.push({ field: bKey, operator: sqlOp as WhereCondition['operator'], value: operand });
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
orConditions.push({ field: bKey, operator: '=', value: bValue });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (orConditions.length > 0) this.iqo.whereOrs.push(orConditions);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
133
|
+
for (const [opKey, operand] of Object.entries(value)) {
|
|
134
|
+
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
135
|
+
if (!sqlOp) throw new Error(`Unsupported query operator: '${opKey}' on field '${key}'.`);
|
|
136
|
+
if (opKey === '$between') {
|
|
137
|
+
if (!Array.isArray(operand) || operand.length !== 2) throw new Error(`$between for '${key}' requires [min, max]`);
|
|
138
|
+
}
|
|
139
|
+
this.iqo.wheres.push({
|
|
140
|
+
field: key,
|
|
141
|
+
operator: sqlOp as WhereCondition['operator'],
|
|
142
|
+
value: operand,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
this.iqo.wheres.push({ field: key, operator: '=', value });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Set the maximum number of rows to return. */
|
|
154
|
+
limit(n: number): this {
|
|
155
|
+
this.iqo.limit = n;
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Set the offset for pagination. */
|
|
160
|
+
offset(n: number): this {
|
|
161
|
+
this.iqo.offset = n;
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Add ORDER BY clauses. */
|
|
166
|
+
orderBy(field: keyof T & string, direction: OrderDirection = 'asc'): this {
|
|
167
|
+
this.iqo.orderBy.push({ field, direction });
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Join another table. Two calling styles:
|
|
173
|
+
*
|
|
174
|
+
* **Accessor-based** (auto-infers FK from relationships):
|
|
175
|
+
* ```ts
|
|
176
|
+
* db.trees.select('name').join(db.forests, ['name']).all()
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* **String-based** (manual FK):
|
|
180
|
+
* ```ts
|
|
181
|
+
* db.trees.select('name').join('forests', 'forestId', ['name']).all()
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
join(accessor: { _tableName: string }, columns?: string[]): this;
|
|
185
|
+
join(table: string, fk: string, columns?: string[], pk?: string): this;
|
|
186
|
+
join(tableOrAccessor: string | { _tableName: string }, fkOrCols?: string | string[], colsOrPk?: string[] | string, pk?: string): this {
|
|
187
|
+
let table: string;
|
|
188
|
+
let fromCol: string;
|
|
189
|
+
let toCol: string;
|
|
190
|
+
let columns: string[];
|
|
191
|
+
|
|
192
|
+
if (typeof tableOrAccessor === 'object' && '_tableName' in tableOrAccessor) {
|
|
193
|
+
table = tableOrAccessor._tableName;
|
|
194
|
+
columns = Array.isArray(fkOrCols) ? fkOrCols : [];
|
|
195
|
+
if (!this.joinResolver) throw new Error(`Cannot auto-resolve join: no relationship data available`);
|
|
196
|
+
const resolved = this.joinResolver(this.tableName, table);
|
|
197
|
+
if (!resolved) throw new Error(`No relationship found between '${this.tableName}' and '${table}'`);
|
|
198
|
+
fromCol = resolved.fk;
|
|
199
|
+
toCol = resolved.pk;
|
|
200
|
+
} else {
|
|
201
|
+
table = tableOrAccessor;
|
|
202
|
+
fromCol = fkOrCols as string;
|
|
203
|
+
columns = Array.isArray(colsOrPk) ? colsOrPk : [];
|
|
204
|
+
toCol = (typeof colsOrPk === 'string' ? colsOrPk : pk) ?? 'id';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.iqo.joins.push({ table, fromCol, toCol, columns });
|
|
208
|
+
this.iqo.raw = true;
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Skip Zod parsing and return raw SQLite row objects. */
|
|
213
|
+
raw(): this {
|
|
214
|
+
this.iqo.raw = true;
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Eagerly load a related entity and attach as an array property.
|
|
220
|
+
*
|
|
221
|
+
* Runs a single batched query (WHERE fk IN (...)) per relation,
|
|
222
|
+
* avoiding the N+1 problem of lazy navigation.
|
|
223
|
+
*/
|
|
224
|
+
with(...relations: string[]): this {
|
|
225
|
+
this.iqo.includes.push(...relations);
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Internal: apply eager loads to a set of results */
|
|
230
|
+
private _applyEagerLoads(results: T[]): T[] {
|
|
231
|
+
if (this.iqo.includes.length === 0 || !this.eagerLoader || results.length === 0) {
|
|
232
|
+
return results;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const parentIds = results.map((r: any) => r.id).filter((id: any) => typeof id === 'number');
|
|
236
|
+
if (parentIds.length === 0) return results;
|
|
237
|
+
|
|
238
|
+
for (const relation of this.iqo.includes) {
|
|
239
|
+
const loaded = this.eagerLoader(this.tableName, relation, parentIds);
|
|
240
|
+
if (!loaded) continue;
|
|
241
|
+
|
|
242
|
+
for (const row of results as any[]) {
|
|
243
|
+
row[loaded.key] = loaded.groups.get(row.id) ?? [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return results;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------- Terminal / Execution Methods ----------
|
|
251
|
+
|
|
252
|
+
/** Execute the query and return all matching rows. */
|
|
253
|
+
all(): T[] {
|
|
254
|
+
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
255
|
+
const results = this.executor(sql, params, this.iqo.raw);
|
|
256
|
+
return this._applyEagerLoads(results);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Execute the query and return the first matching row, or null. */
|
|
260
|
+
get(): T | null {
|
|
261
|
+
this.iqo.limit = 1;
|
|
262
|
+
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
263
|
+
const result = this.singleExecutor(sql, params, this.iqo.raw);
|
|
264
|
+
if (!result) return null;
|
|
265
|
+
const [loaded] = this._applyEagerLoads([result]);
|
|
266
|
+
return loaded ?? null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Execute the query and return the count of matching rows. */
|
|
270
|
+
count(): number {
|
|
271
|
+
// Reuse compileIQO to avoid duplicating WHERE logic
|
|
272
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
273
|
+
// Replace "SELECT ... FROM" with "SELECT COUNT(*) as count FROM"
|
|
274
|
+
const countSql = selectSql.replace(/^SELECT .+? FROM/, 'SELECT COUNT(*) as count FROM');
|
|
275
|
+
const results = this.executor(countSql, params, true);
|
|
276
|
+
return (results[0] as any)?.count ?? 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Alias for get() — returns the first matching row or null. */
|
|
280
|
+
first(): T | null {
|
|
281
|
+
return this.get();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Returns true if at least one row matches the query. */
|
|
285
|
+
exists(): boolean {
|
|
286
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
287
|
+
const existsSql = selectSql.replace(/^SELECT .+? FROM/, 'SELECT 1 FROM').replace(/ LIMIT \d+/, '') + ' LIMIT 1';
|
|
288
|
+
const results = this.executor(existsSql, params, true);
|
|
289
|
+
return results.length > 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Group results by one or more columns. */
|
|
293
|
+
groupBy(...fields: string[]): this {
|
|
294
|
+
this.iqo.groupBy.push(...fields);
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Return only distinct rows. */
|
|
299
|
+
distinct(): this {
|
|
300
|
+
this.iqo.distinct = true;
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Include soft-deleted rows in query results.
|
|
306
|
+
* Only relevant when `softDeletes: true` is set in Database options.
|
|
307
|
+
*/
|
|
308
|
+
withTrashed(): this {
|
|
309
|
+
// Remove the auto-injected `deletedAt IS NULL` filter
|
|
310
|
+
this.iqo.wheres = this.iqo.wheres.filter(
|
|
311
|
+
w => !(w.field === 'deletedAt' && w.operator === 'IS NULL')
|
|
312
|
+
);
|
|
313
|
+
return this;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Add HAVING conditions (used after groupBy for aggregate filtering).
|
|
318
|
+
*
|
|
319
|
+
* ```ts
|
|
320
|
+
* db.orders.select('user_id').groupBy('user_id')
|
|
321
|
+
* .having({ 'COUNT(*)': { $gt: 5 } })
|
|
322
|
+
* .raw().all()
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
having(conditions: Record<string, any>): this {
|
|
326
|
+
for (const [field, value] of Object.entries(conditions)) {
|
|
327
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
328
|
+
for (const [opKey, operand] of Object.entries(value)) {
|
|
329
|
+
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
330
|
+
if (!sqlOp) throw new Error(`Unsupported having operator: '${opKey}'`);
|
|
331
|
+
this.iqo.having.push({ field, operator: sqlOp as WhereCondition['operator'], value: operand });
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
this.iqo.having.push({ field, operator: '=', value });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------- Aggregate Methods ----------
|
|
341
|
+
|
|
342
|
+
/** Returns the SUM of a numeric column. */
|
|
343
|
+
sum(field: keyof T & string): number {
|
|
344
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
345
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
|
|
346
|
+
const results = this.executor(aggSql, params, true);
|
|
347
|
+
return (results[0] as any)?.val ?? 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Returns the AVG of a numeric column. */
|
|
351
|
+
avg(field: keyof T & string): number {
|
|
352
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
353
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
|
|
354
|
+
const results = this.executor(aggSql, params, true);
|
|
355
|
+
return (results[0] as any)?.val ?? 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Returns the MIN of a column. */
|
|
359
|
+
min(field: keyof T & string): number | string | null {
|
|
360
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
361
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
|
|
362
|
+
const results = this.executor(aggSql, params, true);
|
|
363
|
+
return (results[0] as any)?.val ?? null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Returns the MAX of a column. */
|
|
367
|
+
max(field: keyof T & string): number | string | null {
|
|
368
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
369
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
|
|
370
|
+
const results = this.executor(aggSql, params, true);
|
|
371
|
+
return (results[0] as any)?.val ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Paginate results. Returns { data, total, page, perPage, pages }. */
|
|
375
|
+
paginate(page: number = 1, perPage: number = 20): { data: T[]; total: number; page: number; perPage: number; pages: number } {
|
|
376
|
+
const total = this.count();
|
|
377
|
+
const pages = Math.ceil(total / perPage);
|
|
378
|
+
this.iqo.limit = perPage;
|
|
379
|
+
this.iqo.offset = (page - 1) * perPage;
|
|
380
|
+
const data = this.all();
|
|
381
|
+
return { data, total, page, perPage, pages };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
// ---------- Thenable (async/await support) ----------
|
|
387
|
+
|
|
388
|
+
then<TResult1 = T[], TResult2 = never>(
|
|
389
|
+
onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
390
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
391
|
+
): Promise<TResult1 | TResult2> {
|
|
392
|
+
try {
|
|
393
|
+
const result = this.all();
|
|
394
|
+
return Promise.resolve(result).then(onfulfilled, onrejected);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
return Promise.reject(err).then(onfulfilled, onrejected);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -22,4 +22,13 @@ export interface DatabaseContext {
|
|
|
22
22
|
|
|
23
23
|
/** Build a WHERE clause from a conditions object. */
|
|
24
24
|
buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] };
|
|
25
|
+
|
|
26
|
+
/** Whether to log SQL to console. */
|
|
27
|
+
debug: boolean;
|
|
28
|
+
|
|
29
|
+
/** Whether tables have createdAt/updatedAt columns. */
|
|
30
|
+
timestamps: boolean;
|
|
31
|
+
|
|
32
|
+
/** Whether soft deletes are enabled (deletedAt column). */
|
|
33
|
+
softDeletes: boolean;
|
|
25
34
|
}
|
package/src/crud.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Each function accepts a `DatabaseContext` so it can access
|
|
5
5
|
* the db handle, schemas, and entity methods without tight coupling.
|
|
6
6
|
*/
|
|
7
|
-
import type { AugmentedEntity, UpdateBuilder } from './types';
|
|
7
|
+
import type { AugmentedEntity, UpdateBuilder, DeleteBuilder } from './types';
|
|
8
8
|
import { asZodObject } from './types';
|
|
9
9
|
import { transformForStorage, transformFromStorage } from './schema';
|
|
10
10
|
import type { DatabaseContext } from './context';
|
|
@@ -42,6 +42,14 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
42
42
|
const schema = ctx.schemas[entityName]!;
|
|
43
43
|
const validatedData = asZodObject(schema).passthrough().parse(data);
|
|
44
44
|
const transformed = transformForStorage(validatedData);
|
|
45
|
+
|
|
46
|
+
// Auto-inject timestamps
|
|
47
|
+
if (ctx.timestamps) {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
transformed.createdAt = now;
|
|
50
|
+
transformed.updatedAt = now;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const columns = Object.keys(transformed);
|
|
46
54
|
|
|
47
55
|
const quotedCols = columns.map(c => `"${c}"`);
|
|
@@ -49,6 +57,7 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
49
57
|
? `INSERT INTO "${entityName}" DEFAULT VALUES`
|
|
50
58
|
: `INSERT INTO "${entityName}" (${quotedCols.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
|
|
51
59
|
|
|
60
|
+
if (ctx.debug) console.log('[satidb]', sql, Object.values(transformed));
|
|
52
61
|
const result = ctx.db.query(sql).run(...Object.values(transformed));
|
|
53
62
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid as number);
|
|
54
63
|
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
@@ -60,10 +69,17 @@ export function update<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
60
69
|
const schema = ctx.schemas[entityName]!;
|
|
61
70
|
const validatedData = asZodObject(schema).partial().parse(data);
|
|
62
71
|
const transformed = transformForStorage(validatedData);
|
|
63
|
-
if (Object.keys(transformed).length === 0) return getById(ctx, entityName, id);
|
|
72
|
+
if (Object.keys(transformed).length === 0 && !ctx.timestamps) return getById(ctx, entityName, id);
|
|
73
|
+
|
|
74
|
+
// Auto-update timestamp
|
|
75
|
+
if (ctx.timestamps) {
|
|
76
|
+
transformed.updatedAt = new Date().toISOString();
|
|
77
|
+
}
|
|
64
78
|
|
|
65
79
|
const setClause = Object.keys(transformed).map(key => `"${key}" = ?`).join(', ');
|
|
66
|
-
|
|
80
|
+
const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
|
|
81
|
+
if (ctx.debug) console.log('[satidb]', sql, [...Object.values(transformed), id]);
|
|
82
|
+
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
67
83
|
|
|
68
84
|
return getById(ctx, entityName, id);
|
|
69
85
|
}
|
|
@@ -118,6 +134,24 @@ export function deleteEntity(ctx: DatabaseContext, entityName: string, id: numbe
|
|
|
118
134
|
ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
/** Delete all rows matching the given conditions. Returns the number of rows deleted. */
|
|
138
|
+
export function deleteWhere(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): number {
|
|
139
|
+
const { clause, values } = ctx.buildWhereClause(conditions);
|
|
140
|
+
if (!clause) throw new Error('delete().where() requires at least one condition');
|
|
141
|
+
const result = ctx.db.query(`DELETE FROM "${entityName}" ${clause}`).run(...values);
|
|
142
|
+
return (result as any).changes ?? 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Create a fluent delete builder: db.table.delete().where({...}).exec() */
|
|
146
|
+
export function createDeleteBuilder(ctx: DatabaseContext, entityName: string): DeleteBuilder<any> {
|
|
147
|
+
let _conditions: Record<string, any> = {};
|
|
148
|
+
const builder: DeleteBuilder<any> = {
|
|
149
|
+
where: (conditions) => { _conditions = { ..._conditions, ...conditions }; return builder; },
|
|
150
|
+
exec: () => deleteWhere(ctx, entityName, _conditions),
|
|
151
|
+
};
|
|
152
|
+
return builder;
|
|
153
|
+
}
|
|
154
|
+
|
|
121
155
|
/** Insert multiple rows in a single transaction for better performance. */
|
|
122
156
|
export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, rows: Omit<T, 'id'>[]): AugmentedEntity<any>[] {
|
|
123
157
|
if (rows.length === 0) return [];
|
|
@@ -129,6 +163,13 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
|
|
|
129
163
|
for (const data of rows) {
|
|
130
164
|
const validatedData = zodSchema.parse(data);
|
|
131
165
|
const transformed = transformForStorage(validatedData);
|
|
166
|
+
|
|
167
|
+
if (ctx.timestamps) {
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
transformed.createdAt = now;
|
|
170
|
+
transformed.updatedAt = now;
|
|
171
|
+
}
|
|
172
|
+
|
|
132
173
|
const columns = Object.keys(transformed);
|
|
133
174
|
const quotedCols = columns.map(c => `"${c}"`);
|
|
134
175
|
const sql = columns.length === 0
|
package/src/database.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { DatabaseContext } from './context';
|
|
|
24
24
|
import { buildWhereClause } from './helpers';
|
|
25
25
|
import { attachMethods } from './entity';
|
|
26
26
|
import {
|
|
27
|
-
insert, insertMany, update, upsert, deleteEntity,
|
|
27
|
+
insert, insertMany, update, upsert, deleteEntity, createDeleteBuilder,
|
|
28
28
|
getById, getOne, findMany, updateWhere, createUpdateBuilder,
|
|
29
29
|
} from './crud';
|
|
30
30
|
|
|
@@ -41,6 +41,9 @@ type Listener = {
|
|
|
41
41
|
class _Database<Schemas extends SchemaMap> {
|
|
42
42
|
private db: SqliteDatabase;
|
|
43
43
|
private _reactive: boolean;
|
|
44
|
+
private _timestamps: boolean;
|
|
45
|
+
private _softDeletes: boolean;
|
|
46
|
+
private _debug: boolean;
|
|
44
47
|
private schemas: Schemas;
|
|
45
48
|
private relationships: Relationship[];
|
|
46
49
|
private options: DatabaseOptions;
|
|
@@ -67,6 +70,9 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
67
70
|
this.schemas = schemas;
|
|
68
71
|
this.options = options;
|
|
69
72
|
this._reactive = options.reactive !== false; // default true
|
|
73
|
+
this._timestamps = options.timestamps === true;
|
|
74
|
+
this._softDeletes = options.softDeletes === true;
|
|
75
|
+
this._debug = options.debug === true;
|
|
70
76
|
this._pollInterval = options.pollInterval ?? 100;
|
|
71
77
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
72
78
|
|
|
@@ -77,6 +83,9 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
77
83
|
relationships: this.relationships,
|
|
78
84
|
attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
|
|
79
85
|
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
|
|
86
|
+
debug: this._debug,
|
|
87
|
+
timestamps: this._timestamps,
|
|
88
|
+
softDeletes: this._softDeletes,
|
|
80
89
|
};
|
|
81
90
|
|
|
82
91
|
this.initializeTables();
|
|
@@ -95,7 +104,18 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
95
104
|
return createUpdateBuilder(this._ctx, entityName, idOrData);
|
|
96
105
|
},
|
|
97
106
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
98
|
-
delete: (id) =>
|
|
107
|
+
delete: ((id?: any) => {
|
|
108
|
+
if (typeof id === 'number') {
|
|
109
|
+
if (this._softDeletes) {
|
|
110
|
+
// Soft delete: set deletedAt instead of removing
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
this.db.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return deleteEntity(this._ctx, entityName, id);
|
|
116
|
+
}
|
|
117
|
+
return createDeleteBuilder(this._ctx, entityName);
|
|
118
|
+
}) as any,
|
|
99
119
|
select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
|
|
100
120
|
on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
|
|
101
121
|
return this._registerListener(entityName, event, callback);
|
|
@@ -114,6 +134,17 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
114
134
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
115
135
|
const storableFields = getStorableFields(schema);
|
|
116
136
|
const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
|
|
137
|
+
|
|
138
|
+
// Add timestamp columns
|
|
139
|
+
if (this._timestamps) {
|
|
140
|
+
columnDefs.push('"createdAt" TEXT');
|
|
141
|
+
columnDefs.push('"updatedAt" TEXT');
|
|
142
|
+
}
|
|
143
|
+
// Add soft delete column
|
|
144
|
+
if (this._softDeletes) {
|
|
145
|
+
columnDefs.push('"deletedAt" TEXT');
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
const constraints: string[] = [];
|
|
118
149
|
|
|
119
150
|
const belongsToRels = this.relationships.filter(
|
|
@@ -303,9 +334,28 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
303
334
|
return executeProxyQuery(
|
|
304
335
|
this.schemas,
|
|
305
336
|
callback as any,
|
|
306
|
-
(sql: string, params: any[]) =>
|
|
337
|
+
(sql: string, params: any[]) => {
|
|
338
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
339
|
+
return this.db.query(sql).all(...params) as T[];
|
|
340
|
+
},
|
|
307
341
|
);
|
|
308
342
|
}
|
|
343
|
+
|
|
344
|
+
// =========================================================================
|
|
345
|
+
// Raw SQL
|
|
346
|
+
// =========================================================================
|
|
347
|
+
|
|
348
|
+
/** Execute a raw SQL query and return results. */
|
|
349
|
+
public raw<T = any>(sql: string, ...params: any[]): T[] {
|
|
350
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
351
|
+
return this.db.query(sql).all(...params) as T[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Execute a raw SQL statement (INSERT/UPDATE/DELETE) without returning rows. */
|
|
355
|
+
public exec(sql: string, ...params: any[]): void {
|
|
356
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
357
|
+
this.db.run(sql, ...params);
|
|
358
|
+
}
|
|
309
359
|
}
|
|
310
360
|
|
|
311
361
|
// =============================================================================
|
package/src/helpers.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { transformForStorage } from './schema';
|
|
|
13
13
|
* - Operators: `{ age: { $gt: 18 } }`
|
|
14
14
|
* - $in: `{ status: { $in: ['active', 'pending'] } }`
|
|
15
15
|
* - $or: `{ $or: [{ name: 'Alice' }, { name: 'Bob' }] }`
|
|
16
|
+
* - $isNull / $isNotNull: `{ deletedAt: { $isNull: true } }`
|
|
16
17
|
*/
|
|
17
18
|
export function buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] } {
|
|
18
19
|
const parts: string[] = [];
|
|
@@ -73,6 +74,16 @@ export function buildWhereClause(conditions: Record<string, any>, tablePrefix?:
|
|
|
73
74
|
continue;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
if (operator === '$isNull') {
|
|
78
|
+
parts.push(`${fieldName} IS NULL`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (operator === '$isNotNull') {
|
|
83
|
+
parts.push(`${fieldName} IS NOT NULL`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
const sqlOp = ({ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=' } as Record<string, string>)[operator];
|
|
77
88
|
if (!sqlOp) throw new Error(`Unsupported operator '${operator}' on '${key}'`);
|
|
78
89
|
parts.push(`${fieldName} ${sqlOp} ?`);
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { DatabaseType } from './database';
|
|
|
8
8
|
|
|
9
9
|
export type {
|
|
10
10
|
SchemaMap, DatabaseOptions, Relationship,
|
|
11
|
-
EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder,
|
|
11
|
+
EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder, DeleteBuilder,
|
|
12
12
|
InferSchema, EntityData, IndexDef, ChangeEvent,
|
|
13
13
|
ProxyColumns, ColumnRef,
|
|
14
14
|
} from './types';
|