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 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
- const validatedData = asZodObject(schema).passthrough().parse(data);
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
- const validatedData = asZodObject(schema).partial().parse(data);
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
- return getById(ctx, entityName, id);
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 (const data of rows) {
5016
- const validatedData = zodSchema.parse(data);
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
- return ids.map((id) => getById(ctx, entityName, id)).filter(Boolean);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.12.0",
3
+ "version": "3.14.0",
4
4
  "description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- const validatedData = asZodObject(schema).passthrough().parse(data);
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
- const validatedData = asZodObject(schema).partial().parse(data);
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
- return getById(ctx, entityName, id);
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 (const data of rows) {
176
- const validatedData = zodSchema.parse(data);
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
- return ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
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;