sqlite-zod-orm 3.12.0 → 3.14.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 +112 -7
- package/package.json +1 -1
- package/src/builder.ts +36 -0
- package/src/context.ts +4 -1
- package/src/crud.ts +86 -6
- package/src/database.ts +11 -1
- package/src/iqo.ts +9 -0
- package/src/schema.ts +21 -0
- package/src/types.ts +25 -0
package/dist/index.js
CHANGED
|
@@ -4042,6 +4042,8 @@ function zodTypeToSqlType(zodType) {
|
|
|
4042
4042
|
return "TEXT";
|
|
4043
4043
|
if (zodType instanceof exports_external.ZodNumber || zodType instanceof exports_external.ZodBoolean)
|
|
4044
4044
|
return "INTEGER";
|
|
4045
|
+
if (zodType instanceof exports_external.ZodEnum)
|
|
4046
|
+
return "TEXT";
|
|
4045
4047
|
if (zodType._def.typeName === "ZodInstanceOf" && zodType._def.type === Buffer)
|
|
4046
4048
|
return "BLOB";
|
|
4047
4049
|
return "TEXT";
|
|
@@ -4053,6 +4055,8 @@ function transformForStorage(data) {
|
|
|
4053
4055
|
transformed[key] = value.toISOString();
|
|
4054
4056
|
} else if (typeof value === "boolean") {
|
|
4055
4057
|
transformed[key] = value ? 1 : 0;
|
|
4058
|
+
} else if (value !== null && value !== undefined && typeof value === "object" && !(value instanceof Buffer)) {
|
|
4059
|
+
transformed[key] = JSON.stringify(value);
|
|
4056
4060
|
} else {
|
|
4057
4061
|
transformed[key] = value;
|
|
4058
4062
|
}
|
|
@@ -4073,12 +4077,23 @@ function transformFromStorage(row, schema) {
|
|
|
4073
4077
|
transformed[key] = new Date(value);
|
|
4074
4078
|
} else if (fieldSchema instanceof exports_external.ZodBoolean && typeof value === "number") {
|
|
4075
4079
|
transformed[key] = value === 1;
|
|
4080
|
+
} else if (isJsonSchema(fieldSchema) && typeof value === "string") {
|
|
4081
|
+
try {
|
|
4082
|
+
transformed[key] = JSON.parse(value);
|
|
4083
|
+
} catch {
|
|
4084
|
+
transformed[key] = value;
|
|
4085
|
+
}
|
|
4076
4086
|
} else {
|
|
4077
4087
|
transformed[key] = value;
|
|
4078
4088
|
}
|
|
4079
4089
|
}
|
|
4080
4090
|
return transformed;
|
|
4081
4091
|
}
|
|
4092
|
+
function isJsonSchema(schema) {
|
|
4093
|
+
if (!schema)
|
|
4094
|
+
return false;
|
|
4095
|
+
return schema instanceof exports_external.ZodObject || schema instanceof exports_external.ZodArray || schema instanceof exports_external.ZodRecord || schema instanceof exports_external.ZodTuple || schema instanceof exports_external.ZodUnion || schema instanceof exports_external.ZodDiscriminatedUnion;
|
|
4096
|
+
}
|
|
4082
4097
|
|
|
4083
4098
|
// src/ast.ts
|
|
4084
4099
|
var wrapNode = (val) => val !== null && typeof val === "object" && ("type" in val) ? val : { type: "literal", value: val };
|
|
@@ -4244,6 +4259,12 @@ function compileIQO(tableName, iqo) {
|
|
|
4244
4259
|
}
|
|
4245
4260
|
}
|
|
4246
4261
|
}
|
|
4262
|
+
if (iqo.rawWheres && iqo.rawWheres.length > 0) {
|
|
4263
|
+
for (const rw of iqo.rawWheres) {
|
|
4264
|
+
sql += sql.includes(" WHERE ") ? ` AND (${rw.sql})` : ` WHERE (${rw.sql})`;
|
|
4265
|
+
params.push(...rw.params);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4247
4268
|
if (iqo.groupBy.length > 0) {
|
|
4248
4269
|
sql += ` GROUP BY ${iqo.groupBy.join(", ")}`;
|
|
4249
4270
|
}
|
|
@@ -4293,6 +4314,7 @@ class QueryBuilder {
|
|
|
4293
4314
|
selects: [],
|
|
4294
4315
|
wheres: [],
|
|
4295
4316
|
whereOrs: [],
|
|
4317
|
+
rawWheres: [],
|
|
4296
4318
|
whereAST: null,
|
|
4297
4319
|
joins: [],
|
|
4298
4320
|
groupBy: [],
|
|
@@ -4403,6 +4425,10 @@ class QueryBuilder {
|
|
|
4403
4425
|
this.iqo.raw = true;
|
|
4404
4426
|
return this;
|
|
4405
4427
|
}
|
|
4428
|
+
whereRaw(sql, params = []) {
|
|
4429
|
+
this.iqo.rawWheres.push({ sql, params });
|
|
4430
|
+
return this;
|
|
4431
|
+
}
|
|
4406
4432
|
with(...relations) {
|
|
4407
4433
|
this.iqo.includes.push(...relations);
|
|
4408
4434
|
return this;
|
|
@@ -4517,6 +4543,15 @@ class QueryBuilder {
|
|
|
4517
4543
|
const data = this.all();
|
|
4518
4544
|
return { data, total, page, perPage, pages };
|
|
4519
4545
|
}
|
|
4546
|
+
countGrouped() {
|
|
4547
|
+
if (this.iqo.groupBy.length === 0) {
|
|
4548
|
+
throw new Error("countGrouped() requires at least one groupBy() call");
|
|
4549
|
+
}
|
|
4550
|
+
const groupCols = this.iqo.groupBy.map((c) => `"${c}"`).join(", ");
|
|
4551
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
4552
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT ${groupCols}, COUNT(*) as count FROM`);
|
|
4553
|
+
return this.executor(aggSql, params, true);
|
|
4554
|
+
}
|
|
4520
4555
|
then(onfulfilled, onrejected) {
|
|
4521
4556
|
try {
|
|
4522
4557
|
const result = this.all();
|
|
@@ -4902,7 +4937,14 @@ function findMany(ctx, entityName, conditions = {}) {
|
|
|
4902
4937
|
}
|
|
4903
4938
|
function insert(ctx, entityName, data) {
|
|
4904
4939
|
const schema = ctx.schemas[entityName];
|
|
4905
|
-
|
|
4940
|
+
let inputData = { ...data };
|
|
4941
|
+
const hooks = ctx.hooks[entityName];
|
|
4942
|
+
if (hooks?.beforeInsert) {
|
|
4943
|
+
const result2 = hooks.beforeInsert(inputData);
|
|
4944
|
+
if (result2)
|
|
4945
|
+
inputData = result2;
|
|
4946
|
+
}
|
|
4947
|
+
const validatedData = asZodObject(schema).passthrough().parse(inputData);
|
|
4906
4948
|
const transformed = transformForStorage(validatedData);
|
|
4907
4949
|
if (ctx.timestamps) {
|
|
4908
4950
|
const now = new Date().toISOString();
|
|
@@ -4918,11 +4960,20 @@ function insert(ctx, entityName, data) {
|
|
|
4918
4960
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid);
|
|
4919
4961
|
if (!newEntity)
|
|
4920
4962
|
throw new Error("Failed to retrieve entity after insertion");
|
|
4963
|
+
if (hooks?.afterInsert)
|
|
4964
|
+
hooks.afterInsert(newEntity);
|
|
4921
4965
|
return newEntity;
|
|
4922
4966
|
}
|
|
4923
4967
|
function update(ctx, entityName, id, data) {
|
|
4924
4968
|
const schema = ctx.schemas[entityName];
|
|
4925
|
-
|
|
4969
|
+
let inputData = { ...data };
|
|
4970
|
+
const hooks = ctx.hooks[entityName];
|
|
4971
|
+
if (hooks?.beforeUpdate) {
|
|
4972
|
+
const result = hooks.beforeUpdate(inputData, id);
|
|
4973
|
+
if (result)
|
|
4974
|
+
inputData = result;
|
|
4975
|
+
}
|
|
4976
|
+
const validatedData = asZodObject(schema).partial().parse(inputData);
|
|
4926
4977
|
const transformed = transformForStorage(validatedData);
|
|
4927
4978
|
if (Object.keys(transformed).length === 0 && !ctx.timestamps)
|
|
4928
4979
|
return getById(ctx, entityName, id);
|
|
@@ -4934,7 +4985,10 @@ function update(ctx, entityName, id, data) {
|
|
|
4934
4985
|
if (ctx.debug)
|
|
4935
4986
|
console.log("[satidb]", sql, [...Object.values(transformed), id]);
|
|
4936
4987
|
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
4937
|
-
|
|
4988
|
+
const updated = getById(ctx, entityName, id);
|
|
4989
|
+
if (hooks?.afterUpdate && updated)
|
|
4990
|
+
hooks.afterUpdate(updated);
|
|
4991
|
+
return updated;
|
|
4938
4992
|
}
|
|
4939
4993
|
function updateWhere(ctx, entityName, data, conditions) {
|
|
4940
4994
|
const schema = ctx.schemas[entityName];
|
|
@@ -4973,8 +5027,24 @@ function upsert(ctx, entityName, data, conditions = {}) {
|
|
|
4973
5027
|
delete insertData.id;
|
|
4974
5028
|
return insert(ctx, entityName, insertData);
|
|
4975
5029
|
}
|
|
5030
|
+
function findOrCreate(ctx, entityName, conditions, defaults = {}) {
|
|
5031
|
+
const existing = getOne(ctx, entityName, conditions);
|
|
5032
|
+
if (existing)
|
|
5033
|
+
return { entity: existing, created: false };
|
|
5034
|
+
const data = { ...conditions, ...defaults };
|
|
5035
|
+
delete data.id;
|
|
5036
|
+
return { entity: insert(ctx, entityName, data), created: true };
|
|
5037
|
+
}
|
|
4976
5038
|
function deleteEntity(ctx, entityName, id) {
|
|
5039
|
+
const hooks = ctx.hooks[entityName];
|
|
5040
|
+
if (hooks?.beforeDelete) {
|
|
5041
|
+
const result = hooks.beforeDelete(id);
|
|
5042
|
+
if (result === false)
|
|
5043
|
+
return;
|
|
5044
|
+
}
|
|
4977
5045
|
ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
|
|
5046
|
+
if (hooks?.afterDelete)
|
|
5047
|
+
hooks.afterDelete(id);
|
|
4978
5048
|
}
|
|
4979
5049
|
function deleteWhere(ctx, entityName, conditions) {
|
|
4980
5050
|
const { clause, values } = ctx.buildWhereClause(conditions);
|
|
@@ -5010,10 +5080,17 @@ function insertMany(ctx, entityName, rows) {
|
|
|
5010
5080
|
return [];
|
|
5011
5081
|
const schema = ctx.schemas[entityName];
|
|
5012
5082
|
const zodSchema = asZodObject(schema).passthrough();
|
|
5083
|
+
const hooks = ctx.hooks[entityName];
|
|
5013
5084
|
const txn = ctx.db.transaction(() => {
|
|
5014
5085
|
const ids2 = [];
|
|
5015
|
-
for (
|
|
5016
|
-
|
|
5086
|
+
for (let data of rows) {
|
|
5087
|
+
let inputData = { ...data };
|
|
5088
|
+
if (hooks?.beforeInsert) {
|
|
5089
|
+
const result2 = hooks.beforeInsert(inputData);
|
|
5090
|
+
if (result2)
|
|
5091
|
+
inputData = result2;
|
|
5092
|
+
}
|
|
5093
|
+
const validatedData = zodSchema.parse(inputData);
|
|
5017
5094
|
const transformed = transformForStorage(validatedData);
|
|
5018
5095
|
if (ctx.timestamps) {
|
|
5019
5096
|
const now = new Date().toISOString();
|
|
@@ -5029,7 +5106,24 @@ function insertMany(ctx, entityName, rows) {
|
|
|
5029
5106
|
return ids2;
|
|
5030
5107
|
});
|
|
5031
5108
|
const ids = txn();
|
|
5032
|
-
|
|
5109
|
+
const entities = ids.map((id) => getById(ctx, entityName, id)).filter(Boolean);
|
|
5110
|
+
if (hooks?.afterInsert) {
|
|
5111
|
+
for (const entity of entities)
|
|
5112
|
+
hooks.afterInsert(entity);
|
|
5113
|
+
}
|
|
5114
|
+
return entities;
|
|
5115
|
+
}
|
|
5116
|
+
function upsertMany(ctx, entityName, rows, conditions = {}) {
|
|
5117
|
+
if (rows.length === 0)
|
|
5118
|
+
return [];
|
|
5119
|
+
const txn = ctx.db.transaction(() => {
|
|
5120
|
+
const results = [];
|
|
5121
|
+
for (const data of rows) {
|
|
5122
|
+
results.push(upsert(ctx, entityName, data, conditions));
|
|
5123
|
+
}
|
|
5124
|
+
return results;
|
|
5125
|
+
});
|
|
5126
|
+
return txn();
|
|
5033
5127
|
}
|
|
5034
5128
|
|
|
5035
5129
|
// src/entity.ts
|
|
@@ -5101,7 +5195,8 @@ class _Database {
|
|
|
5101
5195
|
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
|
|
5102
5196
|
debug: this._debug,
|
|
5103
5197
|
timestamps: this._timestamps,
|
|
5104
|
-
softDeletes: this._softDeletes
|
|
5198
|
+
softDeletes: this._softDeletes,
|
|
5199
|
+
hooks: options.hooks ?? {}
|
|
5105
5200
|
};
|
|
5106
5201
|
this.initializeTables();
|
|
5107
5202
|
if (this._reactive)
|
|
@@ -5122,11 +5217,21 @@ class _Database {
|
|
|
5122
5217
|
return createUpdateBuilder(this._ctx, entityName, idOrData);
|
|
5123
5218
|
},
|
|
5124
5219
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
5220
|
+
upsertMany: (rows, conditions) => upsertMany(this._ctx, entityName, rows, conditions),
|
|
5221
|
+
findOrCreate: (conditions, defaults) => findOrCreate(this._ctx, entityName, conditions, defaults),
|
|
5125
5222
|
delete: (id) => {
|
|
5126
5223
|
if (typeof id === "number") {
|
|
5224
|
+
const hooks = this._ctx.hooks[entityName];
|
|
5225
|
+
if (hooks?.beforeDelete) {
|
|
5226
|
+
const result = hooks.beforeDelete(id);
|
|
5227
|
+
if (result === false)
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5127
5230
|
if (this._softDeletes) {
|
|
5128
5231
|
const now = new Date().toISOString();
|
|
5129
5232
|
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
5233
|
+
if (hooks?.afterDelete)
|
|
5234
|
+
hooks.afterDelete(id);
|
|
5130
5235
|
return;
|
|
5131
5236
|
}
|
|
5132
5237
|
return deleteEntity(this._ctx, entityName, id);
|
package/package.json
CHANGED
package/src/builder.ts
CHANGED
|
@@ -54,6 +54,7 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
54
54
|
selects: [],
|
|
55
55
|
wheres: [],
|
|
56
56
|
whereOrs: [],
|
|
57
|
+
rawWheres: [],
|
|
57
58
|
whereAST: null,
|
|
58
59
|
joins: [],
|
|
59
60
|
groupBy: [],
|
|
@@ -215,6 +216,19 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
215
216
|
return this;
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Add a raw SQL WHERE fragment with parameterized values.
|
|
221
|
+
* Can be combined with `.where()` — fragments are AND'd together.
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* db.users.select().whereRaw('score > ? AND role != ?', [50, 'guest']).all()
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
whereRaw(sql: string, params: any[] = []): this {
|
|
228
|
+
this.iqo.rawWheres.push({ sql, params });
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
|
|
218
232
|
/**
|
|
219
233
|
* Eagerly load a related entity and attach as an array property.
|
|
220
234
|
*
|
|
@@ -394,6 +408,28 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
394
408
|
return { data, total, page, perPage, pages };
|
|
395
409
|
}
|
|
396
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Count rows per group. Must call `.groupBy()` first.
|
|
413
|
+
* Returns an array of objects with the grouped column(s) and a `count` field.
|
|
414
|
+
*
|
|
415
|
+
* ```ts
|
|
416
|
+
* db.users.select('role').groupBy('role').countGrouped()
|
|
417
|
+
* // → [{ role: 'admin', count: 5 }, { role: 'member', count: 12 }]
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
countGrouped(): (Record<string, any> & { count: number })[] {
|
|
421
|
+
if (this.iqo.groupBy.length === 0) {
|
|
422
|
+
throw new Error('countGrouped() requires at least one groupBy() call');
|
|
423
|
+
}
|
|
424
|
+
const groupCols = this.iqo.groupBy.map(c => `"${c}"`).join(', ');
|
|
425
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
426
|
+
const aggSql = selectSql.replace(
|
|
427
|
+
/^SELECT .+? FROM/,
|
|
428
|
+
`SELECT ${groupCols}, COUNT(*) as count FROM`
|
|
429
|
+
);
|
|
430
|
+
return this.executor(aggSql, params, true) as any;
|
|
431
|
+
}
|
|
432
|
+
|
|
397
433
|
|
|
398
434
|
|
|
399
435
|
// ---------- Thenable (async/await support) ----------
|
package/src/context.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* can access the Database's internals without importing the full class.
|
|
6
6
|
*/
|
|
7
7
|
import type { Database as SqliteDatabase } from 'bun:sqlite';
|
|
8
|
-
import type { SchemaMap, Relationship, AugmentedEntity } from './types';
|
|
8
|
+
import type { SchemaMap, Relationship, AugmentedEntity, TableHooks } from './types';
|
|
9
9
|
|
|
10
10
|
export interface DatabaseContext {
|
|
11
11
|
/** The raw bun:sqlite Database handle. */
|
|
@@ -31,4 +31,7 @@ export interface DatabaseContext {
|
|
|
31
31
|
|
|
32
32
|
/** Whether soft deletes are enabled (deletedAt column). */
|
|
33
33
|
softDeletes: boolean;
|
|
34
|
+
|
|
35
|
+
/** Lifecycle hooks keyed by table name. */
|
|
36
|
+
hooks: Record<string, TableHooks>;
|
|
34
37
|
}
|
package/src/crud.ts
CHANGED
|
@@ -40,7 +40,16 @@ export function findMany(ctx: DatabaseContext, entityName: string, conditions: R
|
|
|
40
40
|
|
|
41
41
|
export function insert<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
|
|
42
42
|
const schema = ctx.schemas[entityName]!;
|
|
43
|
-
|
|
43
|
+
let inputData = { ...data } as Record<string, any>;
|
|
44
|
+
|
|
45
|
+
// beforeInsert hook — can transform data
|
|
46
|
+
const hooks = ctx.hooks[entityName];
|
|
47
|
+
if (hooks?.beforeInsert) {
|
|
48
|
+
const result = hooks.beforeInsert(inputData);
|
|
49
|
+
if (result) inputData = result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validatedData = asZodObject(schema).passthrough().parse(inputData);
|
|
44
53
|
const transformed = transformForStorage(validatedData);
|
|
45
54
|
|
|
46
55
|
// Auto-inject timestamps
|
|
@@ -62,12 +71,24 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
62
71
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid as number);
|
|
63
72
|
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
64
73
|
|
|
74
|
+
// afterInsert hook
|
|
75
|
+
if (hooks?.afterInsert) hooks.afterInsert(newEntity);
|
|
76
|
+
|
|
65
77
|
return newEntity;
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
export function update<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
|
|
69
81
|
const schema = ctx.schemas[entityName]!;
|
|
70
|
-
|
|
82
|
+
let inputData = { ...data } as Record<string, any>;
|
|
83
|
+
|
|
84
|
+
// beforeUpdate hook — can transform data
|
|
85
|
+
const hooks = ctx.hooks[entityName];
|
|
86
|
+
if (hooks?.beforeUpdate) {
|
|
87
|
+
const result = hooks.beforeUpdate(inputData, id);
|
|
88
|
+
if (result) inputData = result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const validatedData = asZodObject(schema).partial().parse(inputData);
|
|
71
92
|
const transformed = transformForStorage(validatedData);
|
|
72
93
|
if (Object.keys(transformed).length === 0 && !ctx.timestamps) return getById(ctx, entityName, id);
|
|
73
94
|
|
|
@@ -81,7 +102,12 @@ export function update<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
81
102
|
if (ctx.debug) console.log('[satidb]', sql, [...Object.values(transformed), id]);
|
|
82
103
|
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
83
104
|
|
|
84
|
-
|
|
105
|
+
const updated = getById(ctx, entityName, id);
|
|
106
|
+
|
|
107
|
+
// afterUpdate hook
|
|
108
|
+
if (hooks?.afterUpdate && updated) hooks.afterUpdate(updated);
|
|
109
|
+
|
|
110
|
+
return updated;
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
export function updateWhere(ctx: DatabaseContext, entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
|
|
@@ -130,8 +156,31 @@ export function upsert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
130
156
|
return insert(ctx, entityName, insertData);
|
|
131
157
|
}
|
|
132
158
|
|
|
159
|
+
/** Find a row matching conditions, or create it with the merged data. Returns { entity, created }. */
|
|
160
|
+
export function findOrCreate<T extends Record<string, any>>(
|
|
161
|
+
ctx: DatabaseContext, entityName: string,
|
|
162
|
+
conditions: Record<string, any>,
|
|
163
|
+
defaults: Record<string, any> = {},
|
|
164
|
+
): { entity: AugmentedEntity<any>; created: boolean } {
|
|
165
|
+
const existing = getOne(ctx, entityName, conditions);
|
|
166
|
+
if (existing) return { entity: existing, created: false };
|
|
167
|
+
const data = { ...conditions, ...defaults };
|
|
168
|
+
delete (data as any).id;
|
|
169
|
+
return { entity: insert(ctx, entityName, data), created: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
133
172
|
export function deleteEntity(ctx: DatabaseContext, entityName: string, id: number): void {
|
|
173
|
+
// beforeDelete hook — return false to cancel
|
|
174
|
+
const hooks = ctx.hooks[entityName];
|
|
175
|
+
if (hooks?.beforeDelete) {
|
|
176
|
+
const result = hooks.beforeDelete(id);
|
|
177
|
+
if (result === false) return;
|
|
178
|
+
}
|
|
179
|
+
|
|
134
180
|
ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
|
|
181
|
+
|
|
182
|
+
// afterDelete hook
|
|
183
|
+
if (hooks?.afterDelete) hooks.afterDelete(id);
|
|
135
184
|
}
|
|
136
185
|
|
|
137
186
|
/** Delete all rows matching the given conditions. Returns the number of rows affected. */
|
|
@@ -169,11 +218,20 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
|
|
|
169
218
|
if (rows.length === 0) return [];
|
|
170
219
|
const schema = ctx.schemas[entityName]!;
|
|
171
220
|
const zodSchema = asZodObject(schema).passthrough();
|
|
221
|
+
const hooks = ctx.hooks[entityName];
|
|
172
222
|
|
|
173
223
|
const txn = ctx.db.transaction(() => {
|
|
174
224
|
const ids: number[] = [];
|
|
175
|
-
for (
|
|
176
|
-
|
|
225
|
+
for (let data of rows) {
|
|
226
|
+
let inputData = { ...data } as Record<string, any>;
|
|
227
|
+
|
|
228
|
+
// beforeInsert hook
|
|
229
|
+
if (hooks?.beforeInsert) {
|
|
230
|
+
const result = hooks.beforeInsert(inputData);
|
|
231
|
+
if (result) inputData = result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const validatedData = zodSchema.parse(inputData);
|
|
177
235
|
const transformed = transformForStorage(validatedData);
|
|
178
236
|
|
|
179
237
|
if (ctx.timestamps) {
|
|
@@ -194,5 +252,27 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
|
|
|
194
252
|
});
|
|
195
253
|
|
|
196
254
|
const ids = txn();
|
|
197
|
-
|
|
255
|
+
const entities = ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
|
|
256
|
+
|
|
257
|
+
// afterInsert hooks
|
|
258
|
+
if (hooks?.afterInsert) {
|
|
259
|
+
for (const entity of entities) hooks.afterInsert(entity);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return entities;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Upsert multiple rows in a single transaction. */
|
|
266
|
+
export function upsertMany<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, rows: any[], conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
|
|
267
|
+
if (rows.length === 0) return [];
|
|
268
|
+
|
|
269
|
+
const txn = ctx.db.transaction(() => {
|
|
270
|
+
const results: AugmentedEntity<any>[] = [];
|
|
271
|
+
for (const data of rows) {
|
|
272
|
+
results.push(upsert(ctx, entityName, data, conditions));
|
|
273
|
+
}
|
|
274
|
+
return results;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return txn();
|
|
198
278
|
}
|
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, createDeleteBuilder,
|
|
27
|
+
insert, insertMany, update, upsert, upsertMany, findOrCreate, deleteEntity, createDeleteBuilder,
|
|
28
28
|
getById, getOne, findMany, updateWhere, createUpdateBuilder,
|
|
29
29
|
} from './crud';
|
|
30
30
|
|
|
@@ -86,6 +86,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
86
86
|
debug: this._debug,
|
|
87
87
|
timestamps: this._timestamps,
|
|
88
88
|
softDeletes: this._softDeletes,
|
|
89
|
+
hooks: options.hooks ?? {},
|
|
89
90
|
};
|
|
90
91
|
|
|
91
92
|
this.initializeTables();
|
|
@@ -105,12 +106,21 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
105
106
|
return createUpdateBuilder(this._ctx, entityName, idOrData);
|
|
106
107
|
},
|
|
107
108
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
109
|
+
upsertMany: (rows: any[], conditions?: any) => upsertMany(this._ctx, entityName, rows, conditions),
|
|
110
|
+
findOrCreate: (conditions: any, defaults?: any) => findOrCreate(this._ctx, entityName, conditions, defaults),
|
|
108
111
|
delete: ((id?: any) => {
|
|
109
112
|
if (typeof id === 'number') {
|
|
113
|
+
// beforeDelete hook — return false to cancel
|
|
114
|
+
const hooks = this._ctx.hooks[entityName];
|
|
115
|
+
if (hooks?.beforeDelete) {
|
|
116
|
+
const result = hooks.beforeDelete(id);
|
|
117
|
+
if (result === false) return;
|
|
118
|
+
}
|
|
110
119
|
if (this._softDeletes) {
|
|
111
120
|
// Soft delete: set deletedAt instead of removing
|
|
112
121
|
const now = new Date().toISOString();
|
|
113
122
|
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
123
|
+
if (hooks?.afterDelete) hooks.afterDelete(id);
|
|
114
124
|
return;
|
|
115
125
|
}
|
|
116
126
|
return deleteEntity(this._ctx, entityName, id);
|
package/src/iqo.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface IQO {
|
|
|
31
31
|
selects: string[];
|
|
32
32
|
wheres: WhereCondition[];
|
|
33
33
|
whereOrs: WhereCondition[][]; // Each sub-array is an OR group
|
|
34
|
+
rawWheres: { sql: string; params: any[] }[]; // Raw WHERE fragments
|
|
34
35
|
whereAST: ASTNode | null;
|
|
35
36
|
joins: JoinClause[];
|
|
36
37
|
groupBy: string[];
|
|
@@ -162,6 +163,14 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
// Append raw WHERE fragments
|
|
167
|
+
if (iqo.rawWheres && iqo.rawWheres.length > 0) {
|
|
168
|
+
for (const rw of iqo.rawWheres) {
|
|
169
|
+
sql += sql.includes(' WHERE ') ? ` AND (${rw.sql})` : ` WHERE (${rw.sql})`;
|
|
170
|
+
params.push(...rw.params);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
165
174
|
// GROUP BY
|
|
166
175
|
if (iqo.groupBy.length > 0) {
|
|
167
176
|
sql += ` GROUP BY ${iqo.groupBy.join(', ')}`;
|
package/src/schema.ts
CHANGED
|
@@ -80,7 +80,9 @@ export function zodTypeToSqlType(zodType: ZodType): string {
|
|
|
80
80
|
}
|
|
81
81
|
if (zodType instanceof z.ZodString || zodType instanceof z.ZodDate) return 'TEXT';
|
|
82
82
|
if (zodType instanceof z.ZodNumber || zodType instanceof z.ZodBoolean) return 'INTEGER';
|
|
83
|
+
if (zodType instanceof z.ZodEnum) return 'TEXT';
|
|
83
84
|
if ((zodType as any)._def.typeName === 'ZodInstanceOf' && (zodType as any)._def.type === Buffer) return 'BLOB';
|
|
85
|
+
// z.object(), z.array(), z.record() → stored as JSON TEXT
|
|
84
86
|
return 'TEXT';
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -92,6 +94,9 @@ export function transformForStorage(data: Record<string, any>): Record<string, a
|
|
|
92
94
|
transformed[key] = value.toISOString();
|
|
93
95
|
} else if (typeof value === 'boolean') {
|
|
94
96
|
transformed[key] = value ? 1 : 0;
|
|
97
|
+
} else if (value !== null && value !== undefined && typeof value === 'object' && !(value instanceof Buffer)) {
|
|
98
|
+
// Auto-serialize objects and arrays to JSON
|
|
99
|
+
transformed[key] = JSON.stringify(value);
|
|
95
100
|
} else {
|
|
96
101
|
transformed[key] = value;
|
|
97
102
|
}
|
|
@@ -114,9 +119,25 @@ export function transformFromStorage(row: Record<string, any>, schema: z.ZodType
|
|
|
114
119
|
transformed[key] = new Date(value);
|
|
115
120
|
} else if (fieldSchema instanceof z.ZodBoolean && typeof value === 'number') {
|
|
116
121
|
transformed[key] = value === 1;
|
|
122
|
+
} else if (isJsonSchema(fieldSchema) && typeof value === 'string') {
|
|
123
|
+
// Auto-parse JSON columns
|
|
124
|
+
try { transformed[key] = JSON.parse(value); } catch { transformed[key] = value; }
|
|
117
125
|
} else {
|
|
118
126
|
transformed[key] = value;
|
|
119
127
|
}
|
|
120
128
|
}
|
|
121
129
|
return transformed;
|
|
122
130
|
}
|
|
131
|
+
|
|
132
|
+
/** Check if a Zod schema represents a JSON-serializable type */
|
|
133
|
+
function isJsonSchema(schema: any): boolean {
|
|
134
|
+
if (!schema) return false;
|
|
135
|
+
return (
|
|
136
|
+
schema instanceof z.ZodObject ||
|
|
137
|
+
schema instanceof z.ZodArray ||
|
|
138
|
+
schema instanceof z.ZodRecord ||
|
|
139
|
+
schema instanceof z.ZodTuple ||
|
|
140
|
+
schema instanceof z.ZodUnion ||
|
|
141
|
+
schema instanceof z.ZodDiscriminatedUnion
|
|
142
|
+
);
|
|
143
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -19,6 +19,16 @@ 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
|
+
/** Lifecycle hooks for a single table. */
|
|
23
|
+
export type TableHooks = {
|
|
24
|
+
beforeInsert?: (data: Record<string, any>) => Record<string, any> | void;
|
|
25
|
+
afterInsert?: (entity: Record<string, any>) => void;
|
|
26
|
+
beforeUpdate?: (data: Record<string, any>, id: number) => Record<string, any> | void;
|
|
27
|
+
afterUpdate?: (entity: Record<string, any>) => void;
|
|
28
|
+
beforeDelete?: (id: number) => false | void;
|
|
29
|
+
afterDelete?: (id: number) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
22
32
|
export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
23
33
|
indexes?: Record<string, IndexDef[]>;
|
|
24
34
|
/**
|
|
@@ -65,6 +75,17 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
|
65
75
|
* Default: `false`.
|
|
66
76
|
*/
|
|
67
77
|
debug?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Lifecycle hooks per table. Each hook receives data and can transform it.
|
|
80
|
+
*
|
|
81
|
+
* - `beforeInsert(data)` — called before insert, return modified data or void
|
|
82
|
+
* - `afterInsert(entity)` — called after insert with the persisted entity
|
|
83
|
+
* - `beforeUpdate(data, id)` — called before update, return modified data or void
|
|
84
|
+
* - `afterUpdate(entity)` — called after update with the updated entity
|
|
85
|
+
* - `beforeDelete(id)` — called before delete, return false to cancel
|
|
86
|
+
* - `afterDelete(id)` — called after delete
|
|
87
|
+
*/
|
|
88
|
+
hooks?: Record<string, TableHooks>;
|
|
68
89
|
};
|
|
69
90
|
|
|
70
91
|
export type Relationship = {
|
|
@@ -184,6 +205,8 @@ export type NavEntityAccessor<
|
|
|
184
205
|
update: ((id: number, data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => NavEntity<S, R, Table> | null)
|
|
185
206
|
& ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
|
|
186
207
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
208
|
+
upsertMany: (rows: Partial<z.infer<S[Table & keyof S]>>[], conditions?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>[];
|
|
209
|
+
findOrCreate: (conditions: Partial<z.infer<S[Table & keyof S]>>, defaults?: Partial<z.infer<S[Table & keyof S]>>) => { entity: NavEntity<S, R, Table>; created: boolean };
|
|
187
210
|
delete: ((id: number) => void) & (() => DeleteBuilder<NavEntity<S, R, Table>>);
|
|
188
211
|
restore: (id: number) => void;
|
|
189
212
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
@@ -215,6 +238,8 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
215
238
|
insertMany: (rows: EntityData<S>[]) => AugmentedEntity<S>[];
|
|
216
239
|
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
217
240
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
241
|
+
upsertMany: (rows: Partial<InferSchema<S>>[], conditions?: Partial<InferSchema<S>>) => AugmentedEntity<S>[];
|
|
242
|
+
findOrCreate: (conditions: Partial<InferSchema<S>>, defaults?: Partial<InferSchema<S>>) => { entity: AugmentedEntity<S>; created: boolean };
|
|
218
243
|
delete: ((id: number) => void) & (() => DeleteBuilder<AugmentedEntity<S>>);
|
|
219
244
|
/** Undo a soft delete by setting deletedAt = null. Requires softDeletes. */
|
|
220
245
|
restore: (id: number) => void;
|