tina4-nodejs 3.13.14 → 3.13.18
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/CLAUDE.md +42 -36
- package/package.json +1 -1
- package/packages/cli/src/commands/migrate.ts +7 -5
- package/packages/cli/src/commands/migrateRollback.ts +3 -3
- package/packages/core/src/server.ts +1 -1
- package/packages/orm/src/adapters/postgres.ts +29 -0
- package/packages/orm/src/autoCrud.ts +32 -16
- package/packages/orm/src/baseModel.ts +233 -197
- package/packages/orm/src/database.ts +189 -70
- package/packages/orm/src/databaseResult.ts +24 -0
- package/packages/orm/src/index.ts +6 -0
- package/packages/orm/src/migration.ts +128 -75
- package/packages/orm/src/queryBuilder.ts +12 -9
- package/packages/orm/src/seeder.ts +2 -1
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl,
|
|
3
|
+
adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
|
|
4
|
+
adapterStartTransaction, adapterCommit, adapterRollback,
|
|
5
|
+
adapterTableExists, adapterCreateTable, extractLastInsertId,
|
|
6
|
+
} from "./database.js";
|
|
2
7
|
import { validate as validateFields } from "./validation.js";
|
|
3
8
|
import { QueryBuilder } from "./queryBuilder.js";
|
|
4
9
|
import { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
5
10
|
import { QueryCache } from "./sqlTranslation.js";
|
|
11
|
+
import { Log } from "@tina4/core";
|
|
6
12
|
import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
|
|
7
13
|
|
|
8
14
|
/**
|
|
@@ -258,7 +264,7 @@ export class BaseModel {
|
|
|
258
264
|
* @param id Primary key value.
|
|
259
265
|
* @param include Optional array of relationship names to eager-load.
|
|
260
266
|
*/
|
|
261
|
-
static findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
|
|
267
|
+
static async findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): Promise<T | null> {
|
|
262
268
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
263
269
|
const pkCol = ModelClass.getPkColumn();
|
|
264
270
|
let sql = `SELECT * FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`;
|
|
@@ -270,9 +276,9 @@ export class BaseModel {
|
|
|
270
276
|
sql += ` AND ${ModelClass.tableFilter}`;
|
|
271
277
|
}
|
|
272
278
|
|
|
273
|
-
const instance = ModelClass.selectOne<T>(sql, [id]);
|
|
279
|
+
const instance = await ModelClass.selectOne<T>(sql, [id]);
|
|
274
280
|
if (instance && include) {
|
|
275
|
-
ModelClass._eagerLoad([instance], include);
|
|
281
|
+
await ModelClass._eagerLoad([instance], include);
|
|
276
282
|
}
|
|
277
283
|
return instance;
|
|
278
284
|
}
|
|
@@ -283,12 +289,12 @@ export class BaseModel {
|
|
|
283
289
|
* Usage:
|
|
284
290
|
* const user = User.create({ name: "Alice", email: "alice@example.com" });
|
|
285
291
|
*/
|
|
286
|
-
static create<T extends BaseModel>(
|
|
292
|
+
static async create<T extends BaseModel>(
|
|
287
293
|
this: new (data?: Record<string, unknown>) => T,
|
|
288
294
|
data: Record<string, unknown> = {},
|
|
289
|
-
): T {
|
|
295
|
+
): Promise<T> {
|
|
290
296
|
const instance = new this(data) as T;
|
|
291
|
-
instance.save();
|
|
297
|
+
await instance.save();
|
|
292
298
|
return instance;
|
|
293
299
|
}
|
|
294
300
|
|
|
@@ -303,14 +309,14 @@ export class BaseModel {
|
|
|
303
309
|
*
|
|
304
310
|
* Use findById(id) for single-record primary key lookup.
|
|
305
311
|
*/
|
|
306
|
-
static find<T extends BaseModel>(
|
|
312
|
+
static async find<T extends BaseModel>(
|
|
307
313
|
this: new (data?: Record<string, unknown>) => T,
|
|
308
314
|
filter?: Record<string, unknown>,
|
|
309
315
|
limit = 100,
|
|
310
316
|
offset = 0,
|
|
311
317
|
orderBy?: string,
|
|
312
318
|
include?: string[],
|
|
313
|
-
): T[] {
|
|
319
|
+
): Promise<T[]> {
|
|
314
320
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
315
321
|
const db = ModelClass.getDb();
|
|
316
322
|
const conditions: string[] = [];
|
|
@@ -336,7 +342,7 @@ export class BaseModel {
|
|
|
336
342
|
sql += ` ORDER BY ${orderBy}`;
|
|
337
343
|
}
|
|
338
344
|
|
|
339
|
-
const rows = db
|
|
345
|
+
const rows = await adapterFetch(db, sql, params, limit, offset);
|
|
340
346
|
const data = (rows as any)?.data ?? rows;
|
|
341
347
|
const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
|
|
342
348
|
const inst = new this(row) as T;
|
|
@@ -345,7 +351,7 @@ export class BaseModel {
|
|
|
345
351
|
});
|
|
346
352
|
|
|
347
353
|
if (include) {
|
|
348
|
-
ModelClass._eagerLoad(instances as BaseModel[], include);
|
|
354
|
+
await ModelClass._eagerLoad(instances as BaseModel[], include);
|
|
349
355
|
}
|
|
350
356
|
|
|
351
357
|
return instances;
|
|
@@ -365,7 +371,7 @@ export class BaseModel {
|
|
|
365
371
|
*
|
|
366
372
|
* Returns true if found, false otherwise.
|
|
367
373
|
*/
|
|
368
|
-
load(filter?: string, params?: unknown[], include?: string[]): boolean {
|
|
374
|
+
async load(filter?: string, params?: unknown[], include?: string[]): Promise<boolean> {
|
|
369
375
|
const ModelClass = this.constructor as typeof BaseModel & (new (data?: Record<string, unknown>) => BaseModel);
|
|
370
376
|
const table = (ModelClass as any).tableName ?? (this as any).tableName;
|
|
371
377
|
|
|
@@ -381,7 +387,7 @@ export class BaseModel {
|
|
|
381
387
|
sql = `SELECT * FROM ${table} WHERE ${filter}`;
|
|
382
388
|
}
|
|
383
389
|
|
|
384
|
-
const result = ModelClass.selectOne(sql, params, include);
|
|
390
|
+
const result = await ModelClass.selectOne(sql, params, include);
|
|
385
391
|
if (!result) return false;
|
|
386
392
|
const data = (result as any).toJSON ? (result as any).toJSON() : result;
|
|
387
393
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -398,13 +404,13 @@ export class BaseModel {
|
|
|
398
404
|
* @param params Optional query parameters.
|
|
399
405
|
* @param include Optional array of relationship names to eager-load.
|
|
400
406
|
*/
|
|
401
|
-
static all<T extends BaseModel>(
|
|
407
|
+
static async all<T extends BaseModel>(
|
|
402
408
|
this: new (data?: Record<string, unknown>) => T,
|
|
403
409
|
where?: string,
|
|
404
410
|
params?: unknown[],
|
|
405
411
|
include?: string[],
|
|
406
412
|
orderBy?: string,
|
|
407
|
-
): T[] {
|
|
413
|
+
): Promise<T[]> {
|
|
408
414
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
409
415
|
const db = ModelClass.getDb();
|
|
410
416
|
|
|
@@ -423,10 +429,10 @@ export class BaseModel {
|
|
|
423
429
|
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
424
430
|
const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
|
|
425
431
|
|
|
426
|
-
const rows = db
|
|
432
|
+
const rows = await adapterQuery(db, sql, params);
|
|
427
433
|
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
428
434
|
if (include) {
|
|
429
|
-
ModelClass._eagerLoad(instances, include);
|
|
435
|
+
await ModelClass._eagerLoad(instances, include);
|
|
430
436
|
}
|
|
431
437
|
return instances;
|
|
432
438
|
}
|
|
@@ -441,14 +447,14 @@ export class BaseModel {
|
|
|
441
447
|
* @param offset Skip records (default 0)
|
|
442
448
|
* @param include Relationship names to eager-load
|
|
443
449
|
*/
|
|
444
|
-
static where<T extends BaseModel>(
|
|
450
|
+
static async where<T extends BaseModel>(
|
|
445
451
|
this: new (data?: Record<string, unknown>) => T,
|
|
446
452
|
conditions: string,
|
|
447
453
|
params?: unknown[],
|
|
448
454
|
limit: number = 20,
|
|
449
455
|
offset: number = 0,
|
|
450
456
|
include?: string[],
|
|
451
|
-
): T[] {
|
|
457
|
+
): Promise<T[]> {
|
|
452
458
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
453
459
|
const db = ModelClass.getDb();
|
|
454
460
|
|
|
@@ -463,10 +469,10 @@ export class BaseModel {
|
|
|
463
469
|
|
|
464
470
|
const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")} LIMIT ${limit} OFFSET ${offset}`;
|
|
465
471
|
|
|
466
|
-
const rows = db
|
|
472
|
+
const rows = await adapterQuery(db, sql, params);
|
|
467
473
|
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
468
474
|
if (include) {
|
|
469
|
-
ModelClass._eagerLoad(instances, include);
|
|
475
|
+
await ModelClass._eagerLoad(instances, include);
|
|
470
476
|
}
|
|
471
477
|
return instances;
|
|
472
478
|
}
|
|
@@ -475,7 +481,7 @@ export class BaseModel {
|
|
|
475
481
|
* Save this instance (insert or update).
|
|
476
482
|
* Returns this on success (fluent), null on failure.
|
|
477
483
|
*/
|
|
478
|
-
save(): this | false {
|
|
484
|
+
async save(): Promise<this | false> {
|
|
479
485
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
480
486
|
const db = ModelClass.getDb();
|
|
481
487
|
const pk = ModelClass.getPkField();
|
|
@@ -498,7 +504,7 @@ export class BaseModel {
|
|
|
498
504
|
isUpdate = true;
|
|
499
505
|
} else {
|
|
500
506
|
try {
|
|
501
|
-
isUpdate = ModelClass.exists(pkValue);
|
|
507
|
+
isUpdate = await ModelClass.exists(pkValue);
|
|
502
508
|
} catch {
|
|
503
509
|
// If we can't tell (e.g. table doesn't exist yet), fall back
|
|
504
510
|
// to INSERT so the user sees the real driver error rather
|
|
@@ -508,19 +514,19 @@ export class BaseModel {
|
|
|
508
514
|
}
|
|
509
515
|
}
|
|
510
516
|
|
|
511
|
-
db
|
|
517
|
+
await adapterStartTransaction(db);
|
|
512
518
|
try {
|
|
513
519
|
if (isUpdate) {
|
|
514
520
|
// Update
|
|
515
521
|
const updateFields = Object.entries(ModelClass.fields).filter(
|
|
516
522
|
([name, def]) => !def.primaryKey && this[name] !== undefined,
|
|
517
523
|
);
|
|
518
|
-
if (updateFields.length === 0) { db
|
|
524
|
+
if (updateFields.length === 0) { await adapterCommit(db); return this; }
|
|
519
525
|
|
|
520
526
|
const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
|
|
521
527
|
const values = [...updateFields.map(([k]) => this[k]), pkValue];
|
|
522
528
|
|
|
523
|
-
db
|
|
529
|
+
await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
|
|
524
530
|
} else {
|
|
525
531
|
// Insert
|
|
526
532
|
const insertFields = Object.entries(ModelClass.fields).filter(
|
|
@@ -531,23 +537,42 @@ export class BaseModel {
|
|
|
531
537
|
const placeholders = insertFields.map(() => "?").join(", ");
|
|
532
538
|
const values = insertFields.map(([k]) => this[k]);
|
|
533
539
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
)
|
|
540
|
+
// For auto-increment PKs on engines that need it (PostgreSQL),
|
|
541
|
+
// RETURNING the PK column lets us read the engine-assigned id back.
|
|
542
|
+
// SQLite ignores the RETURNING clause harmlessly (it supports it
|
|
543
|
+
// since 3.35) and we still prefer its lastInsertRowid; for other
|
|
544
|
+
// engines extractLastInsertId() reads rows[0].id.
|
|
545
|
+
const wantReturning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter";
|
|
546
|
+
const insertSql =
|
|
547
|
+
`INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})` +
|
|
548
|
+
(wantReturning ? ` RETURNING "${pkCol}"` : "");
|
|
549
|
+
|
|
550
|
+
const result = await adapterExecute(db, insertSql, values);
|
|
538
551
|
|
|
539
552
|
// v3.13.11 (issue #50.2): only adopt the engine-assigned ID
|
|
540
553
|
// for auto-increment PKs. A natural-key PK was already set by
|
|
541
|
-
// the caller; don't overwrite it with the driver's last_id
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
554
|
+
// the caller; don't overwrite it with the driver's last_id.
|
|
555
|
+
if (pkField?.autoIncrement) {
|
|
556
|
+
// RETURNING result: pg puts it in rows[0][pkCol]; normalise here.
|
|
557
|
+
let newId = extractLastInsertId(result);
|
|
558
|
+
if (newId === null && result && typeof result === "object") {
|
|
559
|
+
const rows = (result as any).rows;
|
|
560
|
+
if (Array.isArray(rows) && rows[0]) {
|
|
561
|
+
newId = rows[0][pkCol] ?? rows[0].id ?? null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (newId === null) {
|
|
565
|
+
// Fall back to the adapter's tracked last id (MySQL/MSSQL).
|
|
566
|
+
newId = db.lastInsertId();
|
|
567
|
+
}
|
|
568
|
+
if (newId !== null && newId !== undefined) {
|
|
569
|
+
this[pk] = newId;
|
|
570
|
+
}
|
|
546
571
|
}
|
|
547
572
|
}
|
|
548
|
-
db
|
|
573
|
+
await adapterCommit(db);
|
|
549
574
|
} catch (e) {
|
|
550
|
-
db
|
|
575
|
+
await adapterRollback(db);
|
|
551
576
|
return false;
|
|
552
577
|
}
|
|
553
578
|
(this as any)._exists = true;
|
|
@@ -557,7 +582,7 @@ export class BaseModel {
|
|
|
557
582
|
/**
|
|
558
583
|
* Delete this instance. Uses soft delete if configured.
|
|
559
584
|
*/
|
|
560
|
-
delete(): boolean {
|
|
585
|
+
async delete(): Promise<boolean> {
|
|
561
586
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
562
587
|
const db = ModelClass.getDb();
|
|
563
588
|
const pk = ModelClass.getPkField();
|
|
@@ -568,23 +593,23 @@ export class BaseModel {
|
|
|
568
593
|
throw new Error("Cannot delete a model without a primary key value");
|
|
569
594
|
}
|
|
570
595
|
|
|
571
|
-
db
|
|
596
|
+
await adapterStartTransaction(db);
|
|
572
597
|
try {
|
|
573
598
|
if (ModelClass.softDelete) {
|
|
574
|
-
db
|
|
599
|
+
await adapterExecute(db,
|
|
575
600
|
`UPDATE "${ModelClass.tableName}" SET is_deleted = 1 WHERE "${pkCol}" = ?`,
|
|
576
601
|
[pkValue],
|
|
577
602
|
);
|
|
578
603
|
this.is_deleted = 1;
|
|
579
604
|
} else {
|
|
580
|
-
db
|
|
605
|
+
await adapterExecute(db,
|
|
581
606
|
`DELETE FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`,
|
|
582
607
|
[pkValue],
|
|
583
608
|
);
|
|
584
609
|
}
|
|
585
|
-
db
|
|
610
|
+
await adapterCommit(db);
|
|
586
611
|
} catch (e) {
|
|
587
|
-
db
|
|
612
|
+
await adapterRollback(db);
|
|
588
613
|
throw e;
|
|
589
614
|
}
|
|
590
615
|
return true;
|
|
@@ -624,14 +649,13 @@ export class BaseModel {
|
|
|
624
649
|
}
|
|
625
650
|
|
|
626
651
|
for (const [relName, nested] of Object.entries(topLevel)) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
this._relCache[relName] = related;
|
|
633
|
-
}
|
|
652
|
+
// toDict stays synchronous (used in routes, templates, serialization).
|
|
653
|
+
// Relationships must be eager-loaded first (await Model._eagerLoad / the
|
|
654
|
+
// include arg on find/all/where) which fills _relCache. A relation that
|
|
655
|
+
// isn't cached is simply skipped here — async lazy-load on a sync
|
|
656
|
+
// serializer is not possible after the Option A async refactor.
|
|
634
657
|
const data = this._relCache[relName];
|
|
658
|
+
if (data === undefined) continue;
|
|
635
659
|
if (data === null || data === undefined) {
|
|
636
660
|
result[relName] = null;
|
|
637
661
|
} else if (Array.isArray(data)) {
|
|
@@ -722,54 +746,59 @@ export class BaseModel {
|
|
|
722
746
|
* Generate and execute CREATE TABLE DDL from the model's field definitions.
|
|
723
747
|
* Uses the adapter's createTable method if available, otherwise builds SQL directly.
|
|
724
748
|
*/
|
|
725
|
-
static createTable(): boolean {
|
|
749
|
+
static async createTable(): Promise<boolean> {
|
|
726
750
|
const db = this.getDb();
|
|
727
|
-
if (db
|
|
751
|
+
if (await adapterTableExists(db, this.tableName)) return true;
|
|
728
752
|
|
|
729
|
-
|
|
730
|
-
|
|
753
|
+
// Prefer the adapter's createTable — every adapter implements it and the
|
|
754
|
+
// async variants (PostgreSQL/MySQL/MSSQL/Firebird) emit engine-aware DDL
|
|
755
|
+
// (datetime → TIMESTAMP, boolean → native BOOLEAN, auto-increment → SERIAL
|
|
756
|
+
// etc. on PG). Remap field keys to DB column names if fieldMapping exists.
|
|
757
|
+
if (typeof db.createTable === "function" || typeof (db as any).createTableAsync === "function") {
|
|
731
758
|
const mappedFields: Record<string, FieldDefinition> = {};
|
|
732
759
|
for (const [fieldName, def] of Object.entries(this.fields)) {
|
|
733
760
|
const dbCol = this.getDbColumn(fieldName);
|
|
734
761
|
mappedFields[dbCol] = def;
|
|
735
762
|
}
|
|
736
|
-
db
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const typeMap: Record<string, string> = {
|
|
740
|
-
integer: "INTEGER",
|
|
741
|
-
string: "TEXT",
|
|
742
|
-
text: "TEXT",
|
|
743
|
-
number: "REAL",
|
|
744
|
-
numeric: "REAL",
|
|
745
|
-
boolean: "INTEGER",
|
|
746
|
-
datetime: "TEXT",
|
|
747
|
-
};
|
|
763
|
+
await adapterCreateTable(db, this.tableName, mappedFields);
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
748
766
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
colDefs.push(parts.join(" "));
|
|
762
|
-
}
|
|
767
|
+
// Fallback: build SQL manually (SQLite-only dialect — used only when an
|
|
768
|
+
// adapter lacks createTable, which none currently do).
|
|
769
|
+
const typeMap: Record<string, string> = {
|
|
770
|
+
integer: "INTEGER",
|
|
771
|
+
string: "TEXT",
|
|
772
|
+
text: "TEXT",
|
|
773
|
+
number: "REAL",
|
|
774
|
+
numeric: "REAL",
|
|
775
|
+
boolean: "INTEGER",
|
|
776
|
+
datetime: "TEXT",
|
|
777
|
+
};
|
|
763
778
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
779
|
+
const colDefs: string[] = [];
|
|
780
|
+
for (const [fieldName, def] of Object.entries(this.fields)) {
|
|
781
|
+
const dbCol = this.getDbColumn(fieldName);
|
|
782
|
+
const sqlType = typeMap[def.type] || "TEXT";
|
|
783
|
+
const parts = [`"${dbCol}" ${sqlType}`];
|
|
784
|
+
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
785
|
+
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
786
|
+
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
787
|
+
if (def.default !== undefined) {
|
|
788
|
+
const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
|
|
789
|
+
parts.push(`DEFAULT ${dv}`);
|
|
772
790
|
}
|
|
791
|
+
colDefs.push(parts.join(" "));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
|
|
795
|
+
await adapterStartTransaction(db);
|
|
796
|
+
try {
|
|
797
|
+
await adapterExecute(db, sql);
|
|
798
|
+
await adapterCommit(db);
|
|
799
|
+
} catch (e) {
|
|
800
|
+
await adapterRollback(db);
|
|
801
|
+
throw e;
|
|
773
802
|
}
|
|
774
803
|
return true;
|
|
775
804
|
}
|
|
@@ -777,9 +806,9 @@ export class BaseModel {
|
|
|
777
806
|
/**
|
|
778
807
|
* Find a record by primary key or throw an error if not found.
|
|
779
808
|
*/
|
|
780
|
-
static findOrFail<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): T {
|
|
809
|
+
static async findOrFail<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): Promise<T> {
|
|
781
810
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
782
|
-
const result = ModelClass.findById(id) as T | null;
|
|
811
|
+
const result = (await ModelClass.findById(id)) as T | null;
|
|
783
812
|
if (result === null) {
|
|
784
813
|
throw new Error(`${ModelClass.tableName}: record with id ${id} not found`);
|
|
785
814
|
}
|
|
@@ -789,9 +818,9 @@ export class BaseModel {
|
|
|
789
818
|
/**
|
|
790
819
|
* Return true if a record with the given primary key exists.
|
|
791
820
|
*/
|
|
792
|
-
static exists(pkValue: unknown): boolean {
|
|
821
|
+
static async exists(pkValue: unknown): Promise<boolean> {
|
|
793
822
|
const ModelClass = this as unknown as typeof BaseModel;
|
|
794
|
-
return ModelClass.findById(pkValue) !== null;
|
|
823
|
+
return (await ModelClass.findById(pkValue)) !== null;
|
|
795
824
|
}
|
|
796
825
|
|
|
797
826
|
/**
|
|
@@ -804,7 +833,7 @@ export class BaseModel {
|
|
|
804
833
|
* @param offset Records to skip (default 0).
|
|
805
834
|
* @param include Relationship names to eager-load on cache miss.
|
|
806
835
|
*/
|
|
807
|
-
static cached<T extends BaseModel>(
|
|
836
|
+
static async cached<T extends BaseModel>(
|
|
808
837
|
this: new (data?: Record<string, unknown>) => T,
|
|
809
838
|
sql: string,
|
|
810
839
|
params?: unknown[],
|
|
@@ -812,7 +841,7 @@ export class BaseModel {
|
|
|
812
841
|
limit = 20,
|
|
813
842
|
offset = 0,
|
|
814
843
|
include?: string[],
|
|
815
|
-
): T[] {
|
|
844
|
+
): Promise<T[]> {
|
|
816
845
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
817
846
|
if (!ModelClass._queryCache) {
|
|
818
847
|
ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
|
|
@@ -824,10 +853,10 @@ export class BaseModel {
|
|
|
824
853
|
|
|
825
854
|
const db = ModelClass.getDb();
|
|
826
855
|
const querySql = `${sql} LIMIT ${limit} OFFSET ${offset}`;
|
|
827
|
-
const rows = db
|
|
856
|
+
const rows = await adapterQuery(db, querySql, params);
|
|
828
857
|
const results = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
829
858
|
if (include && results.length > 0) {
|
|
830
|
-
ModelClass._eagerLoad(results as BaseModel[], include);
|
|
859
|
+
await ModelClass._eagerLoad(results as BaseModel[], include);
|
|
831
860
|
}
|
|
832
861
|
ModelClass._queryCache.set(key, results, ttl);
|
|
833
862
|
return results;
|
|
@@ -846,28 +875,28 @@ export class BaseModel {
|
|
|
846
875
|
/**
|
|
847
876
|
* Execute a raw SQL SELECT and return results as model instances.
|
|
848
877
|
*/
|
|
849
|
-
static select<T extends BaseModel>(
|
|
878
|
+
static async select<T extends BaseModel>(
|
|
850
879
|
this: new (data?: Record<string, unknown>) => T,
|
|
851
880
|
sql: string,
|
|
852
881
|
params?: unknown[],
|
|
853
|
-
): T[] {
|
|
882
|
+
): Promise<T[]> {
|
|
854
883
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
855
884
|
const db = ModelClass.getDb();
|
|
856
|
-
const rows = db
|
|
885
|
+
const rows = await adapterQuery(db, sql, params);
|
|
857
886
|
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
858
887
|
}
|
|
859
888
|
|
|
860
|
-
static selectOne<T extends BaseModel>(
|
|
889
|
+
static async selectOne<T extends BaseModel>(
|
|
861
890
|
this: new (data?: Record<string, unknown>) => T,
|
|
862
891
|
sql: string,
|
|
863
892
|
params?: unknown[],
|
|
864
893
|
include?: string[],
|
|
865
|
-
): T | null {
|
|
894
|
+
): Promise<T | null> {
|
|
866
895
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
867
|
-
const results = ModelClass.select<T>(sql, params);
|
|
896
|
+
const results = await ModelClass.select<T>(sql, params);
|
|
868
897
|
const instance = results[0] ?? null;
|
|
869
898
|
if (instance && include) {
|
|
870
|
-
ModelClass._eagerLoad([instance], include);
|
|
899
|
+
await ModelClass._eagerLoad([instance], include);
|
|
871
900
|
}
|
|
872
901
|
return instance;
|
|
873
902
|
}
|
|
@@ -875,7 +904,7 @@ export class BaseModel {
|
|
|
875
904
|
/**
|
|
876
905
|
* Permanently delete this instance, bypassing soft delete.
|
|
877
906
|
*/
|
|
878
|
-
forceDelete(): boolean {
|
|
907
|
+
async forceDelete(): Promise<boolean> {
|
|
879
908
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
880
909
|
const db = ModelClass.getDb();
|
|
881
910
|
const pk = ModelClass.getPkField();
|
|
@@ -886,15 +915,15 @@ export class BaseModel {
|
|
|
886
915
|
throw new Error("Cannot delete a model without a primary key value");
|
|
887
916
|
}
|
|
888
917
|
|
|
889
|
-
db
|
|
918
|
+
await adapterStartTransaction(db);
|
|
890
919
|
try {
|
|
891
|
-
db
|
|
920
|
+
await adapterExecute(db,
|
|
892
921
|
`DELETE FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`,
|
|
893
922
|
[pkValue],
|
|
894
923
|
);
|
|
895
|
-
db
|
|
924
|
+
await adapterCommit(db);
|
|
896
925
|
} catch (e) {
|
|
897
|
-
db
|
|
926
|
+
await adapterRollback(db);
|
|
898
927
|
throw e;
|
|
899
928
|
}
|
|
900
929
|
return true;
|
|
@@ -903,7 +932,7 @@ export class BaseModel {
|
|
|
903
932
|
/**
|
|
904
933
|
* Restore a soft-deleted record.
|
|
905
934
|
*/
|
|
906
|
-
restore(): boolean {
|
|
935
|
+
async restore(): Promise<boolean> {
|
|
907
936
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
908
937
|
if (!ModelClass.softDelete) {
|
|
909
938
|
throw new Error("restore() is only available on models with softDelete enabled");
|
|
@@ -918,15 +947,15 @@ export class BaseModel {
|
|
|
918
947
|
throw new Error("Cannot restore a model without a primary key value");
|
|
919
948
|
}
|
|
920
949
|
|
|
921
|
-
db
|
|
950
|
+
await adapterStartTransaction(db);
|
|
922
951
|
try {
|
|
923
|
-
db
|
|
952
|
+
await adapterExecute(db,
|
|
924
953
|
`UPDATE "${ModelClass.tableName}" SET is_deleted = 0 WHERE "${pkCol}" = ?`,
|
|
925
954
|
[pkValue],
|
|
926
955
|
);
|
|
927
|
-
db
|
|
956
|
+
await adapterCommit(db);
|
|
928
957
|
} catch (e) {
|
|
929
|
-
db
|
|
958
|
+
await adapterRollback(db);
|
|
930
959
|
throw e;
|
|
931
960
|
}
|
|
932
961
|
this.is_deleted = 0;
|
|
@@ -936,13 +965,13 @@ export class BaseModel {
|
|
|
936
965
|
/**
|
|
937
966
|
* Find records including soft-deleted ones.
|
|
938
967
|
*/
|
|
939
|
-
static withTrashed<T extends BaseModel>(
|
|
968
|
+
static async withTrashed<T extends BaseModel>(
|
|
940
969
|
this: new (data?: Record<string, unknown>) => T,
|
|
941
970
|
conditions?: string,
|
|
942
971
|
params?: unknown[],
|
|
943
972
|
limit?: number,
|
|
944
973
|
offset?: number,
|
|
945
|
-
): T[] {
|
|
974
|
+
): Promise<T[]> {
|
|
946
975
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
947
976
|
const db = ModelClass.getDb();
|
|
948
977
|
|
|
@@ -965,14 +994,14 @@ export class BaseModel {
|
|
|
965
994
|
sql += ` OFFSET ${offset}`;
|
|
966
995
|
}
|
|
967
996
|
|
|
968
|
-
const rows = db
|
|
997
|
+
const rows = await adapterQuery(db, sql, params);
|
|
969
998
|
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
970
999
|
}
|
|
971
1000
|
|
|
972
1001
|
/**
|
|
973
1002
|
* Count records matching conditions (respects soft delete and table filter).
|
|
974
1003
|
*/
|
|
975
|
-
static count(conditions?: string, params?: unknown[]): number {
|
|
1004
|
+
static async count(conditions?: string, params?: unknown[]): Promise<number> {
|
|
976
1005
|
const db = this.getDb();
|
|
977
1006
|
const parts: string[] = [];
|
|
978
1007
|
if (this.softDelete) {
|
|
@@ -986,8 +1015,14 @@ export class BaseModel {
|
|
|
986
1015
|
}
|
|
987
1016
|
const whereClause = parts.length > 0 ? ` WHERE ${parts.join(" AND ")}` : "";
|
|
988
1017
|
const sql = `SELECT COUNT(*) as cnt FROM "${this.tableName}"${whereClause}`;
|
|
989
|
-
const rows = db
|
|
990
|
-
|
|
1018
|
+
const rows = await adapterQuery(db, sql, params);
|
|
1019
|
+
if (rows.length === 0) return 0;
|
|
1020
|
+
// PostgreSQL returns COUNT(*) as a bigint, which the `pg` driver hands
|
|
1021
|
+
// back as a string ("2"); Firebird upper-cases the alias. Coerce to a
|
|
1022
|
+
// Number and tolerate case so count() returns a real number on every engine.
|
|
1023
|
+
const row = rows[0] as Record<string, unknown>;
|
|
1024
|
+
const cnt = row.cnt ?? row.CNT ?? 0;
|
|
1025
|
+
return Number(cnt);
|
|
991
1026
|
}
|
|
992
1027
|
|
|
993
1028
|
/**
|
|
@@ -1012,11 +1047,11 @@ export class BaseModel {
|
|
|
1012
1047
|
/**
|
|
1013
1048
|
* Load a has-one related model instance.
|
|
1014
1049
|
*/
|
|
1015
|
-
hasOne<T extends BaseModel, R extends BaseModel>(
|
|
1050
|
+
async hasOne<T extends BaseModel, R extends BaseModel>(
|
|
1016
1051
|
this: T,
|
|
1017
1052
|
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
1018
1053
|
foreignKey: string,
|
|
1019
|
-
): R | null {
|
|
1054
|
+
): Promise<R | null> {
|
|
1020
1055
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1021
1056
|
const pk = ModelClass.getPkField();
|
|
1022
1057
|
const pkValue = this[pk];
|
|
@@ -1032,7 +1067,7 @@ export class BaseModel {
|
|
|
1032
1067
|
}
|
|
1033
1068
|
sql += ` LIMIT 1`;
|
|
1034
1069
|
|
|
1035
|
-
const rows = db
|
|
1070
|
+
const rows = await adapterQuery(db, sql, [pkValue]);
|
|
1036
1071
|
if (rows.length === 0) return null;
|
|
1037
1072
|
|
|
1038
1073
|
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
@@ -1044,13 +1079,13 @@ export class BaseModel {
|
|
|
1044
1079
|
/**
|
|
1045
1080
|
* Load has-many related model instances.
|
|
1046
1081
|
*/
|
|
1047
|
-
hasMany<T extends BaseModel, R extends BaseModel>(
|
|
1082
|
+
async hasMany<T extends BaseModel, R extends BaseModel>(
|
|
1048
1083
|
this: T,
|
|
1049
1084
|
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
1050
1085
|
foreignKey: string,
|
|
1051
1086
|
limit: number = 100,
|
|
1052
1087
|
offset: number = 0,
|
|
1053
|
-
): R[] {
|
|
1088
|
+
): Promise<R[]> {
|
|
1054
1089
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1055
1090
|
const pk = ModelClass.getPkField();
|
|
1056
1091
|
const pkValue = this[pk];
|
|
@@ -1066,7 +1101,7 @@ export class BaseModel {
|
|
|
1066
1101
|
}
|
|
1067
1102
|
sql += ` LIMIT ${limit} OFFSET ${offset}`;
|
|
1068
1103
|
|
|
1069
|
-
const rows = db
|
|
1104
|
+
const rows = await adapterQuery(db, sql, [pkValue]);
|
|
1070
1105
|
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
|
|
1071
1106
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1072
1107
|
this[relKey] = related;
|
|
@@ -1076,11 +1111,11 @@ export class BaseModel {
|
|
|
1076
1111
|
/**
|
|
1077
1112
|
* Load the parent model this instance belongs to.
|
|
1078
1113
|
*/
|
|
1079
|
-
belongsTo<T extends BaseModel, R extends BaseModel>(
|
|
1114
|
+
async belongsTo<T extends BaseModel, R extends BaseModel>(
|
|
1080
1115
|
this: T,
|
|
1081
1116
|
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
1082
1117
|
foreignKey: string,
|
|
1083
|
-
): R | null {
|
|
1118
|
+
): Promise<R | null> {
|
|
1084
1119
|
// foreignKey is a DB column name — resolve to JS property name on this model
|
|
1085
1120
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1086
1121
|
const reverseMap = ModelClass.getReverseMapping();
|
|
@@ -1099,7 +1134,7 @@ export class BaseModel {
|
|
|
1099
1134
|
}
|
|
1100
1135
|
sql += ` LIMIT 1`;
|
|
1101
1136
|
|
|
1102
|
-
const rows = db
|
|
1137
|
+
const rows = await adapterQuery(db, sql, [fkValue]);
|
|
1103
1138
|
if (rows.length === 0) return null;
|
|
1104
1139
|
|
|
1105
1140
|
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
@@ -1115,63 +1150,35 @@ export class BaseModel {
|
|
|
1115
1150
|
|
|
1116
1151
|
static registerModel(name: string, modelClass: typeof BaseModel): void {
|
|
1117
1152
|
BaseModel._modelRegistry[name] = modelClass;
|
|
1153
|
+
// Eagerly process this model's foreignKey fields so the cross-model
|
|
1154
|
+
// _fkRegistry is populated as soon as the model is known — not only when
|
|
1155
|
+
// the *declaring* model happens to be touched first. This is what makes
|
|
1156
|
+
// eager loading work standalone (without server-boot auto-discovery):
|
|
1157
|
+
// a parent's hasMany is registered by the child's _processForeignKeys(),
|
|
1158
|
+
// so we must run it for every registered model proactively.
|
|
1159
|
+
modelClass._processForeignKeys();
|
|
1118
1160
|
}
|
|
1119
1161
|
|
|
1120
1162
|
/**
|
|
1121
|
-
*
|
|
1163
|
+
* Process foreignKey fields on every registered model so the cross-model
|
|
1164
|
+
* _fkRegistry (and each model's belongsTo/hasMany) is fully wired regardless
|
|
1165
|
+
* of which model was used first. Idempotent — _processForeignKeys() and
|
|
1166
|
+
* _applyFkRegistry() both guard against duplicates.
|
|
1122
1167
|
*/
|
|
1123
|
-
private static
|
|
1124
|
-
|
|
1168
|
+
private static _processAllForeignKeys(): void {
|
|
1169
|
+
for (const modelClass of Object.values(BaseModel._modelRegistry)) {
|
|
1170
|
+
modelClass._processForeignKeys();
|
|
1171
|
+
}
|
|
1172
|
+
for (const modelClass of Object.values(BaseModel._modelRegistry)) {
|
|
1173
|
+
modelClass._applyFkRegistry();
|
|
1174
|
+
}
|
|
1125
1175
|
}
|
|
1126
1176
|
|
|
1127
1177
|
/**
|
|
1128
|
-
*
|
|
1178
|
+
* Resolve a model class by name from the registry.
|
|
1129
1179
|
*/
|
|
1130
|
-
private
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
// Apply FK registry so foreignKey fields auto-wire relationships
|
|
1134
|
-
ModelClass._processForeignKeys();
|
|
1135
|
-
ModelClass._applyFkRegistry();
|
|
1136
|
-
|
|
1137
|
-
// Check hasOne
|
|
1138
|
-
if (ModelClass.hasOne) {
|
|
1139
|
-
const rel = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
|
|
1140
|
-
if (rel) {
|
|
1141
|
-
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
1142
|
-
if (relatedClass) {
|
|
1143
|
-
return this.hasOne(relatedClass as any, rel.foreignKey);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Check hasMany
|
|
1149
|
-
if (ModelClass.hasMany) {
|
|
1150
|
-
const rel = ModelClass.hasMany.find((r) => {
|
|
1151
|
-
const base = r.model.toLowerCase();
|
|
1152
|
-
const key = _pluralRelKeys() ? base + "s" : base;
|
|
1153
|
-
return key === relName || base === relName || r.model === relName;
|
|
1154
|
-
});
|
|
1155
|
-
if (rel) {
|
|
1156
|
-
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
1157
|
-
if (relatedClass) {
|
|
1158
|
-
return this.hasMany(relatedClass as any, rel.foreignKey);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// Check belongsTo
|
|
1164
|
-
if (ModelClass.belongsTo) {
|
|
1165
|
-
const rel = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
|
|
1166
|
-
if (rel) {
|
|
1167
|
-
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
1168
|
-
if (relatedClass) {
|
|
1169
|
-
return this.belongsTo(relatedClass as any, rel.foreignKey);
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
return undefined;
|
|
1180
|
+
private static _resolveModel(name: string): (typeof BaseModel) | null {
|
|
1181
|
+
return BaseModel._modelRegistry[name] ?? null;
|
|
1175
1182
|
}
|
|
1176
1183
|
|
|
1177
1184
|
/**
|
|
@@ -1179,14 +1186,18 @@ export class BaseModel {
|
|
|
1179
1186
|
* @param instances Array of model instances.
|
|
1180
1187
|
* @param include Array of relationship names (supports dot notation for nesting).
|
|
1181
1188
|
*/
|
|
1182
|
-
static _eagerLoad(instances: BaseModel[], include: string[]): void {
|
|
1189
|
+
static async _eagerLoad(instances: BaseModel[], include: string[]): Promise<void> {
|
|
1183
1190
|
if (instances.length === 0) return;
|
|
1184
1191
|
|
|
1185
1192
|
const ModelClass = instances[0].constructor as typeof BaseModel;
|
|
1186
1193
|
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1194
|
+
// Wire FK relationships across ALL registered models, not just the parent.
|
|
1195
|
+
// A parent's hasMany is declared by the CHILD's foreignKey field, so we must
|
|
1196
|
+
// process every registered model's FKs before resolving includes — otherwise
|
|
1197
|
+
// a standalone Author.findById(id, ["posts"]) silently finds nothing because
|
|
1198
|
+
// Post._processForeignKeys() never ran. _applyFkRegistry() then merges the
|
|
1199
|
+
// registered hasMany entries onto each model.
|
|
1200
|
+
BaseModel._processAllForeignKeys();
|
|
1190
1201
|
|
|
1191
1202
|
// Group includes: top-level and nested
|
|
1192
1203
|
const topLevel: Record<string, string[]> = {};
|
|
@@ -1202,28 +1213,54 @@ export class BaseModel {
|
|
|
1202
1213
|
}
|
|
1203
1214
|
|
|
1204
1215
|
for (const [relName, nested] of Object.entries(topLevel)) {
|
|
1205
|
-
// Find the relationship definition
|
|
1216
|
+
// Find the relationship definition.
|
|
1217
|
+
//
|
|
1218
|
+
// Include names are resolved case-insensitively against each candidate
|
|
1219
|
+
// relation. Accepted forms (for a relation to model "Post" on table "posts"):
|
|
1220
|
+
// - the model name → "Post" / "post"
|
|
1221
|
+
// - the auto/related key → "post" (singular) or "posts" (when
|
|
1222
|
+
// TINA4_ORM_PLURAL_TABLE_NAMES is enabled)
|
|
1223
|
+
// - the table name → "posts"
|
|
1224
|
+
// All matching is lower-cased so "Post", "post" and "posts" all resolve.
|
|
1225
|
+
const want = relName.toLowerCase();
|
|
1206
1226
|
let relDef: RelationshipDefinition | undefined;
|
|
1207
1227
|
let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
|
|
1208
1228
|
|
|
1229
|
+
const matchesModel = (r: RelationshipDefinition): boolean => {
|
|
1230
|
+
const base = r.model.toLowerCase();
|
|
1231
|
+
const related = BaseModel._modelRegistry[r.model];
|
|
1232
|
+
const table = related?.tableName?.toLowerCase();
|
|
1233
|
+
return (
|
|
1234
|
+
base === want ||
|
|
1235
|
+
base + "s" === want ||
|
|
1236
|
+
(table !== undefined && table === want)
|
|
1237
|
+
);
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1209
1240
|
if (ModelClass.hasOne) {
|
|
1210
|
-
relDef = ModelClass.hasOne.find(
|
|
1241
|
+
relDef = ModelClass.hasOne.find(matchesModel);
|
|
1211
1242
|
if (relDef) relType = "hasOne";
|
|
1212
1243
|
}
|
|
1213
1244
|
if (!relDef && ModelClass.hasMany) {
|
|
1214
|
-
relDef = ModelClass.hasMany.find(
|
|
1215
|
-
const base = r.model.toLowerCase();
|
|
1216
|
-
const key = _pluralRelKeys() ? base + "s" : base;
|
|
1217
|
-
return key === relName || base === relName || r.model === relName;
|
|
1218
|
-
});
|
|
1245
|
+
relDef = ModelClass.hasMany.find(matchesModel);
|
|
1219
1246
|
if (relDef) relType = "hasMany";
|
|
1220
1247
|
}
|
|
1221
1248
|
if (!relDef && ModelClass.belongsTo) {
|
|
1222
|
-
relDef = ModelClass.belongsTo.find(
|
|
1249
|
+
relDef = ModelClass.belongsTo.find(matchesModel);
|
|
1223
1250
|
if (relDef) relType = "belongsTo";
|
|
1224
1251
|
}
|
|
1225
1252
|
|
|
1226
|
-
if (!relDef || !relType)
|
|
1253
|
+
if (!relDef || !relType) {
|
|
1254
|
+
// Don't silently skip — a typo'd or unknown include name is almost
|
|
1255
|
+
// always a developer mistake. Surface it so it's visible.
|
|
1256
|
+
Log.warn(
|
|
1257
|
+
`eager-load: include "${relName}" did not match any relationship on ` +
|
|
1258
|
+
`${ModelClass.name} (table "${ModelClass.tableName}"). ` +
|
|
1259
|
+
`Accepted forms are the related model name, its singular/plural ` +
|
|
1260
|
+
`key, or the related table name (case-insensitive).`,
|
|
1261
|
+
);
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1227
1264
|
|
|
1228
1265
|
const relatedClass = BaseModel._modelRegistry[relDef.model];
|
|
1229
1266
|
if (!relatedClass) continue;
|
|
@@ -1244,12 +1281,12 @@ export class BaseModel {
|
|
|
1244
1281
|
sql += ` AND is_deleted = 0`;
|
|
1245
1282
|
}
|
|
1246
1283
|
|
|
1247
|
-
const rows = db
|
|
1284
|
+
const rows = await adapterQuery(db, sql, pkValues);
|
|
1248
1285
|
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
|
|
1249
1286
|
|
|
1250
1287
|
// Eager load nested
|
|
1251
1288
|
if (nested.length > 0 && related.length > 0) {
|
|
1252
|
-
relatedClass._eagerLoad(related, nested);
|
|
1289
|
+
await relatedClass._eagerLoad(related, nested);
|
|
1253
1290
|
}
|
|
1254
1291
|
|
|
1255
1292
|
// Group by FK — fk is a DB column name, resolve to JS property name on the related model
|
|
@@ -1290,11 +1327,11 @@ export class BaseModel {
|
|
|
1290
1327
|
sql += ` AND is_deleted = 0`;
|
|
1291
1328
|
}
|
|
1292
1329
|
|
|
1293
|
-
const rows = db
|
|
1330
|
+
const rows = await adapterQuery(db, sql, fkValues);
|
|
1294
1331
|
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
|
|
1295
1332
|
|
|
1296
1333
|
if (nested.length > 0 && related.length > 0) {
|
|
1297
|
-
relatedClass._eagerLoad(related, nested);
|
|
1334
|
+
await relatedClass._eagerLoad(related, nested);
|
|
1298
1335
|
}
|
|
1299
1336
|
|
|
1300
1337
|
const lookup: Record<string, BaseModel> = {};
|
|
@@ -1323,10 +1360,9 @@ export class BaseModel {
|
|
|
1323
1360
|
* @param instances Array of model instances to load relationships onto.
|
|
1324
1361
|
* @param includeList Array of relationship names (supports dot notation for nesting).
|
|
1325
1362
|
*/
|
|
1326
|
-
static eagerLoad(instances: BaseModel[], includeList: string[]): Promise<void> {
|
|
1363
|
+
static async eagerLoad(instances: BaseModel[], includeList: string[]): Promise<void> {
|
|
1327
1364
|
const ModelClass = this as unknown as typeof BaseModel;
|
|
1328
|
-
ModelClass._eagerLoad(instances, includeList);
|
|
1329
|
-
return Promise.resolve();
|
|
1365
|
+
await ModelClass._eagerLoad(instances, includeList);
|
|
1330
1366
|
}
|
|
1331
1367
|
|
|
1332
1368
|
/**
|