sqlite-zod-orm 3.4.0 → 3.5.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 CHANGED
@@ -321,7 +321,7 @@ Run `bun examples/messages-demo.ts` for a full working demo.
321
321
  ```bash
322
322
  bun examples/messages-demo.ts # .on() vs .subscribe() demo
323
323
  bun examples/example.ts # comprehensive demo
324
- bun test # 93 tests
324
+ bun test # 100 tests
325
325
  ```
326
326
 
327
327
  ---
@@ -336,6 +336,8 @@ bun test # 93 tests
336
336
  | `db.table.select(...cols?).where(filter).all()` | Array of rows |
337
337
  | `db.table.select().count()` | Count rows |
338
338
  | `db.table.select().join(db.other, cols?).all()` | Fluent join (auto FK) |
339
+ | `db.table.select().with('children').all()` | Eager load related entities (no N+1) |
340
+ | `.where({ relation: entity })` | Filter by entity reference |
339
341
  | `db.query(c => { ... })` | Proxy callback (SQL-like JOINs) |
340
342
  | **Writing** | |
341
343
  | `db.table.insert(data)` | Insert with validation |
package/dist/index.js CHANGED
@@ -112,6 +112,8 @@ function compileIQO(tableName, iqo) {
112
112
  sql += ` WHERE ${compiled.sql}`;
113
113
  params.push(...compiled.params);
114
114
  } else if (iqo.wheres.length > 0) {
115
+ const hasJoins = iqo.joins.length > 0;
116
+ const qualify = (field) => hasJoins && !field.includes(".") ? `${tableName}.${field}` : field;
115
117
  const whereParts = [];
116
118
  for (const w of iqo.wheres) {
117
119
  if (w.operator === "IN") {
@@ -120,11 +122,11 @@ function compileIQO(tableName, iqo) {
120
122
  whereParts.push("1 = 0");
121
123
  } else {
122
124
  const placeholders = arr.map(() => "?").join(", ");
123
- whereParts.push(`${w.field} IN (${placeholders})`);
125
+ whereParts.push(`${qualify(w.field)} IN (${placeholders})`);
124
126
  params.push(...arr.map(transformValueForStorage));
125
127
  }
126
128
  } else {
127
- whereParts.push(`${w.field} ${w.operator} ?`);
129
+ whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
128
130
  params.push(transformValueForStorage(w.value));
129
131
  }
130
132
  }
@@ -174,13 +176,15 @@ class QueryBuilder {
174
176
  joinResolver;
175
177
  conditionResolver;
176
178
  revisionGetter;
177
- constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter) {
179
+ eagerLoader;
180
+ constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader) {
178
181
  this.tableName = tableName;
179
182
  this.executor = executor;
180
183
  this.singleExecutor = singleExecutor;
181
184
  this.joinResolver = joinResolver ?? null;
182
185
  this.conditionResolver = conditionResolver ?? null;
183
186
  this.revisionGetter = revisionGetter ?? null;
187
+ this.eagerLoader = eagerLoader ?? null;
184
188
  this.iqo = {
185
189
  selects: [],
186
190
  wheres: [],
@@ -288,14 +292,40 @@ class QueryBuilder {
288
292
  this.iqo.raw = true;
289
293
  return this;
290
294
  }
295
+ with(...relations) {
296
+ this.iqo.includes.push(...relations);
297
+ return this;
298
+ }
299
+ _applyEagerLoads(results) {
300
+ if (this.iqo.includes.length === 0 || !this.eagerLoader || results.length === 0) {
301
+ return results;
302
+ }
303
+ const parentIds = results.map((r) => r.id).filter((id) => typeof id === "number");
304
+ if (parentIds.length === 0)
305
+ return results;
306
+ for (const relation of this.iqo.includes) {
307
+ const loaded = this.eagerLoader(this.tableName, relation, parentIds);
308
+ if (!loaded)
309
+ continue;
310
+ for (const row of results) {
311
+ row[loaded.key] = loaded.groups.get(row.id) ?? [];
312
+ }
313
+ }
314
+ return results;
315
+ }
291
316
  all() {
292
317
  const { sql, params } = compileIQO(this.tableName, this.iqo);
293
- return this.executor(sql, params, this.iqo.raw);
318
+ const results = this.executor(sql, params, this.iqo.raw);
319
+ return this._applyEagerLoads(results);
294
320
  }
295
321
  get() {
296
322
  this.iqo.limit = 1;
297
323
  const { sql, params } = compileIQO(this.tableName, this.iqo);
298
- return this.singleExecutor(sql, params, this.iqo.raw);
324
+ const result = this.singleExecutor(sql, params, this.iqo.raw);
325
+ if (!result)
326
+ return null;
327
+ const [loaded] = this._applyEagerLoads([result]);
328
+ return loaded ?? null;
299
329
  }
300
330
  count() {
301
331
  const params = [];
@@ -4955,7 +4985,56 @@ class _Database {
4955
4985
  return null;
4956
4986
  };
4957
4987
  const revisionGetter = () => this._getRevision(entityName);
4958
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
4988
+ const conditionResolver = (conditions) => {
4989
+ const resolved = {};
4990
+ for (const [key, value] of Object.entries(conditions)) {
4991
+ if (value && typeof value === "object" && typeof value.id === "number" && typeof value.delete === "function") {
4992
+ const fkCol = key + "_id";
4993
+ const rel = this.relationships.find((r) => r.type === "belongs-to" && r.from === entityName && r.foreignKey === fkCol);
4994
+ if (rel) {
4995
+ resolved[fkCol] = value.id;
4996
+ } else {
4997
+ const relByNav = this.relationships.find((r) => r.type === "belongs-to" && r.from === entityName && r.to === key + "s") || this.relationships.find((r) => r.type === "belongs-to" && r.from === entityName && r.to === key);
4998
+ if (relByNav) {
4999
+ resolved[relByNav.foreignKey] = value.id;
5000
+ } else {
5001
+ resolved[key] = value;
5002
+ }
5003
+ }
5004
+ } else {
5005
+ resolved[key] = value;
5006
+ }
5007
+ }
5008
+ return resolved;
5009
+ };
5010
+ const eagerLoader = (parentTable, relation, parentIds) => {
5011
+ const hasMany = this.relationships.find((r) => r.type === "one-to-many" && r.from === parentTable && r.relationshipField === relation);
5012
+ if (hasMany) {
5013
+ const belongsTo2 = this.relationships.find((r) => r.type === "belongs-to" && r.from === hasMany.to && r.to === parentTable);
5014
+ if (belongsTo2) {
5015
+ const fk = belongsTo2.foreignKey;
5016
+ const placeholders = parentIds.map(() => "?").join(", ");
5017
+ const childRows = this.db.query(`SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`).all(...parentIds);
5018
+ const groups = new Map;
5019
+ const childSchema = this.schemas[hasMany.to];
5020
+ for (const rawRow of childRows) {
5021
+ const entity = this._attachMethods(hasMany.to, transformFromStorage(rawRow, childSchema));
5022
+ const parentId = rawRow[fk];
5023
+ if (!groups.has(parentId))
5024
+ groups.set(parentId, []);
5025
+ groups.get(parentId).push(entity);
5026
+ }
5027
+ return { key: relation, groups };
5028
+ }
5029
+ }
5030
+ const belongsTo = this.relationships.find((r) => r.type === "belongs-to" && r.from === parentTable && r.relationshipField === relation);
5031
+ if (belongsTo) {
5032
+ const fkValues = [...new Set(parentIds)];
5033
+ return null;
5034
+ }
5035
+ return null;
5036
+ };
5037
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader);
4959
5038
  if (initialCols.length > 0)
4960
5039
  builder.select(...initialCols);
4961
5040
  return builder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.4.0",
3
+ "version": "3.5.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/database.ts CHANGED
@@ -453,7 +453,88 @@ class _Database<Schemas extends SchemaMap> {
453
453
  // Pass revision getter — allows .subscribe() to detect ALL changes
454
454
  const revisionGetter = () => this._getRevision(entityName);
455
455
 
456
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
456
+ // Condition resolver: { author: aliceEntity } { author_id: 1 }
457
+ const conditionResolver = (conditions: Record<string, any>): Record<string, any> => {
458
+ const resolved: Record<string, any> = {};
459
+ for (const [key, value] of Object.entries(conditions)) {
460
+ // Detect entity references: objects with `id` and `delete` (augmented entities)
461
+ if (value && typeof value === 'object' && typeof value.id === 'number' && typeof value.delete === 'function') {
462
+ // Find a belongs-to relationship: entityName has a FK named `key_id` pointing to another table
463
+ const fkCol = key + '_id';
464
+ const rel = this.relationships.find(
465
+ r => r.type === 'belongs-to' && r.from === entityName && r.foreignKey === fkCol
466
+ );
467
+ if (rel) {
468
+ resolved[fkCol] = value.id;
469
+ } else {
470
+ // Fallback: try any relationship that matches the key as the nav name
471
+ const relByNav = this.relationships.find(
472
+ r => r.type === 'belongs-to' && r.from === entityName && r.to === key + 's'
473
+ ) || this.relationships.find(
474
+ r => r.type === 'belongs-to' && r.from === entityName && r.to === key
475
+ );
476
+ if (relByNav) {
477
+ resolved[relByNav.foreignKey] = value.id;
478
+ } else {
479
+ resolved[key] = value; // pass through
480
+ }
481
+ }
482
+ } else {
483
+ resolved[key] = value;
484
+ }
485
+ }
486
+ return resolved;
487
+ };
488
+
489
+ // Eager loader: resolves .with('books') → batch load children
490
+ const eagerLoader = (parentTable: string, relation: string, parentIds: number[]): { key: string; groups: Map<number, any[]> } | null => {
491
+ // 1. Try one-to-many: parentTable has-many relation (e.g., authors → books)
492
+ const hasMany = this.relationships.find(
493
+ r => r.type === 'one-to-many' && r.from === parentTable && r.relationshipField === relation
494
+ );
495
+ if (hasMany) {
496
+ // Find the belongs-to FK on the child table
497
+ const belongsTo = this.relationships.find(
498
+ r => r.type === 'belongs-to' && r.from === hasMany.to && r.to === parentTable
499
+ );
500
+ if (belongsTo) {
501
+ const fk = belongsTo.foreignKey;
502
+ const placeholders = parentIds.map(() => '?').join(', ');
503
+ const childRows = this.db.query(
504
+ `SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`
505
+ ).all(...parentIds) as any[];
506
+
507
+ const groups = new Map<number, any[]>();
508
+ const childSchema = this.schemas[hasMany.to]!;
509
+ for (const rawRow of childRows) {
510
+ const entity = this._attachMethods(
511
+ hasMany.to,
512
+ transformFromStorage(rawRow, childSchema)
513
+ );
514
+ const parentId = rawRow[fk] as number;
515
+ if (!groups.has(parentId)) groups.set(parentId, []);
516
+ groups.get(parentId)!.push(entity);
517
+ }
518
+ return { key: relation, groups };
519
+ }
520
+ }
521
+
522
+ // 2. Try belongs-to: parentTable belongs-to relation (e.g., books → author)
523
+ const belongsTo = this.relationships.find(
524
+ r => r.type === 'belongs-to' && r.from === parentTable && r.relationshipField === relation
525
+ );
526
+ if (belongsTo) {
527
+ // Load parent entities and map by id
528
+ const fkValues = [...new Set(parentIds)];
529
+ // Actually we need FK values from parent rows, not parent IDs
530
+ // This case is trickier — skip for now, belongs-to is already handled by lazy nav
531
+ return null;
532
+ }
533
+
534
+ return null;
535
+ };
536
+
537
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader);
457
538
  if (initialCols.length > 0) builder.select(...initialCols);
458
539
  return builder;
459
540
  }
@@ -86,6 +86,11 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
86
86
  sql += ` WHERE ${compiled.sql}`;
87
87
  params.push(...compiled.params);
88
88
  } else if (iqo.wheres.length > 0) {
89
+ const hasJoins = iqo.joins.length > 0;
90
+ // When joins exist, qualify bare column names with the main table
91
+ const qualify = (field: string) =>
92
+ hasJoins && !field.includes('.') ? `${tableName}.${field}` : field;
93
+
89
94
  const whereParts: string[] = [];
90
95
  for (const w of iqo.wheres) {
91
96
  if (w.operator === 'IN') {
@@ -94,11 +99,11 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
94
99
  whereParts.push('1 = 0');
95
100
  } else {
96
101
  const placeholders = arr.map(() => '?').join(', ');
97
- whereParts.push(`${w.field} IN (${placeholders})`);
102
+ whereParts.push(`${qualify(w.field)} IN (${placeholders})`);
98
103
  params.push(...arr.map(transformValueForStorage));
99
104
  }
100
105
  } else {
101
- whereParts.push(`${w.field} ${w.operator} ?`);
106
+ whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
102
107
  params.push(transformValueForStorage(w.value));
103
108
  }
104
109
  }
@@ -168,6 +173,7 @@ export class QueryBuilder<T extends Record<string, any>> {
168
173
  private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
169
174
  private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
170
175
  private revisionGetter: (() => string) | null;
176
+ private eagerLoader: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null;
171
177
 
172
178
  constructor(
173
179
  tableName: string,
@@ -176,6 +182,7 @@ export class QueryBuilder<T extends Record<string, any>> {
176
182
  joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
177
183
  conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
178
184
  revisionGetter?: (() => string) | null,
185
+ eagerLoader?: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null,
179
186
  ) {
180
187
  this.tableName = tableName;
181
188
  this.executor = executor;
@@ -183,6 +190,7 @@ export class QueryBuilder<T extends Record<string, any>> {
183
190
  this.joinResolver = joinResolver ?? null;
184
191
  this.conditionResolver = conditionResolver ?? null;
185
192
  this.revisionGetter = revisionGetter ?? null;
193
+ this.eagerLoader = eagerLoader ?? null;
186
194
  this.iqo = {
187
195
  selects: [],
188
196
  wheres: [],
@@ -369,19 +377,60 @@ export class QueryBuilder<T extends Record<string, any>> {
369
377
  return this;
370
378
  }
371
379
 
380
+ /**
381
+ * Eagerly load a related entity and attach as an array property.
382
+ *
383
+ * ```ts
384
+ * const authors = db.authors.select().with('books').all();
385
+ * // authors[0].books → [{ title: 'War and Peace', ... }, ...]
386
+ * ```
387
+ *
388
+ * Runs a single batched query (WHERE fk IN (...)) per relation,
389
+ * avoiding the N+1 problem of lazy navigation.
390
+ */
391
+ with(...relations: string[]): this {
392
+ this.iqo.includes.push(...relations);
393
+ return this;
394
+ }
395
+
396
+ /** Internal: apply eager loads to a set of results */
397
+ private _applyEagerLoads(results: T[]): T[] {
398
+ if (this.iqo.includes.length === 0 || !this.eagerLoader || results.length === 0) {
399
+ return results;
400
+ }
401
+
402
+ const parentIds = results.map((r: any) => r.id).filter((id: any) => typeof id === 'number');
403
+ if (parentIds.length === 0) return results;
404
+
405
+ for (const relation of this.iqo.includes) {
406
+ const loaded = this.eagerLoader(this.tableName, relation, parentIds);
407
+ if (!loaded) continue;
408
+
409
+ for (const row of results as any[]) {
410
+ row[loaded.key] = loaded.groups.get(row.id) ?? [];
411
+ }
412
+ }
413
+
414
+ return results;
415
+ }
416
+
372
417
  // ---------- Terminal / Execution Methods ----------
373
418
 
374
419
  /** Execute the query and return all matching rows. */
375
420
  all(): T[] {
376
421
  const { sql, params } = compileIQO(this.tableName, this.iqo);
377
- return this.executor(sql, params, this.iqo.raw);
422
+ const results = this.executor(sql, params, this.iqo.raw);
423
+ return this._applyEagerLoads(results);
378
424
  }
379
425
 
380
426
  /** Execute the query and return the first matching row, or null. */
381
427
  get(): T | null {
382
428
  this.iqo.limit = 1;
383
429
  const { sql, params } = compileIQO(this.tableName, this.iqo);
384
- return this.singleExecutor(sql, params, this.iqo.raw);
430
+ const result = this.singleExecutor(sql, params, this.iqo.raw);
431
+ if (!result) return null;
432
+ const [loaded] = this._applyEagerLoads([result]);
433
+ return loaded ?? null;
385
434
  }
386
435
 
387
436
  /** Execute the query and return the count of matching rows. */