sqlite-zod-orm 3.10.0 → 3.12.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 +48 -381
- package/dist/index.js +195 -10
- package/package.json +1 -1
- package/src/builder.ts +101 -0
- package/src/context.ts +9 -0
- package/src/crud.ts +39 -4
- package/src/database.ts +79 -3
- package/src/helpers.ts +11 -0
- package/src/index.ts +1 -1
- package/src/iqo.ts +29 -3
- package/src/query.ts +7 -0
- package/src/types.ts +27 -1
package/src/database.ts
CHANGED
|
@@ -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,12 +83,16 @@ 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();
|
|
83
92
|
if (this._reactive) this.initializeChangeTracking();
|
|
84
93
|
this.runMigrations();
|
|
85
94
|
if (options.indexes) this.createIndexes(options.indexes);
|
|
95
|
+
if (options.unique) this.createUniqueConstraints(options.unique);
|
|
86
96
|
|
|
87
97
|
// Create typed entity accessors (db.users, db.posts, etc.)
|
|
88
98
|
for (const entityName of Object.keys(schemas)) {
|
|
@@ -96,9 +106,22 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
96
106
|
},
|
|
97
107
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
98
108
|
delete: ((id?: any) => {
|
|
99
|
-
if (typeof id === 'number')
|
|
109
|
+
if (typeof id === 'number') {
|
|
110
|
+
if (this._softDeletes) {
|
|
111
|
+
// Soft delete: set deletedAt instead of removing
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
return deleteEntity(this._ctx, entityName, id);
|
|
117
|
+
}
|
|
100
118
|
return createDeleteBuilder(this._ctx, entityName);
|
|
101
119
|
}) as any,
|
|
120
|
+
restore: ((id: number) => {
|
|
121
|
+
if (!this._softDeletes) throw new Error('restore() requires softDeletes: true');
|
|
122
|
+
if (this._debug) console.log('[satidb]', `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
|
|
123
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
|
|
124
|
+
}) as any,
|
|
102
125
|
select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
|
|
103
126
|
on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
|
|
104
127
|
return this._registerListener(entityName, event, callback);
|
|
@@ -117,6 +140,17 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
117
140
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
118
141
|
const storableFields = getStorableFields(schema);
|
|
119
142
|
const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
|
|
143
|
+
|
|
144
|
+
// Add timestamp columns
|
|
145
|
+
if (this._timestamps) {
|
|
146
|
+
columnDefs.push('"createdAt" TEXT');
|
|
147
|
+
columnDefs.push('"updatedAt" TEXT');
|
|
148
|
+
}
|
|
149
|
+
// Add soft delete column
|
|
150
|
+
if (this._softDeletes) {
|
|
151
|
+
columnDefs.push('"deletedAt" TEXT');
|
|
152
|
+
}
|
|
153
|
+
|
|
120
154
|
const constraints: string[] = [];
|
|
121
155
|
|
|
122
156
|
const belongsToRels = this.relationships.filter(
|
|
@@ -200,6 +234,15 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
200
234
|
}
|
|
201
235
|
}
|
|
202
236
|
|
|
237
|
+
private createUniqueConstraints(unique: Record<string, string[][]>): void {
|
|
238
|
+
for (const [tableName, groups] of Object.entries(unique)) {
|
|
239
|
+
for (const cols of groups) {
|
|
240
|
+
const idxName = `uq_${tableName}_${cols.join('_')}`;
|
|
241
|
+
this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map(c => `"${c}"`).join(', ')})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
203
246
|
// =========================================================================
|
|
204
247
|
// Change Listeners — db.table.on('insert' | 'update' | 'delete', cb)
|
|
205
248
|
// =========================================================================
|
|
@@ -278,7 +321,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
278
321
|
}
|
|
279
322
|
|
|
280
323
|
// Clean up consumed changes
|
|
281
|
-
this.db.
|
|
324
|
+
this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
|
|
282
325
|
}
|
|
283
326
|
|
|
284
327
|
// =========================================================================
|
|
@@ -306,9 +349,42 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
306
349
|
return executeProxyQuery(
|
|
307
350
|
this.schemas,
|
|
308
351
|
callback as any,
|
|
309
|
-
(sql: string, params: any[]) =>
|
|
352
|
+
(sql: string, params: any[]) => {
|
|
353
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
354
|
+
return this.db.query(sql).all(...params) as T[];
|
|
355
|
+
},
|
|
310
356
|
);
|
|
311
357
|
}
|
|
358
|
+
|
|
359
|
+
// =========================================================================
|
|
360
|
+
// Raw SQL
|
|
361
|
+
// =========================================================================
|
|
362
|
+
|
|
363
|
+
/** Execute a raw SQL query and return results. */
|
|
364
|
+
public raw<T = any>(sql: string, ...params: any[]): T[] {
|
|
365
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
366
|
+
return this.db.query(sql).all(...params) as T[];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Execute a raw SQL statement (INSERT/UPDATE/DELETE) without returning rows. */
|
|
370
|
+
public exec(sql: string, ...params: any[]): void {
|
|
371
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
372
|
+
this.db.run(sql, ...params);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// =========================================================================
|
|
376
|
+
// Schema Introspection
|
|
377
|
+
// =========================================================================
|
|
378
|
+
|
|
379
|
+
/** Return the list of user-defined table names. */
|
|
380
|
+
public tables(): string[] {
|
|
381
|
+
return Object.keys(this.schemas);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Return column info for a table via PRAGMA table_info. */
|
|
385
|
+
public columns(tableName: string): { name: string; type: string; notnull: number; pk: number }[] {
|
|
386
|
+
return this.db.query(`PRAGMA table_info("${tableName}")`).all() as any[];
|
|
387
|
+
}
|
|
312
388
|
}
|
|
313
389
|
|
|
314
390
|
// =============================================================================
|
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';
|
package/src/iqo.ts
CHANGED
|
@@ -12,11 +12,11 @@ import { type ASTNode, compileAST } from './ast';
|
|
|
12
12
|
// =============================================================================
|
|
13
13
|
|
|
14
14
|
export type OrderDirection = 'asc' | 'desc';
|
|
15
|
-
export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between';
|
|
15
|
+
export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between' | '$isNull' | '$isNotNull';
|
|
16
16
|
|
|
17
17
|
export interface WhereCondition {
|
|
18
18
|
field: string;
|
|
19
|
-
operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN';
|
|
19
|
+
operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN' | 'IS NULL' | 'IS NOT NULL';
|
|
20
20
|
value: any;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -34,11 +34,13 @@ export interface IQO {
|
|
|
34
34
|
whereAST: ASTNode | null;
|
|
35
35
|
joins: JoinClause[];
|
|
36
36
|
groupBy: string[];
|
|
37
|
+
having: WhereCondition[];
|
|
37
38
|
limit: number | null;
|
|
38
39
|
offset: number | null;
|
|
39
40
|
orderBy: { field: string; direction: OrderDirection }[];
|
|
40
41
|
includes: string[];
|
|
41
42
|
raw: boolean;
|
|
43
|
+
distinct: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export const OPERATOR_MAP: Record<WhereOperator, string> = {
|
|
@@ -51,6 +53,8 @@ export const OPERATOR_MAP: Record<WhereOperator, string> = {
|
|
|
51
53
|
$like: 'LIKE',
|
|
52
54
|
$notIn: 'NOT IN',
|
|
53
55
|
$between: 'BETWEEN',
|
|
56
|
+
$isNull: 'IS NULL',
|
|
57
|
+
$isNotNull: 'IS NOT NULL',
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
export function transformValueForStorage(value: any): any {
|
|
@@ -81,7 +85,7 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
|
|
88
|
+
let sql = `SELECT ${iqo.distinct ? 'DISTINCT ' : ''}${selectParts.join(', ')} FROM ${tableName}`;
|
|
85
89
|
|
|
86
90
|
// JOIN clauses
|
|
87
91
|
for (const j of iqo.joins) {
|
|
@@ -119,6 +123,10 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
119
123
|
const [min, max] = w.value as [any, any];
|
|
120
124
|
whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
|
|
121
125
|
params.push(transformValueForStorage(min), transformValueForStorage(max));
|
|
126
|
+
} else if (w.operator === 'IS NULL') {
|
|
127
|
+
whereParts.push(`${qualify(w.field)} IS NULL`);
|
|
128
|
+
} else if (w.operator === 'IS NOT NULL') {
|
|
129
|
+
whereParts.push(`${qualify(w.field)} IS NOT NULL`);
|
|
122
130
|
} else {
|
|
123
131
|
whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
|
|
124
132
|
params.push(transformValueForStorage(w.value));
|
|
@@ -159,6 +167,24 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
159
167
|
sql += ` GROUP BY ${iqo.groupBy.join(', ')}`;
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
// HAVING
|
|
171
|
+
if (iqo.having && iqo.having.length > 0) {
|
|
172
|
+
const havingParts: string[] = [];
|
|
173
|
+
for (const h of iqo.having) {
|
|
174
|
+
if (h.operator === 'IS NULL') {
|
|
175
|
+
havingParts.push(`${h.field} IS NULL`);
|
|
176
|
+
} else if (h.operator === 'IS NOT NULL') {
|
|
177
|
+
havingParts.push(`${h.field} IS NOT NULL`);
|
|
178
|
+
} else {
|
|
179
|
+
havingParts.push(`${h.field} ${h.operator} ?`);
|
|
180
|
+
params.push(transformValueForStorage(h.value));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (havingParts.length > 0) {
|
|
184
|
+
sql += ` HAVING ${havingParts.join(' AND ')}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
162
188
|
// ORDER BY
|
|
163
189
|
if (iqo.orderBy.length > 0) {
|
|
164
190
|
const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
|
package/src/query.ts
CHANGED
|
@@ -39,6 +39,7 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
|
|
|
39
39
|
const schema = ctx.schemas[entityName]!;
|
|
40
40
|
|
|
41
41
|
const executor = (sql: string, params: any[], raw: boolean): any[] => {
|
|
42
|
+
if (ctx.debug) console.log('[satidb]', sql, params);
|
|
42
43
|
const rows = ctx.db.query(sql).all(...params);
|
|
43
44
|
if (raw) return rows;
|
|
44
45
|
return rows.map((row: any) => ctx.attachMethods(entityName, transformFromStorage(row, schema)));
|
|
@@ -132,5 +133,11 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
|
|
|
132
133
|
|
|
133
134
|
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
|
|
134
135
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
136
|
+
|
|
137
|
+
// Auto-filter soft-deleted rows unless withTrashed() is called
|
|
138
|
+
if (ctx.softDeletes) {
|
|
139
|
+
builder.where({ deletedAt: { $isNull: true } });
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
return builder;
|
|
136
143
|
}
|
package/src/types.ts
CHANGED
|
@@ -19,9 +19,14 @@ export const asZodObject = (s: z.ZodType<any>) => s as unknown as z.ZodObject<an
|
|
|
19
19
|
/** Index definition: single column or composite columns */
|
|
20
20
|
export type IndexDef = string | string[];
|
|
21
21
|
|
|
22
|
-
/** Options for the Database constructor */
|
|
23
22
|
export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
24
23
|
indexes?: Record<string, IndexDef[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Unique constraints per table. Each entry is an array of column groups.
|
|
26
|
+
* Single column: `{ users: [['email']] }` → UNIQUE INDEX.
|
|
27
|
+
* Compound: `{ users: [['email'], ['name', 'org_id']] }` → two UNIQUE indexes.
|
|
28
|
+
*/
|
|
29
|
+
unique?: Record<string, string[][]>;
|
|
25
30
|
/**
|
|
26
31
|
* Declare relationships between tables.
|
|
27
32
|
*
|
|
@@ -42,6 +47,24 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
|
42
47
|
* `.on()` will throw. Default: `true`.
|
|
43
48
|
*/
|
|
44
49
|
reactive?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Auto-add `createdAt` and `updatedAt` TEXT columns to every table.
|
|
52
|
+
* `createdAt` is set on insert, `updatedAt` on insert + update.
|
|
53
|
+
* Default: `false`.
|
|
54
|
+
*/
|
|
55
|
+
timestamps?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Enable soft deletes. Adds a `deletedAt` TEXT column to every table.
|
|
58
|
+
* `delete()` sets `deletedAt` instead of removing the row.
|
|
59
|
+
* Use `.withTrashed()` on queries to include soft-deleted rows.
|
|
60
|
+
* Default: `false`.
|
|
61
|
+
*/
|
|
62
|
+
softDeletes?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Log every SQL query to the console. Useful for debugging.
|
|
65
|
+
* Default: `false`.
|
|
66
|
+
*/
|
|
67
|
+
debug?: boolean;
|
|
45
68
|
};
|
|
46
69
|
|
|
47
70
|
export type Relationship = {
|
|
@@ -162,6 +185,7 @@ export type NavEntityAccessor<
|
|
|
162
185
|
& ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
|
|
163
186
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
164
187
|
delete: ((id: number) => void) & (() => DeleteBuilder<NavEntity<S, R, Table>>);
|
|
188
|
+
restore: (id: number) => void;
|
|
165
189
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
166
190
|
on: ((event: 'insert' | 'update', callback: (row: NavEntity<S, R, Table>) => void | Promise<void>) => () => void) &
|
|
167
191
|
((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
|
|
@@ -192,6 +216,8 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
192
216
|
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
193
217
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
194
218
|
delete: ((id: number) => void) & (() => DeleteBuilder<AugmentedEntity<S>>);
|
|
219
|
+
/** Undo a soft delete by setting deletedAt = null. Requires softDeletes. */
|
|
220
|
+
restore: (id: number) => void;
|
|
195
221
|
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
196
222
|
on: ((event: 'insert' | 'update', callback: (row: AugmentedEntity<S>) => void | Promise<void>) => () => void) &
|
|
197
223
|
((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
|