@c9up/atlas 0.1.8 → 0.1.10
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/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +9 -1
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +3 -12
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +20 -4
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +122 -110
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +2 -2
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +62 -17
- package/dist/ModelQuery.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +7 -5
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +5 -6
- package/dist/configure.js.map +1 -1
- package/dist/decorators/entity.d.ts +1 -1
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +11 -3
- package/dist/decorators/entity.js.map +1 -1
- package/dist/metadata-keys.d.ts +20 -0
- package/dist/metadata-keys.d.ts.map +1 -0
- package/dist/metadata-keys.js +13 -0
- package/dist/metadata-keys.js.map +1 -0
- package/dist/query/native.d.ts +19 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +77 -2
- package/dist/query/native.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +5 -2
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Seeder.d.ts +13 -8
- package/dist/schema/Seeder.d.ts.map +1 -1
- package/dist/schema/Seeder.js +13 -8
- package/dist/schema/Seeder.js.map +1 -1
- package/dist/services/db.d.ts +7 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +10 -0
- package/dist/services/db.js.map +1 -1
- package/dist/testing/Factory.d.ts +6 -7
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +3 -5
- package/dist/testing/Factory.js.map +1 -1
- package/dist/utils/safePath.d.ts +7 -1
- package/dist/utils/safePath.d.ts.map +1 -1
- package/dist/utils/safePath.js +24 -1
- package/dist/utils/safePath.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/AtlasProvider.ts +9 -1
- package/src/BaseEntity.ts +27 -12
- package/src/BaseRepository.ts +125 -118
- package/src/ModelQuery.ts +66 -17
- package/src/adapters/NapiDbAdapter.ts +11 -8
- package/src/configure.ts +5 -6
- package/src/decorators/entity.ts +12 -4
- package/src/metadata-keys.ts +22 -0
- package/src/query/native.ts +93 -2
- package/src/schema/MigrationRunner.ts +5 -2
- package/src/schema/Seeder.ts +14 -12
- package/src/services/db.ts +10 -0
- package/src/testing/Factory.ts +9 -12
- package/src/utils/safePath.ts +27 -2
package/src/BaseRepository.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
type AtlasDialect,
|
|
34
34
|
compileStatementNative,
|
|
35
35
|
getAtlasDialect,
|
|
36
|
+
registerColumnCast,
|
|
37
|
+
registerTableCasts,
|
|
36
38
|
} from "./query/native.js";
|
|
37
39
|
import { camelToSnake, snakeToCamel } from "./utils/casing.js";
|
|
38
40
|
|
|
@@ -265,6 +267,37 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
265
267
|
// Postgres cast hints: sqlx binds JS strings as `text`, which Postgres
|
|
266
268
|
// won't coerce to timestamp/uuid/date. See `computeCastTypes`.
|
|
267
269
|
this.#castTypes = computeCastTypes(entityClass);
|
|
270
|
+
// Publish this table's casts to the compile-time registry so EVERY
|
|
271
|
+
// statement on this table — including the fluent `ModelQuery` and relation
|
|
272
|
+
// loaders, which never receive `#castTypes` directly — gets `$N::uuid`
|
|
273
|
+
// casts on its params. Without this, `repo.query().where('id', uuid)` and
|
|
274
|
+
// relation WHEREs fail on Postgres with `operator does not exist: uuid = text`.
|
|
275
|
+
registerTableCasts(this.#tableName, this.#castTypes);
|
|
276
|
+
// Publish relation FK column casts to the (merging) registry so eager AND
|
|
277
|
+
// lazy relation WHEREs on a uuid FK get `::uuid` even when the FK column
|
|
278
|
+
// wasn't explicitly `@Column({ type })`-typed. A FK references a typed PK
|
|
279
|
+
// whose logical type we know. (m2m FKs live on the pivot table — handled
|
|
280
|
+
// separately via `pivotKeyCasts`.)
|
|
281
|
+
for (const rel of getRelationMetadata(entityClass)) {
|
|
282
|
+
if (rel.type === "manyToMany") continue;
|
|
283
|
+
const related = rel.target();
|
|
284
|
+
if (rel.type === "belongsTo") {
|
|
285
|
+
// FK lives on THIS table, references the related (owner) PK.
|
|
286
|
+
const fk = rel.foreignKey ?? `${camelToSnake(related.name)}_id`;
|
|
287
|
+
const ownerKey = rel.ownerKey ?? getPrimaryKey(related) ?? "id";
|
|
288
|
+
const cast = computeCastTypes(related)[camelToSnake(ownerKey)];
|
|
289
|
+
if (cast) registerColumnCast(this.#tableName, fk, cast);
|
|
290
|
+
} else {
|
|
291
|
+
// hasOne / hasMany: FK lives on the RELATED table, references THIS PK.
|
|
292
|
+
const fk = rel.foreignKey ?? `${camelToSnake(entityClass.name)}_id`;
|
|
293
|
+
const localKey = rel.localKey ?? this.#primaryKey;
|
|
294
|
+
const cast = this.#castTypes[camelToSnake(localKey)];
|
|
295
|
+
const relatedMeta = getEntityMetadata(related);
|
|
296
|
+
if (cast && relatedMeta) {
|
|
297
|
+
registerColumnCast(relatedMeta.tableName, fk, cast);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
268
301
|
}
|
|
269
302
|
|
|
270
303
|
// ─── Column validation ────────────────────────────────────
|
|
@@ -318,15 +351,10 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
318
351
|
// ─── Finders ──────────────────────────────────────────────
|
|
319
352
|
|
|
320
353
|
async find(id: string | number | bigint): Promise<T | null> {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
this.#
|
|
325
|
-
const { sql, params } = this.#compileSelect({ wheres, limit: 1 });
|
|
326
|
-
const rows = await this.#db.query<Row>(sql, params);
|
|
327
|
-
const row = rows[0];
|
|
328
|
-
if (!row) return null;
|
|
329
|
-
return this.#hydrate(row);
|
|
354
|
+
// Route through the query builder so the read hooks (beforeFind/afterFind)
|
|
355
|
+
// fire and a `beforeFind` hook can mutate the query — the previous direct
|
|
356
|
+
// `#compileSelect` fast path bypassed every read hook silently.
|
|
357
|
+
return this.query().where(this.#primaryKey, id).first();
|
|
330
358
|
}
|
|
331
359
|
|
|
332
360
|
async findOrFail(id: string | number): Promise<T> {
|
|
@@ -340,60 +368,34 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
340
368
|
}
|
|
341
369
|
|
|
342
370
|
async findBy(column: string, value: unknown): Promise<T | null> {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
{ column: col, operator: "=", value, type: "and" },
|
|
346
|
-
];
|
|
347
|
-
this.#appendSoftScope(wheres);
|
|
348
|
-
const { sql, params } = this.#compileSelect({ wheres, limit: 1 });
|
|
349
|
-
const rows = await this.#db.query<Row>(sql, params);
|
|
350
|
-
const row = rows[0];
|
|
351
|
-
if (!row) return null;
|
|
352
|
-
return this.#hydrate(row);
|
|
371
|
+
// Through the builder for read-hook parity (see `find`).
|
|
372
|
+
return this.query().where(column, value).first();
|
|
353
373
|
}
|
|
354
374
|
|
|
355
375
|
async all(): Promise<T[]> {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return this
|
|
376
|
+
// Through the builder so beforeFetch/afterFetch fire. The builder applies
|
|
377
|
+
// the soft-delete scope by default, exactly like the old fast path.
|
|
378
|
+
return this.query().exec();
|
|
359
379
|
}
|
|
360
380
|
|
|
361
381
|
async allWithTrashed(): Promise<T[]> {
|
|
362
|
-
return this
|
|
382
|
+
return this.query().withTrashed().exec();
|
|
363
383
|
}
|
|
364
384
|
|
|
365
385
|
async onlyTrashed(): Promise<T[]> {
|
|
366
386
|
if (!this.#softDeletes) return [];
|
|
367
|
-
return this
|
|
368
|
-
wheres: [
|
|
369
|
-
{
|
|
370
|
-
column: "deleted_at",
|
|
371
|
-
operator: "IS NOT NULL",
|
|
372
|
-
value: null,
|
|
373
|
-
type: "and",
|
|
374
|
-
},
|
|
375
|
-
],
|
|
376
|
-
});
|
|
387
|
+
return this.query().onlyTrashed().exec();
|
|
377
388
|
}
|
|
378
389
|
|
|
379
390
|
async where(column: string, value: unknown): Promise<T[]> {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// blew up on Postgres/MySQL the moment the app ran against a real driver.
|
|
389
|
-
// Using the PK works on every dialect and matches the user's actual
|
|
390
|
-
// schema — the ordering contract is "most recent first by PK" for
|
|
391
|
-
// `repo.where(col, val)` as a convenience finder.
|
|
392
|
-
const pkCol = camelToSnake(this.#primaryKey);
|
|
393
|
-
return this.#runSelect({
|
|
394
|
-
wheres,
|
|
395
|
-
orderBy: [{ column: pkCol, direction: "desc" }],
|
|
396
|
-
});
|
|
391
|
+
// Order by the primary key (DESC = most recent insert first when the PK is
|
|
392
|
+
// an auto-increment integer or a monotonic UUID). The ordering contract is
|
|
393
|
+
// "most recent first by PK" for `repo.where(col, val)` as a convenience
|
|
394
|
+
// finder. Through the builder for read-hook parity (see `find`).
|
|
395
|
+
return this.query()
|
|
396
|
+
.where(column, value)
|
|
397
|
+
.orderBy(this.#primaryKey, "desc")
|
|
398
|
+
.exec();
|
|
397
399
|
}
|
|
398
400
|
|
|
399
401
|
// ─── Create / Save / Delete ───────────────────────────────
|
|
@@ -417,6 +419,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
417
419
|
await this.#insert(entity);
|
|
418
420
|
await fireHooks(this.#entityClass, "afterCreate", entity);
|
|
419
421
|
await fireHooks(this.#entityClass, "afterSave", entity);
|
|
422
|
+
await this.#dispatchDomainEvents(entity);
|
|
420
423
|
return entity;
|
|
421
424
|
}
|
|
422
425
|
|
|
@@ -461,6 +464,17 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
461
464
|
}
|
|
462
465
|
await fireHooks(this.#entityClass, "afterSave", entity);
|
|
463
466
|
|
|
467
|
+
await this.#dispatchDomainEvents(entity);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Flush the entity's accumulated domain events through `onDomainEvents`.
|
|
472
|
+
* On dispatch failure the events are re-queued on the entity and the error
|
|
473
|
+
* propagates, so a caller can retry without losing them. Shared by `save`,
|
|
474
|
+
* `create` and `createMany` — every persistence path that produces a live
|
|
475
|
+
* entity must dispatch, otherwise events silently vanish on batch inserts.
|
|
476
|
+
*/
|
|
477
|
+
async #dispatchDomainEvents(entity: BaseEntity): Promise<void> {
|
|
464
478
|
const events = entity.flushDomainEvents();
|
|
465
479
|
if (events.length > 0 && this.onDomainEvents) {
|
|
466
480
|
try {
|
|
@@ -543,6 +557,9 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
543
557
|
await fireHooks(this.#entityClass, "afterCreate", e);
|
|
544
558
|
await fireHooks(this.#entityClass, "afterSave", e);
|
|
545
559
|
}
|
|
560
|
+
for (const e of entities) {
|
|
561
|
+
await this.#dispatchDomainEvents(e);
|
|
562
|
+
}
|
|
546
563
|
return entities;
|
|
547
564
|
}
|
|
548
565
|
|
|
@@ -680,15 +697,32 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
680
697
|
|
|
681
698
|
#applyConsume(propertyKey: string, value: unknown): unknown {
|
|
682
699
|
const consume = this.#columnConsumes.get(propertyKey);
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
700
|
+
if (consume) {
|
|
701
|
+
let result: unknown;
|
|
702
|
+
try {
|
|
703
|
+
result = consume(value);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
throw wrapAdapterError("consume", propertyKey, err);
|
|
706
|
+
}
|
|
707
|
+
assertNotPromise("consume", propertyKey, result);
|
|
708
|
+
return result;
|
|
689
709
|
}
|
|
690
|
-
|
|
691
|
-
|
|
710
|
+
// No explicit `@Column({ consume })`: a `@column.date()` / `@column.dateTime()`
|
|
711
|
+
// column hydrates its DB value (an ISO string from the Rust decode) into a
|
|
712
|
+
// JS `Date`, so `.getTime()` / date arithmetic work on read. Mirrors Adonis
|
|
713
|
+
// Lucid hydrating date columns to a Luxon `DateTime` — atlas standardises on
|
|
714
|
+
// the native `Date` (no Luxon dependency). An unparseable string is left
|
|
715
|
+
// untouched rather than turned into `Invalid Date`.
|
|
716
|
+
if (
|
|
717
|
+
this.#dateColumns[propertyKey] &&
|
|
718
|
+
value != null &&
|
|
719
|
+
!(value instanceof Date) &&
|
|
720
|
+
(typeof value === "string" || typeof value === "number")
|
|
721
|
+
) {
|
|
722
|
+
const d = new Date(value);
|
|
723
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
724
|
+
}
|
|
725
|
+
return value;
|
|
692
726
|
}
|
|
693
727
|
|
|
694
728
|
#plainToRowPairs(obj: Record<string, unknown>): Array<[string, unknown]> {
|
|
@@ -858,43 +892,6 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
858
892
|
|
|
859
893
|
// ─── Private helpers ──────────────────────────────────────
|
|
860
894
|
|
|
861
|
-
#compileSelect(opts: {
|
|
862
|
-
wheres?: Array<Record<string, unknown>>;
|
|
863
|
-
orderBy?: Array<Record<string, unknown>>;
|
|
864
|
-
limit?: number;
|
|
865
|
-
}): { sql: string; params: unknown[] } {
|
|
866
|
-
const spec = {
|
|
867
|
-
kind: "select",
|
|
868
|
-
table: this.#tableName,
|
|
869
|
-
select: ["*"],
|
|
870
|
-
wheres: opts.wheres ?? [],
|
|
871
|
-
orderBy: opts.orderBy ?? [],
|
|
872
|
-
groupBy: [],
|
|
873
|
-
having: [],
|
|
874
|
-
limit: opts.limit ?? null,
|
|
875
|
-
offset: null,
|
|
876
|
-
distinct: false,
|
|
877
|
-
ctes: [],
|
|
878
|
-
unions: [],
|
|
879
|
-
// Cast WHERE params on native-typed columns (uuid/timestamp/…) so a
|
|
880
|
-
// `WHERE id = $1` on a uuid PK emits `$1::uuid` — otherwise Postgres
|
|
881
|
-
// rejects it with `operator does not exist: uuid = text`.
|
|
882
|
-
casts: this.#castTypes,
|
|
883
|
-
};
|
|
884
|
-
const compiled = compileStatementNative(spec, this.#dialect);
|
|
885
|
-
return { sql: compiled.statements[0], params: compiled.params };
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
async #runSelect(opts: {
|
|
889
|
-
wheres?: Array<Record<string, unknown>>;
|
|
890
|
-
orderBy?: Array<Record<string, unknown>>;
|
|
891
|
-
limit?: number;
|
|
892
|
-
}): Promise<T[]> {
|
|
893
|
-
const { sql, params } = this.#compileSelect(opts);
|
|
894
|
-
const rows = await this.#db.query<Row>(sql, params);
|
|
895
|
-
return rows.map((r) => this.#hydrate(r));
|
|
896
|
-
}
|
|
897
|
-
|
|
898
895
|
async #runDelete(wheres: Array<Record<string, unknown>>): Promise<void> {
|
|
899
896
|
const compiled = compileStatementNative(
|
|
900
897
|
{ kind: "delete", table: this.#tableName, wheres },
|
|
@@ -973,17 +970,6 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
973
970
|
return {};
|
|
974
971
|
}
|
|
975
972
|
|
|
976
|
-
#appendSoftScope(wheres: Array<Record<string, unknown>>): void {
|
|
977
|
-
if (this.#softDeletes) {
|
|
978
|
-
wheres.push({
|
|
979
|
-
column: "deleted_at",
|
|
980
|
-
operator: "IS NULL",
|
|
981
|
-
value: null,
|
|
982
|
-
type: "and",
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
973
|
async #insert(entity: T): Promise<void> {
|
|
988
974
|
// Auto-generate the PK when declared via `@PrimaryKey({ generated })`.
|
|
989
975
|
this.#applyPrimaryKeyGenerator(entity);
|
|
@@ -1264,6 +1250,10 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1264
1250
|
const relatedRepo = new BaseRepository<BaseEntity>(relatedClass, this.#db, {
|
|
1265
1251
|
dialect: this.#dialect,
|
|
1266
1252
|
});
|
|
1253
|
+
// Propagate the domain-event sink so entities persisted through a relation
|
|
1254
|
+
// proxy (user.related('posts').create(...)) dispatch their events too —
|
|
1255
|
+
// otherwise the related entity's events silently vanish.
|
|
1256
|
+
relatedRepo.onDomainEvents = this.onDomainEvents;
|
|
1267
1257
|
const db = this.#db;
|
|
1268
1258
|
|
|
1269
1259
|
// FK column naming: belongsTo stores the FK on THIS side; has* / m2m on the OTHER side.
|
|
@@ -1326,6 +1316,14 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1326
1316
|
}
|
|
1327
1317
|
return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
1328
1318
|
};
|
|
1319
|
+
// The bound `?` carries the parent PK type (often uuid). A raw `?`
|
|
1320
|
+
// can't be cast by the structured `casts` mechanism, so emit the
|
|
1321
|
+
// `::uuid` inline — `whereRaw` rewrites `?`→`$N`, yielding `$N::uuid`.
|
|
1322
|
+
// Postgres-only; sqlite/mysql coerce. Without it: `pivotFk = $N` is
|
|
1323
|
+
// `uuid = text`.
|
|
1324
|
+
const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
|
|
1325
|
+
const ph =
|
|
1326
|
+
dialect === "postgres" && parentPkCast ? `?::${parentPkCast}` : "?";
|
|
1329
1327
|
// EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk)
|
|
1330
1328
|
// Framework-internal raw fragment (identifiers already validated by
|
|
1331
1329
|
// the `quote` helper above) — bypass strict mode so this path still
|
|
@@ -1333,7 +1331,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1333
1331
|
runWithAtlasInternalBypass(() => {
|
|
1334
1332
|
q.whereRaw(
|
|
1335
1333
|
`EXISTS (SELECT 1 FROM ${quote(pivot.pivotTable)} ` +
|
|
1336
|
-
`WHERE ${quote(pivot.pivotTable)}.${quote(pivotFk)} =
|
|
1334
|
+
`WHERE ${quote(pivot.pivotTable)}.${quote(pivotFk)} = ${ph} ` +
|
|
1337
1335
|
`AND ${quote(pivot.pivotTable)}.${quote(pivotOther)} = ${quote(relatedTable)}.${quote(relatedPk)})`,
|
|
1338
1336
|
[parentIdValue],
|
|
1339
1337
|
);
|
|
@@ -1395,6 +1393,20 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1395
1393
|
const pivotAdapters = pivot.pivotColumnAdapters;
|
|
1396
1394
|
const dialect = this.#dialect;
|
|
1397
1395
|
|
|
1396
|
+
// Postgres cast hints for the two pivot FK columns — they reference the
|
|
1397
|
+
// parent / related PK types (often uuid). The pivot table is NOT an
|
|
1398
|
+
// entity table, so the compile-time cast registry never covers it;
|
|
1399
|
+
// every pivot statement (sync's currentIds SELECT, detach DELETE, attach
|
|
1400
|
+
// INSERT) must carry these explicitly, else `pivotFk = $1` is `uuid = text`.
|
|
1401
|
+
const pivotKeyCasts: Record<string, string> = {};
|
|
1402
|
+
const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
|
|
1403
|
+
if (parentPkCast) pivotKeyCasts[pivotFk] = parentPkCast;
|
|
1404
|
+
const relatedPkCast =
|
|
1405
|
+
computeCastTypes(relatedClass)[
|
|
1406
|
+
camelToSnake(getPrimaryKey(relatedClass) ?? "id")
|
|
1407
|
+
];
|
|
1408
|
+
if (relatedPkCast) pivotKeyCasts[pivotOther] = relatedPkCast;
|
|
1409
|
+
|
|
1398
1410
|
/**
|
|
1399
1411
|
* Resolve pivot timestamp column names from the decorator config.
|
|
1400
1412
|
*
|
|
@@ -1467,6 +1479,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1467
1479
|
unions: [],
|
|
1468
1480
|
joins: [],
|
|
1469
1481
|
lockMode: null,
|
|
1482
|
+
casts: pivotKeyCasts,
|
|
1470
1483
|
};
|
|
1471
1484
|
const compiled = compileStatementNative(selectSpec, dialect);
|
|
1472
1485
|
const rows = await db.query<Record<string, unknown>>(
|
|
@@ -1496,6 +1509,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1496
1509
|
table: pivotTable,
|
|
1497
1510
|
wheres,
|
|
1498
1511
|
returning: [],
|
|
1512
|
+
casts: pivotKeyCasts,
|
|
1499
1513
|
};
|
|
1500
1514
|
const compiled = compileStatementNative(spec, dialect);
|
|
1501
1515
|
await db.execute(compiled.statements[0], compiled.params);
|
|
@@ -1557,17 +1571,10 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1557
1571
|
for (const [k, v] of Object.entries(ts)) pairs.push([k, v]);
|
|
1558
1572
|
return pairs;
|
|
1559
1573
|
});
|
|
1560
|
-
// Postgres casts for the pivot row: the two FK columns
|
|
1561
|
-
//
|
|
1562
|
-
// bound strings — all need `$N::<type>` on Postgres.
|
|
1563
|
-
const pivotCasts: Record<string, string> = {};
|
|
1564
|
-
const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
|
|
1565
|
-
if (parentPkCast) pivotCasts[pivotFk] = parentPkCast;
|
|
1566
|
-
const relatedPkCast =
|
|
1567
|
-
computeCastTypes(relatedClass)[
|
|
1568
|
-
camelToSnake(getPrimaryKey(relatedClass) ?? "id")
|
|
1569
|
-
];
|
|
1570
|
-
if (relatedPkCast) pivotCasts[pivotOther] = relatedPkCast;
|
|
1574
|
+
// Postgres casts for the pivot row: the two FK columns (parent /
|
|
1575
|
+
// related PK types, often uuid) reuse `pivotKeyCasts`; pivot
|
|
1576
|
+
// timestamps are bound strings — all need `$N::<type>` on Postgres.
|
|
1577
|
+
const pivotCasts: Record<string, string> = { ...pivotKeyCasts };
|
|
1571
1578
|
for (const k of Object.keys(ts)) pivotCasts[k] = "timestamp";
|
|
1572
1579
|
const spec = {
|
|
1573
1580
|
kind: "insert",
|
package/src/ModelQuery.ts
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
import type { BaseEntity } from "./BaseEntity.js";
|
|
11
11
|
import type { DatabaseConnection } from "./BaseRepository.js";
|
|
12
12
|
import {
|
|
13
|
+
getColumnMetadata,
|
|
13
14
|
getEntityMetadata,
|
|
14
15
|
getPrimaryKey,
|
|
15
16
|
getRelationMetadata,
|
|
16
17
|
hasSoftDeletes,
|
|
17
18
|
type RelationMetadata,
|
|
18
19
|
} from "./decorators/entity.js";
|
|
20
|
+
import { fireHooks } from "./decorators/hooks.js";
|
|
19
21
|
import {
|
|
20
22
|
type AtlasDialect,
|
|
21
23
|
compileStatementNative,
|
|
@@ -937,11 +939,15 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
937
939
|
return this;
|
|
938
940
|
}
|
|
939
941
|
|
|
940
|
-
/** Execute and return the first matching entity or null. */
|
|
942
|
+
/** Execute and return the first matching entity or null. Fires beforeFind/afterFind. */
|
|
941
943
|
async first(): Promise<T | null> {
|
|
942
944
|
this.#limit = 1;
|
|
943
|
-
|
|
944
|
-
|
|
945
|
+
await fireHooks(this.#entityClass, "beforeFind", this);
|
|
946
|
+
// Bypass exec() so the multi-row beforeFetch/afterFetch hooks don't ALSO
|
|
947
|
+
// fire — first() is the single-row terminal and owns beforeFind/afterFind.
|
|
948
|
+
const result = (await this.#doExec())[0] ?? null;
|
|
949
|
+
await fireHooks(this.#entityClass, "afterFind", result);
|
|
950
|
+
return result;
|
|
945
951
|
}
|
|
946
952
|
|
|
947
953
|
/** Execute and return the first matching entity or throw. */
|
|
@@ -1035,12 +1041,19 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1035
1041
|
*/
|
|
1036
1042
|
#cachedExec?: Promise<T[]>;
|
|
1037
1043
|
|
|
1038
|
-
/** Execute and return all matching entities, with preloaded relations. */
|
|
1044
|
+
/** Execute and return all matching entities, with preloaded relations. Fires beforeFetch/afterFetch. */
|
|
1039
1045
|
exec(): Promise<T[]> {
|
|
1040
|
-
this.#cachedExec ??= this.#
|
|
1046
|
+
this.#cachedExec ??= this.#execWithFetchHooks();
|
|
1041
1047
|
return this.#cachedExec;
|
|
1042
1048
|
}
|
|
1043
1049
|
|
|
1050
|
+
async #execWithFetchHooks(): Promise<T[]> {
|
|
1051
|
+
await fireHooks(this.#entityClass, "beforeFetch", this);
|
|
1052
|
+
const results = await this.#doExec();
|
|
1053
|
+
await fireHooks(this.#entityClass, "afterFetch", results);
|
|
1054
|
+
return results;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1044
1057
|
async #doExec(): Promise<T[]> {
|
|
1045
1058
|
const { sql, params } = this.toSQL();
|
|
1046
1059
|
const rawRows = await this.#db.query<Record<string, unknown>>(sql, params);
|
|
@@ -1099,12 +1112,28 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1099
1112
|
const relatedMeta = getEntityMetadata(relatedClass);
|
|
1100
1113
|
if (!relatedMeta) return null;
|
|
1101
1114
|
|
|
1115
|
+
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1116
|
+
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
1117
|
+
// on a freshly constructed instance, so `key in entity` is always false and
|
|
1118
|
+
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1119
|
+
const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
|
|
1120
|
+
const validColumns = new Set<string>();
|
|
1121
|
+
for (const col of getColumnMetadata(relatedClass)) {
|
|
1122
|
+
validColumns.add(col.propertyKey);
|
|
1123
|
+
validColumns.add(camelToSnake(col.propertyKey));
|
|
1124
|
+
}
|
|
1125
|
+
validColumns.add(relatedPkName);
|
|
1126
|
+
validColumns.add(camelToSnake(relatedPkName));
|
|
1127
|
+
|
|
1102
1128
|
const hydrate = (row: Record<string, unknown>): BaseEntity => {
|
|
1103
1129
|
const entity = new relatedClass();
|
|
1104
1130
|
for (const [key, value] of Object.entries(row)) {
|
|
1105
1131
|
const camelKey = snakeToCamel(key);
|
|
1106
|
-
const targetKey =
|
|
1107
|
-
|
|
1132
|
+
const targetKey = validColumns.has(camelKey)
|
|
1133
|
+
? camelKey
|
|
1134
|
+
: validColumns.has(key)
|
|
1135
|
+
? key
|
|
1136
|
+
: null;
|
|
1108
1137
|
if (targetKey !== null) entity.setProp(targetKey, value);
|
|
1109
1138
|
}
|
|
1110
1139
|
return entity;
|
|
@@ -1351,12 +1380,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1351
1380
|
);
|
|
1352
1381
|
}
|
|
1353
1382
|
const pivot = ctx.relation.pivot;
|
|
1383
|
+
// Default pivot FK = `<model_snake>_id`, derived from the entity CLASS name
|
|
1384
|
+
// (singular by convention), consistent with hasMany/hasOne/belongsTo. Do NOT
|
|
1385
|
+
// singularize the plural TABLE name by stripping a trailing `s` — that breaks
|
|
1386
|
+
// on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
|
|
1354
1387
|
const foreignKey =
|
|
1355
|
-
pivot.foreignKey ??
|
|
1356
|
-
`${camelToSnake(this.#tableName.replace(/s$/, ""))}_id`;
|
|
1388
|
+
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1357
1389
|
const otherKey =
|
|
1358
|
-
pivot.otherKey ??
|
|
1359
|
-
`${camelToSnake(ctx.relatedTable.replace(/s$/, ""))}_id`;
|
|
1390
|
+
pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1360
1391
|
const pk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
1361
1392
|
|
|
1362
1393
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
@@ -1576,12 +1607,12 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1576
1607
|
);
|
|
1577
1608
|
}
|
|
1578
1609
|
const pivot = relation.pivot;
|
|
1610
|
+
// Default pivot FK from the CLASS name (singular), not the plural table
|
|
1611
|
+
// name stripped of a trailing `s` — see the eager loader above.
|
|
1579
1612
|
const foreignKey =
|
|
1580
|
-
pivot.foreignKey ??
|
|
1581
|
-
`${camelToSnake(parentTable.replace(/s$/, ""))}_id`;
|
|
1613
|
+
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1582
1614
|
const otherKey =
|
|
1583
|
-
pivot.otherKey ??
|
|
1584
|
-
`${camelToSnake(relatedTable.replace(/s$/, ""))}_id`;
|
|
1615
|
+
pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1585
1616
|
const relatedPk = getPrimaryKey(relatedClass) ?? "id";
|
|
1586
1617
|
sub.#pushWhereRaw(
|
|
1587
1618
|
`${q(relatedTable)}.${q(relatedPk)} IN ` +
|
|
@@ -1590,6 +1621,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1590
1621
|
);
|
|
1591
1622
|
break;
|
|
1592
1623
|
}
|
|
1624
|
+
default:
|
|
1625
|
+
// hasOneThrough / hasManyThrough build a 2-hop correlated subquery,
|
|
1626
|
+
// which isn't implemented here. Fail loud — falling through would
|
|
1627
|
+
// leave `sub` WITHOUT a join predicate, so whereHas/withCount would
|
|
1628
|
+
// silently match/count EVERY related row.
|
|
1629
|
+
throw new Error(
|
|
1630
|
+
`whereHas/withCount on a '${relation.type}' relation ` +
|
|
1631
|
+
`(${this.#entityClass.name}.${relationName}) is not supported yet. ` +
|
|
1632
|
+
`Use a direct hasMany/belongsTo/manyToMany relation, or filter via a sub-query.`,
|
|
1633
|
+
);
|
|
1593
1634
|
}
|
|
1594
1635
|
return sub;
|
|
1595
1636
|
}
|
|
@@ -1802,6 +1843,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1802
1843
|
async paginate(page: number, perPage: number): Promise<Paginator<T>> {
|
|
1803
1844
|
const p = Math.max(1, Math.floor(page));
|
|
1804
1845
|
const pp = Math.max(1, Math.floor(perPage));
|
|
1846
|
+
// beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
|
|
1847
|
+
// tenant scope) propagates into both the COUNT and the data fetch.
|
|
1848
|
+
await fireHooks(this.#entityClass, "beforePaginate", this);
|
|
1805
1849
|
// Parallel COUNT(*) + data fetch
|
|
1806
1850
|
const countQ = this.clone();
|
|
1807
1851
|
countQ.#select = ["COUNT(*) AS count"];
|
|
@@ -1815,7 +1859,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1815
1859
|
const dataQ = this.clone();
|
|
1816
1860
|
dataQ.#limit = pp;
|
|
1817
1861
|
dataQ.#offset = (p - 1) * pp;
|
|
1818
|
-
|
|
1862
|
+
// `#doExec` (not `exec`) so the generic beforeFetch/afterFetch don't fire on
|
|
1863
|
+
// top of the paginate hooks — paginate is its own terminal.
|
|
1864
|
+
const items = await dataQ.#doExec();
|
|
1865
|
+
await fireHooks(this.#entityClass, "afterPaginate", items);
|
|
1819
1866
|
return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
|
|
1820
1867
|
}
|
|
1821
1868
|
|
|
@@ -1880,7 +1927,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1880
1927
|
direction: "asc" as const,
|
|
1881
1928
|
}));
|
|
1882
1929
|
clone.#limit = lim + 1;
|
|
1883
|
-
|
|
1930
|
+
// `#doExec` (not `exec`) — cursorPaginate is an atlas-specific terminal, not a
|
|
1931
|
+
// Lucid hook point; don't fire the generic beforeFetch/afterFetch on its clone.
|
|
1932
|
+
const rows = await clone.#doExec();
|
|
1884
1933
|
const hasMore = rows.length > lim;
|
|
1885
1934
|
const items = hasMore ? rows.slice(0, lim) : rows;
|
|
1886
1935
|
const last = items[items.length - 1] as Record<string, unknown> | undefined;
|
|
@@ -63,12 +63,8 @@ export async function createNapiConnection(
|
|
|
63
63
|
poolMax = 10,
|
|
64
64
|
pragmas?: Record<string, string | number>,
|
|
65
65
|
): Promise<AsyncDatabaseConnection> {
|
|
66
|
+
// Throws with the underlying cause if the binary can't be loaded.
|
|
66
67
|
const native = await loadNativeDb();
|
|
67
|
-
if (!native) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
"[ATLAS] Rust DB driver (atlas-db-napi) not available. Build with: cargo build --release",
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
68
|
|
|
73
69
|
// Validate sqlite pragmas before crossing the NAPI boundary.
|
|
74
70
|
// PRAGMA syntax doesn't take bound parameters — the Rust side will
|
|
@@ -149,7 +145,7 @@ export async function createNapiConnection(
|
|
|
149
145
|
// shared with AtlasProvider.
|
|
150
146
|
|
|
151
147
|
/** Load the native DB binding from the prebuilt `.node` binary in the package root. */
|
|
152
|
-
async function loadNativeDb(): Promise<NapiModule
|
|
148
|
+
async function loadNativeDb(): Promise<NapiModule> {
|
|
153
149
|
const platform = process.platform;
|
|
154
150
|
const arch = process.arch;
|
|
155
151
|
// Same naming convention as napi-rs / src/query/native.ts: the build emits
|
|
@@ -172,7 +168,14 @@ async function loadNativeDb(): Promise<NapiModule | null> {
|
|
|
172
168
|
// The binary lives at the package root (../../db.<suffix>.node from src/adapters/)
|
|
173
169
|
const binaryPath = join(here, "..", "..", binaryName);
|
|
174
170
|
return require(binaryPath) as NapiModule;
|
|
175
|
-
} catch {
|
|
176
|
-
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// Surface the real cause (missing file, ABI mismatch, dlopen error) — a
|
|
173
|
+
// bare `return null` previously erased it and left the caller throwing a
|
|
174
|
+
// generic "not available" message that was impossible to debug.
|
|
175
|
+
throw new Error(
|
|
176
|
+
`[ATLAS] Failed to load Rust DB driver '${binaryName}' for ${platform}-${arch}. ` +
|
|
177
|
+
"Build with: cargo build --release",
|
|
178
|
+
{ cause: err },
|
|
179
|
+
);
|
|
177
180
|
}
|
|
178
181
|
}
|
package/src/configure.ts
CHANGED
|
@@ -11,24 +11,23 @@ interface Codemods {
|
|
|
11
11
|
export async function configure(codemods: Codemods): Promise<void> {
|
|
12
12
|
await codemods.addProvider("@c9up/atlas/provider");
|
|
13
13
|
await codemods.addEnvVars({
|
|
14
|
-
DB_CONNECTION: "postgres",
|
|
15
14
|
DB_HOST: "localhost",
|
|
16
15
|
DB_PORT: "5432",
|
|
17
16
|
DB_DATABASE: "ream",
|
|
18
17
|
DB_USER: "postgres",
|
|
19
|
-
DB_PASSWORD: "
|
|
18
|
+
DB_PASSWORD: "change-me",
|
|
20
19
|
});
|
|
21
20
|
await codemods.writeFile(
|
|
22
21
|
"config/database.ts",
|
|
23
22
|
`import { defineConfig } from '@c9up/atlas'
|
|
24
23
|
|
|
25
24
|
export default defineConfig({
|
|
26
|
-
|
|
25
|
+
default: 'postgres',
|
|
27
26
|
connections: {
|
|
28
27
|
postgres: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
url:
|
|
29
|
+
process.env.DATABASE_URL ??
|
|
30
|
+
\`postgres://\${process.env.DB_USER ?? 'postgres'}:\${process.env.DB_PASSWORD ?? ''}@\${process.env.DB_HOST ?? 'localhost'}:\${process.env.DB_PORT ?? '5432'}/\${process.env.DB_DATABASE ?? 'ream'}\`,
|
|
32
31
|
},
|
|
33
32
|
},
|
|
34
33
|
})
|
package/src/decorators/entity.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
COLUMN_SERIALIZE_KEY,
|
|
10
10
|
COMPUTED_KEY,
|
|
11
11
|
type ColumnSerializeConfig,
|
|
12
|
-
} from "../
|
|
12
|
+
} from "../metadata-keys.js";
|
|
13
13
|
|
|
14
14
|
const ENTITY_KEY = Symbol("atlas:entity");
|
|
15
15
|
const COLUMNS_KEY = Symbol("atlas:columns");
|
|
@@ -20,7 +20,7 @@ const RELATIONS_KEY = Symbol("atlas:relations");
|
|
|
20
20
|
/** Auto-generation strategy for `@PrimaryKey({ generated: ... })`. */
|
|
21
21
|
export type PrimaryKeyGenerator = "uuid";
|
|
22
22
|
|
|
23
|
-
export interface PrimaryKeyOptions {
|
|
23
|
+
export interface PrimaryKeyOptions extends ColumnOptions {
|
|
24
24
|
/**
|
|
25
25
|
* Auto-generate the primary-key value on INSERT when the entity has none
|
|
26
26
|
* (undefined). `'uuid'` produces an RFC-4122 v4 string via `crypto.randomUUID()`.
|
|
@@ -355,8 +355,16 @@ export function PrimaryKey(options?: PrimaryKeyOptions): PropertyDecorator {
|
|
|
355
355
|
target.constructor,
|
|
356
356
|
);
|
|
357
357
|
}
|
|
358
|
-
//
|
|
359
|
-
|
|
358
|
+
// Register as a column, PROPAGATING the options so a typed PK records its
|
|
359
|
+
// SQL type — needed for the `::uuid` cast in WHERE/INSERT (otherwise a
|
|
360
|
+
// uuid PK compared to a bound string fails `operator does not exist:
|
|
361
|
+
// uuid = text`). A uuid generator implies a uuid column type unless the
|
|
362
|
+
// caller set one explicitly.
|
|
363
|
+
const columnOptions: ColumnOptions = { ...options };
|
|
364
|
+
if (columnOptions.type === undefined && options?.generated === "uuid") {
|
|
365
|
+
columnOptions.type = "uuid";
|
|
366
|
+
}
|
|
367
|
+
Column(columnOptions)(target, propertyKey);
|
|
360
368
|
};
|
|
361
369
|
}
|
|
362
370
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared entity metadata keys + serialize config.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `BaseEntity` so the decorator layer (`decorators/entity.ts`)
|
|
5
|
+
* can read them without importing `BaseEntity` — that import-back formed a
|
|
6
|
+
* runtime cycle `BaseEntity` ↔ `entity` (fallow 2026-06-14). Agnostic: pure
|
|
7
|
+
* `Symbol.for` keys + a plain interface, zero runtime dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Symbol metadata key for the computed-property registry on an entity class. */
|
|
11
|
+
export const COMPUTED_KEY = Symbol.for("atlas:computed");
|
|
12
|
+
|
|
13
|
+
/** Symbol metadata key for the serialize-as / serializer overrides on columns. */
|
|
14
|
+
export const COLUMN_SERIALIZE_KEY = Symbol.for("atlas:columnSerialize");
|
|
15
|
+
|
|
16
|
+
/** Per-column serialization config (populated by @Column options). */
|
|
17
|
+
export interface ColumnSerializeConfig {
|
|
18
|
+
/** Rename this column at toJSON time (e.g. `password` → `passwordHash`). Null = hidden. */
|
|
19
|
+
serializeAs?: string | null;
|
|
20
|
+
/** Transform function applied to the value at toJSON time. */
|
|
21
|
+
serialize?: (value: unknown) => unknown;
|
|
22
|
+
}
|