sqlite-zod-orm 3.12.0 → 3.13.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
@@ -4517,6 +4517,15 @@ class QueryBuilder {
4517
4517
  const data = this.all();
4518
4518
  return { data, total, page, perPage, pages };
4519
4519
  }
4520
+ countGrouped() {
4521
+ if (this.iqo.groupBy.length === 0) {
4522
+ throw new Error("countGrouped() requires at least one groupBy() call");
4523
+ }
4524
+ const groupCols = this.iqo.groupBy.map((c) => `"${c}"`).join(", ");
4525
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4526
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT ${groupCols}, COUNT(*) as count FROM`);
4527
+ return this.executor(aggSql, params, true);
4528
+ }
4520
4529
  then(onfulfilled, onrejected) {
4521
4530
  try {
4522
4531
  const result = this.all();
@@ -4902,7 +4911,14 @@ function findMany(ctx, entityName, conditions = {}) {
4902
4911
  }
4903
4912
  function insert(ctx, entityName, data) {
4904
4913
  const schema = ctx.schemas[entityName];
4905
- const validatedData = asZodObject(schema).passthrough().parse(data);
4914
+ let inputData = { ...data };
4915
+ const hooks = ctx.hooks[entityName];
4916
+ if (hooks?.beforeInsert) {
4917
+ const result2 = hooks.beforeInsert(inputData);
4918
+ if (result2)
4919
+ inputData = result2;
4920
+ }
4921
+ const validatedData = asZodObject(schema).passthrough().parse(inputData);
4906
4922
  const transformed = transformForStorage(validatedData);
4907
4923
  if (ctx.timestamps) {
4908
4924
  const now = new Date().toISOString();
@@ -4918,11 +4934,20 @@ function insert(ctx, entityName, data) {
4918
4934
  const newEntity = getById(ctx, entityName, result.lastInsertRowid);
4919
4935
  if (!newEntity)
4920
4936
  throw new Error("Failed to retrieve entity after insertion");
4937
+ if (hooks?.afterInsert)
4938
+ hooks.afterInsert(newEntity);
4921
4939
  return newEntity;
4922
4940
  }
4923
4941
  function update(ctx, entityName, id, data) {
4924
4942
  const schema = ctx.schemas[entityName];
4925
- const validatedData = asZodObject(schema).partial().parse(data);
4943
+ let inputData = { ...data };
4944
+ const hooks = ctx.hooks[entityName];
4945
+ if (hooks?.beforeUpdate) {
4946
+ const result = hooks.beforeUpdate(inputData, id);
4947
+ if (result)
4948
+ inputData = result;
4949
+ }
4950
+ const validatedData = asZodObject(schema).partial().parse(inputData);
4926
4951
  const transformed = transformForStorage(validatedData);
4927
4952
  if (Object.keys(transformed).length === 0 && !ctx.timestamps)
4928
4953
  return getById(ctx, entityName, id);
@@ -4934,7 +4959,10 @@ function update(ctx, entityName, id, data) {
4934
4959
  if (ctx.debug)
4935
4960
  console.log("[satidb]", sql, [...Object.values(transformed), id]);
4936
4961
  ctx.db.query(sql).run(...Object.values(transformed), id);
4937
- return getById(ctx, entityName, id);
4962
+ const updated = getById(ctx, entityName, id);
4963
+ if (hooks?.afterUpdate && updated)
4964
+ hooks.afterUpdate(updated);
4965
+ return updated;
4938
4966
  }
4939
4967
  function updateWhere(ctx, entityName, data, conditions) {
4940
4968
  const schema = ctx.schemas[entityName];
@@ -4974,7 +5002,15 @@ function upsert(ctx, entityName, data, conditions = {}) {
4974
5002
  return insert(ctx, entityName, insertData);
4975
5003
  }
4976
5004
  function deleteEntity(ctx, entityName, id) {
5005
+ const hooks = ctx.hooks[entityName];
5006
+ if (hooks?.beforeDelete) {
5007
+ const result = hooks.beforeDelete(id);
5008
+ if (result === false)
5009
+ return;
5010
+ }
4977
5011
  ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
5012
+ if (hooks?.afterDelete)
5013
+ hooks.afterDelete(id);
4978
5014
  }
4979
5015
  function deleteWhere(ctx, entityName, conditions) {
4980
5016
  const { clause, values } = ctx.buildWhereClause(conditions);
@@ -5010,10 +5046,17 @@ function insertMany(ctx, entityName, rows) {
5010
5046
  return [];
5011
5047
  const schema = ctx.schemas[entityName];
5012
5048
  const zodSchema = asZodObject(schema).passthrough();
5049
+ const hooks = ctx.hooks[entityName];
5013
5050
  const txn = ctx.db.transaction(() => {
5014
5051
  const ids2 = [];
5015
- for (const data of rows) {
5016
- const validatedData = zodSchema.parse(data);
5052
+ for (let data of rows) {
5053
+ let inputData = { ...data };
5054
+ if (hooks?.beforeInsert) {
5055
+ const result2 = hooks.beforeInsert(inputData);
5056
+ if (result2)
5057
+ inputData = result2;
5058
+ }
5059
+ const validatedData = zodSchema.parse(inputData);
5017
5060
  const transformed = transformForStorage(validatedData);
5018
5061
  if (ctx.timestamps) {
5019
5062
  const now = new Date().toISOString();
@@ -5029,7 +5072,24 @@ function insertMany(ctx, entityName, rows) {
5029
5072
  return ids2;
5030
5073
  });
5031
5074
  const ids = txn();
5032
- return ids.map((id) => getById(ctx, entityName, id)).filter(Boolean);
5075
+ const entities = ids.map((id) => getById(ctx, entityName, id)).filter(Boolean);
5076
+ if (hooks?.afterInsert) {
5077
+ for (const entity of entities)
5078
+ hooks.afterInsert(entity);
5079
+ }
5080
+ return entities;
5081
+ }
5082
+ function upsertMany(ctx, entityName, rows, conditions = {}) {
5083
+ if (rows.length === 0)
5084
+ return [];
5085
+ const txn = ctx.db.transaction(() => {
5086
+ const results = [];
5087
+ for (const data of rows) {
5088
+ results.push(upsert(ctx, entityName, data, conditions));
5089
+ }
5090
+ return results;
5091
+ });
5092
+ return txn();
5033
5093
  }
5034
5094
 
5035
5095
  // src/entity.ts
@@ -5101,7 +5161,8 @@ class _Database {
5101
5161
  buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
5102
5162
  debug: this._debug,
5103
5163
  timestamps: this._timestamps,
5104
- softDeletes: this._softDeletes
5164
+ softDeletes: this._softDeletes,
5165
+ hooks: options.hooks ?? {}
5105
5166
  };
5106
5167
  this.initializeTables();
5107
5168
  if (this._reactive)
@@ -5122,11 +5183,20 @@ class _Database {
5122
5183
  return createUpdateBuilder(this._ctx, entityName, idOrData);
5123
5184
  },
5124
5185
  upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
5186
+ upsertMany: (rows, conditions) => upsertMany(this._ctx, entityName, rows, conditions),
5125
5187
  delete: (id) => {
5126
5188
  if (typeof id === "number") {
5189
+ const hooks = this._ctx.hooks[entityName];
5190
+ if (hooks?.beforeDelete) {
5191
+ const result = hooks.beforeDelete(id);
5192
+ if (result === false)
5193
+ return;
5194
+ }
5127
5195
  if (this._softDeletes) {
5128
5196
  const now = new Date().toISOString();
5129
5197
  this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
5198
+ if (hooks?.afterDelete)
5199
+ hooks.afterDelete(id);
5130
5200
  return;
5131
5201
  }
5132
5202
  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.13.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
@@ -394,6 +394,28 @@ export class QueryBuilder<T extends Record<string, any>> {
394
394
  return { data, total, page, perPage, pages };
395
395
  }
396
396
 
397
+ /**
398
+ * Count rows per group. Must call `.groupBy()` first.
399
+ * Returns an array of objects with the grouped column(s) and a `count` field.
400
+ *
401
+ * ```ts
402
+ * db.users.select('role').groupBy('role').countGrouped()
403
+ * // → [{ role: 'admin', count: 5 }, { role: 'member', count: 12 }]
404
+ * ```
405
+ */
406
+ countGrouped(): (Record<string, any> & { count: number })[] {
407
+ if (this.iqo.groupBy.length === 0) {
408
+ throw new Error('countGrouped() requires at least one groupBy() call');
409
+ }
410
+ const groupCols = this.iqo.groupBy.map(c => `"${c}"`).join(', ');
411
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
412
+ const aggSql = selectSql.replace(
413
+ /^SELECT .+? FROM/,
414
+ `SELECT ${groupCols}, COUNT(*) as count FROM`
415
+ );
416
+ return this.executor(aggSql, params, true) as any;
417
+ }
418
+
397
419
 
398
420
 
399
421
  // ---------- 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 {
@@ -131,7 +157,17 @@ export function upsert<T extends Record<string, any>>(ctx: DatabaseContext, enti
131
157
  }
132
158
 
133
159
  export function deleteEntity(ctx: DatabaseContext, entityName: string, id: number): void {
160
+ // beforeDelete hook — return false to cancel
161
+ const hooks = ctx.hooks[entityName];
162
+ if (hooks?.beforeDelete) {
163
+ const result = hooks.beforeDelete(id);
164
+ if (result === false) return;
165
+ }
166
+
134
167
  ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
168
+
169
+ // afterDelete hook
170
+ if (hooks?.afterDelete) hooks.afterDelete(id);
135
171
  }
136
172
 
137
173
  /** Delete all rows matching the given conditions. Returns the number of rows affected. */
@@ -169,11 +205,20 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
169
205
  if (rows.length === 0) return [];
170
206
  const schema = ctx.schemas[entityName]!;
171
207
  const zodSchema = asZodObject(schema).passthrough();
208
+ const hooks = ctx.hooks[entityName];
172
209
 
173
210
  const txn = ctx.db.transaction(() => {
174
211
  const ids: number[] = [];
175
- for (const data of rows) {
176
- const validatedData = zodSchema.parse(data);
212
+ for (let data of rows) {
213
+ let inputData = { ...data } as Record<string, any>;
214
+
215
+ // beforeInsert hook
216
+ if (hooks?.beforeInsert) {
217
+ const result = hooks.beforeInsert(inputData);
218
+ if (result) inputData = result;
219
+ }
220
+
221
+ const validatedData = zodSchema.parse(inputData);
177
222
  const transformed = transformForStorage(validatedData);
178
223
 
179
224
  if (ctx.timestamps) {
@@ -194,5 +239,27 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
194
239
  });
195
240
 
196
241
  const ids = txn();
197
- return ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
242
+ const entities = ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
243
+
244
+ // afterInsert hooks
245
+ if (hooks?.afterInsert) {
246
+ for (const entity of entities) hooks.afterInsert(entity);
247
+ }
248
+
249
+ return entities;
250
+ }
251
+
252
+ /** Upsert multiple rows in a single transaction. */
253
+ export function upsertMany<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, rows: any[], conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
254
+ if (rows.length === 0) return [];
255
+
256
+ const txn = ctx.db.transaction(() => {
257
+ const results: AugmentedEntity<any>[] = [];
258
+ for (const data of rows) {
259
+ results.push(upsert(ctx, entityName, data, conditions));
260
+ }
261
+ return results;
262
+ });
263
+
264
+ return txn();
198
265
  }
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, 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,20 @@ 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),
108
110
  delete: ((id?: any) => {
109
111
  if (typeof id === 'number') {
112
+ // beforeDelete hook — return false to cancel
113
+ const hooks = this._ctx.hooks[entityName];
114
+ if (hooks?.beforeDelete) {
115
+ const result = hooks.beforeDelete(id);
116
+ if (result === false) return;
117
+ }
110
118
  if (this._softDeletes) {
111
119
  // Soft delete: set deletedAt instead of removing
112
120
  const now = new Date().toISOString();
113
121
  this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
122
+ if (hooks?.afterDelete) hooks.afterDelete(id);
114
123
  return;
115
124
  }
116
125
  return deleteEntity(this._ctx, entityName, id);
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,7 @@ 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>[];
187
209
  delete: ((id: number) => void) & (() => DeleteBuilder<NavEntity<S, R, Table>>);
188
210
  restore: (id: number) => void;
189
211
  select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
@@ -215,6 +237,7 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
215
237
  insertMany: (rows: EntityData<S>[]) => AugmentedEntity<S>[];
216
238
  update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
217
239
  upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
240
+ upsertMany: (rows: Partial<InferSchema<S>>[], conditions?: Partial<InferSchema<S>>) => AugmentedEntity<S>[];
218
241
  delete: ((id: number) => void) & (() => DeleteBuilder<AugmentedEntity<S>>);
219
242
  /** Undo a soft delete by setting deletedAt = null. Requires softDeletes. */
220
243
  restore: (id: number) => void;