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 +3 -1
- package/dist/index.js +85 -6
- package/package.json +1 -1
- package/src/database.ts +82 -1
- package/src/query-builder.ts +53 -4
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 #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
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
|
-
|
|
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
|
}
|
package/src/query-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|