sqlite-zod-orm 3.4.1 → 3.5.1
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 +80 -15
- package/package.json +1 -1
- package/src/database.ts +82 -21
- package/src/query-builder.ts +46 -2
- package/src/types.ts +4 -2
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
|
@@ -176,13 +176,15 @@ class QueryBuilder {
|
|
|
176
176
|
joinResolver;
|
|
177
177
|
conditionResolver;
|
|
178
178
|
revisionGetter;
|
|
179
|
-
|
|
179
|
+
eagerLoader;
|
|
180
|
+
constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader) {
|
|
180
181
|
this.tableName = tableName;
|
|
181
182
|
this.executor = executor;
|
|
182
183
|
this.singleExecutor = singleExecutor;
|
|
183
184
|
this.joinResolver = joinResolver ?? null;
|
|
184
185
|
this.conditionResolver = conditionResolver ?? null;
|
|
185
186
|
this.revisionGetter = revisionGetter ?? null;
|
|
187
|
+
this.eagerLoader = eagerLoader ?? null;
|
|
186
188
|
this.iqo = {
|
|
187
189
|
selects: [],
|
|
188
190
|
wheres: [],
|
|
@@ -290,14 +292,40 @@ class QueryBuilder {
|
|
|
290
292
|
this.iqo.raw = true;
|
|
291
293
|
return this;
|
|
292
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
|
+
}
|
|
293
316
|
all() {
|
|
294
317
|
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
295
|
-
|
|
318
|
+
const results = this.executor(sql, params, this.iqo.raw);
|
|
319
|
+
return this._applyEagerLoads(results);
|
|
296
320
|
}
|
|
297
321
|
get() {
|
|
298
322
|
this.iqo.limit = 1;
|
|
299
323
|
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
300
|
-
|
|
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;
|
|
301
329
|
}
|
|
302
330
|
count() {
|
|
303
331
|
const params = [];
|
|
@@ -4737,19 +4765,29 @@ class _Database {
|
|
|
4737
4765
|
const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get();
|
|
4738
4766
|
let lastMaxId = maxRow?._max ?? 0;
|
|
4739
4767
|
let lastRevision = this._getRevision(entityName);
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
if (
|
|
4768
|
+
let stopped = false;
|
|
4769
|
+
const poll = async () => {
|
|
4770
|
+
if (stopped)
|
|
4743
4771
|
return;
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
const
|
|
4748
|
-
|
|
4749
|
-
|
|
4772
|
+
const currentRevision = this._getRevision(entityName);
|
|
4773
|
+
if (currentRevision !== lastRevision) {
|
|
4774
|
+
lastRevision = currentRevision;
|
|
4775
|
+
const newRows = this.db.query(`SELECT * FROM "${entityName}" WHERE id > ? ORDER BY id ASC`).all(lastMaxId);
|
|
4776
|
+
for (const rawRow of newRows) {
|
|
4777
|
+
if (stopped)
|
|
4778
|
+
return;
|
|
4779
|
+
const entity = this._attachMethods(entityName, transformFromStorage(rawRow, this.schemas[entityName]));
|
|
4780
|
+
await callback(entity);
|
|
4781
|
+
lastMaxId = rawRow.id;
|
|
4782
|
+
}
|
|
4750
4783
|
}
|
|
4751
|
-
|
|
4752
|
-
|
|
4784
|
+
if (!stopped)
|
|
4785
|
+
setTimeout(poll, interval);
|
|
4786
|
+
};
|
|
4787
|
+
setTimeout(poll, interval);
|
|
4788
|
+
return () => {
|
|
4789
|
+
stopped = true;
|
|
4790
|
+
};
|
|
4753
4791
|
}
|
|
4754
4792
|
insert(entityName, data) {
|
|
4755
4793
|
const schema = this.schemas[entityName];
|
|
@@ -4979,7 +5017,34 @@ class _Database {
|
|
|
4979
5017
|
}
|
|
4980
5018
|
return resolved;
|
|
4981
5019
|
};
|
|
4982
|
-
const
|
|
5020
|
+
const eagerLoader = (parentTable, relation, parentIds) => {
|
|
5021
|
+
const hasMany = this.relationships.find((r) => r.type === "one-to-many" && r.from === parentTable && r.relationshipField === relation);
|
|
5022
|
+
if (hasMany) {
|
|
5023
|
+
const belongsTo2 = this.relationships.find((r) => r.type === "belongs-to" && r.from === hasMany.to && r.to === parentTable);
|
|
5024
|
+
if (belongsTo2) {
|
|
5025
|
+
const fk = belongsTo2.foreignKey;
|
|
5026
|
+
const placeholders = parentIds.map(() => "?").join(", ");
|
|
5027
|
+
const childRows = this.db.query(`SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`).all(...parentIds);
|
|
5028
|
+
const groups = new Map;
|
|
5029
|
+
const childSchema = this.schemas[hasMany.to];
|
|
5030
|
+
for (const rawRow of childRows) {
|
|
5031
|
+
const entity = this._attachMethods(hasMany.to, transformFromStorage(rawRow, childSchema));
|
|
5032
|
+
const parentId = rawRow[fk];
|
|
5033
|
+
if (!groups.has(parentId))
|
|
5034
|
+
groups.set(parentId, []);
|
|
5035
|
+
groups.get(parentId).push(entity);
|
|
5036
|
+
}
|
|
5037
|
+
return { key: relation, groups };
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
const belongsTo = this.relationships.find((r) => r.type === "belongs-to" && r.from === parentTable && r.relationshipField === relation);
|
|
5041
|
+
if (belongsTo) {
|
|
5042
|
+
const fkValues = [...new Set(parentIds)];
|
|
5043
|
+
return null;
|
|
5044
|
+
}
|
|
5045
|
+
return null;
|
|
5046
|
+
};
|
|
5047
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader);
|
|
4983
5048
|
if (initialCols.length > 0)
|
|
4984
5049
|
builder.select(...initialCols);
|
|
4985
5050
|
return builder;
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -151,7 +151,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
151
151
|
*/
|
|
152
152
|
public _createOnStream(
|
|
153
153
|
entityName: string,
|
|
154
|
-
callback: (row: any) => void
|
|
154
|
+
callback: (row: any) => void | Promise<void>,
|
|
155
155
|
options?: { interval?: number },
|
|
156
156
|
): () => void {
|
|
157
157
|
const { interval = 500 } = options ?? {};
|
|
@@ -160,30 +160,43 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
160
160
|
const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get() as any;
|
|
161
161
|
let lastMaxId: number = maxRow?._max ?? 0;
|
|
162
162
|
let lastRevision: string = this._getRevision(entityName);
|
|
163
|
+
let stopped = false;
|
|
164
|
+
|
|
165
|
+
// Self-scheduling async loop: guarantees strict ordering
|
|
166
|
+
// - Each callback (sync or async) completes before the next row is emitted
|
|
167
|
+
// - Next poll only starts after the current batch is fully processed
|
|
168
|
+
const poll = async () => {
|
|
169
|
+
if (stopped) return;
|
|
163
170
|
|
|
164
|
-
const timer = setInterval(() => {
|
|
165
171
|
// Fast check: did anything change?
|
|
166
172
|
const currentRevision = this._getRevision(entityName);
|
|
167
|
-
if (currentRevision
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
173
|
+
if (currentRevision !== lastRevision) {
|
|
174
|
+
lastRevision = currentRevision;
|
|
175
|
+
|
|
176
|
+
// Fetch new rows since watermark
|
|
177
|
+
const newRows = this.db.query(
|
|
178
|
+
`SELECT * FROM "${entityName}" WHERE id > ? ORDER BY id ASC`
|
|
179
|
+
).all(lastMaxId) as any[];
|
|
180
|
+
|
|
181
|
+
for (const rawRow of newRows) {
|
|
182
|
+
if (stopped) return; // bail if unsubscribed mid-batch
|
|
183
|
+
const entity = this._attachMethods(
|
|
184
|
+
entityName,
|
|
185
|
+
transformFromStorage(rawRow, this.schemas[entityName]!)
|
|
186
|
+
);
|
|
187
|
+
await callback(entity); // await async callbacks
|
|
188
|
+
lastMaxId = rawRow.id;
|
|
189
|
+
}
|
|
183
190
|
}
|
|
184
|
-
}, interval);
|
|
185
191
|
|
|
186
|
-
|
|
192
|
+
// Schedule next poll only after this one is done
|
|
193
|
+
if (!stopped) setTimeout(poll, interval);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Start the loop
|
|
197
|
+
setTimeout(poll, interval);
|
|
198
|
+
|
|
199
|
+
return () => { stopped = true; };
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
// ===========================================================================
|
|
@@ -486,7 +499,55 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
486
499
|
return resolved;
|
|
487
500
|
};
|
|
488
501
|
|
|
489
|
-
|
|
502
|
+
// Eager loader: resolves .with('books') → batch load children
|
|
503
|
+
const eagerLoader = (parentTable: string, relation: string, parentIds: number[]): { key: string; groups: Map<number, any[]> } | null => {
|
|
504
|
+
// 1. Try one-to-many: parentTable has-many relation (e.g., authors → books)
|
|
505
|
+
const hasMany = this.relationships.find(
|
|
506
|
+
r => r.type === 'one-to-many' && r.from === parentTable && r.relationshipField === relation
|
|
507
|
+
);
|
|
508
|
+
if (hasMany) {
|
|
509
|
+
// Find the belongs-to FK on the child table
|
|
510
|
+
const belongsTo = this.relationships.find(
|
|
511
|
+
r => r.type === 'belongs-to' && r.from === hasMany.to && r.to === parentTable
|
|
512
|
+
);
|
|
513
|
+
if (belongsTo) {
|
|
514
|
+
const fk = belongsTo.foreignKey;
|
|
515
|
+
const placeholders = parentIds.map(() => '?').join(', ');
|
|
516
|
+
const childRows = this.db.query(
|
|
517
|
+
`SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`
|
|
518
|
+
).all(...parentIds) as any[];
|
|
519
|
+
|
|
520
|
+
const groups = new Map<number, any[]>();
|
|
521
|
+
const childSchema = this.schemas[hasMany.to]!;
|
|
522
|
+
for (const rawRow of childRows) {
|
|
523
|
+
const entity = this._attachMethods(
|
|
524
|
+
hasMany.to,
|
|
525
|
+
transformFromStorage(rawRow, childSchema)
|
|
526
|
+
);
|
|
527
|
+
const parentId = rawRow[fk] as number;
|
|
528
|
+
if (!groups.has(parentId)) groups.set(parentId, []);
|
|
529
|
+
groups.get(parentId)!.push(entity);
|
|
530
|
+
}
|
|
531
|
+
return { key: relation, groups };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 2. Try belongs-to: parentTable belongs-to relation (e.g., books → author)
|
|
536
|
+
const belongsTo = this.relationships.find(
|
|
537
|
+
r => r.type === 'belongs-to' && r.from === parentTable && r.relationshipField === relation
|
|
538
|
+
);
|
|
539
|
+
if (belongsTo) {
|
|
540
|
+
// Load parent entities and map by id
|
|
541
|
+
const fkValues = [...new Set(parentIds)];
|
|
542
|
+
// Actually we need FK values from parent rows, not parent IDs
|
|
543
|
+
// This case is trickier — skip for now, belongs-to is already handled by lazy nav
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return null;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader);
|
|
490
551
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
491
552
|
return builder;
|
|
492
553
|
}
|
package/src/query-builder.ts
CHANGED
|
@@ -173,6 +173,7 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
173
173
|
private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
|
|
174
174
|
private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
|
|
175
175
|
private revisionGetter: (() => string) | null;
|
|
176
|
+
private eagerLoader: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null;
|
|
176
177
|
|
|
177
178
|
constructor(
|
|
178
179
|
tableName: string,
|
|
@@ -181,6 +182,7 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
181
182
|
joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
|
|
182
183
|
conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
|
|
183
184
|
revisionGetter?: (() => string) | null,
|
|
185
|
+
eagerLoader?: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null,
|
|
184
186
|
) {
|
|
185
187
|
this.tableName = tableName;
|
|
186
188
|
this.executor = executor;
|
|
@@ -188,6 +190,7 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
188
190
|
this.joinResolver = joinResolver ?? null;
|
|
189
191
|
this.conditionResolver = conditionResolver ?? null;
|
|
190
192
|
this.revisionGetter = revisionGetter ?? null;
|
|
193
|
+
this.eagerLoader = eagerLoader ?? null;
|
|
191
194
|
this.iqo = {
|
|
192
195
|
selects: [],
|
|
193
196
|
wheres: [],
|
|
@@ -374,19 +377,60 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
374
377
|
return this;
|
|
375
378
|
}
|
|
376
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
|
+
|
|
377
417
|
// ---------- Terminal / Execution Methods ----------
|
|
378
418
|
|
|
379
419
|
/** Execute the query and return all matching rows. */
|
|
380
420
|
all(): T[] {
|
|
381
421
|
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
382
|
-
|
|
422
|
+
const results = this.executor(sql, params, this.iqo.raw);
|
|
423
|
+
return this._applyEagerLoads(results);
|
|
383
424
|
}
|
|
384
425
|
|
|
385
426
|
/** Execute the query and return the first matching row, or null. */
|
|
386
427
|
get(): T | null {
|
|
387
428
|
this.iqo.limit = 1;
|
|
388
429
|
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
389
|
-
|
|
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;
|
|
390
434
|
}
|
|
391
435
|
|
|
392
436
|
/** Execute the query and return the count of matching rows. */
|
package/src/types.ts
CHANGED
|
@@ -146,9 +146,10 @@ export type NavEntityAccessor<
|
|
|
146
146
|
/**
|
|
147
147
|
* Stream new rows one at a time, in insertion order.
|
|
148
148
|
* Only emits rows inserted AFTER subscription starts.
|
|
149
|
+
* Callbacks are awaited — strict ordering guaranteed even with async handlers.
|
|
149
150
|
* @returns Unsubscribe function.
|
|
150
151
|
*/
|
|
151
|
-
on: (callback: (row: NavEntity<S, R, Table>) => void
|
|
152
|
+
on: (callback: (row: NavEntity<S, R, Table>) => void | Promise<void>, options?: { interval?: number }) => () => void;
|
|
152
153
|
_tableName: string;
|
|
153
154
|
readonly _schema?: S[Table & keyof S];
|
|
154
155
|
};
|
|
@@ -177,9 +178,10 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
177
178
|
/**
|
|
178
179
|
* Stream new rows one at a time, in insertion order.
|
|
179
180
|
* Only emits rows inserted AFTER subscription starts.
|
|
181
|
+
* Callbacks are awaited — strict ordering guaranteed even with async handlers.
|
|
180
182
|
* @returns Unsubscribe function.
|
|
181
183
|
*/
|
|
182
|
-
on: (callback: (row: AugmentedEntity<S>) => void
|
|
184
|
+
on: (callback: (row: AugmentedEntity<S>) => void | Promise<void>, options?: { interval?: number }) => () => void;
|
|
183
185
|
_tableName: string;
|
|
184
186
|
readonly _schema?: S;
|
|
185
187
|
};
|