@c9up/atlas 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ModelQuery.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  wrapAdapterError,
20
20
  } from "./BaseRepository.js";
21
21
  import {
22
+ defaultRelationForeignKey,
22
23
  ensureEntityMetadata,
23
24
  getColumnMetadata,
24
25
  getDateColumnConfig,
@@ -750,6 +751,22 @@ function structuredCloneSafe<T>(value: T): T {
750
751
  return structuredClone(value);
751
752
  }
752
753
 
754
+ /**
755
+ * What {@link ModelQuery.pojo} hands back: awaitable for the rows, and
756
+ * chainable into `first()`.
757
+ *
758
+ * NAMED DEVIATION — Lucid's `pojo()` is a flag on the builder
759
+ * (`pojo(): this`), so the whole builder surface stays available after it.
760
+ * `ModelQuery<T>` is constrained to `T extends BaseEntity` and cannot be
761
+ * re-parametrised to a plain record, so ours is a terminal view instead. It
762
+ * covers what Lucid's own code does with it — `query.pojo().first()` — and
763
+ * `await query.pojo()` is unchanged.
764
+ */
765
+ export interface PojoQuery<R> extends PromiseLike<R[]> {
766
+ /** The first row, or `null`. */
767
+ first(): Promise<R | null>;
768
+ }
769
+
753
770
  export class ModelQuery<T extends BaseEntity> {
754
771
  #tableName: string;
755
772
  #db: DatabaseConnection;
@@ -3012,6 +3029,24 @@ export class ModelQuery<T extends BaseEntity> {
3012
3029
  return this.exec().then(onfulfilled, onrejected);
3013
3030
  }
3014
3031
 
3032
+ /**
3033
+ * The rest of the promise protocol, so a builder is not a half-promise.
3034
+ *
3035
+ * `await query` worked while `query.catch(fn)` and `query.finally(fn)` threw
3036
+ * "not a function" — a value that answers to `then` and nothing else
3037
+ * surprises anyone who treats it as the promise it looks like. Lucid's
3038
+ * builder carries all three.
3039
+ */
3040
+ catch<TResult = never>(
3041
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
3042
+ ): Promise<T[] | TResult> {
3043
+ return this.exec().catch(onrejected);
3044
+ }
3045
+
3046
+ finally(onfinally?: (() => void) | null): Promise<T[]> {
3047
+ return this.exec().finally(onfinally);
3048
+ }
3049
+
3015
3050
  /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
3016
3051
  /**
3017
3052
  * DB column backing the soft-delete `deletedAt` property — honours a
@@ -3339,9 +3374,18 @@ export class ModelQuery<T extends BaseEntity> {
3339
3374
  * AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
3340
3375
  * instances aren't needed.
3341
3376
  */
3342
- async pojo<R = Record<string, unknown>>(): Promise<R[]> {
3343
- const { sql, params } = this.#compiledNative();
3344
- return this.#db.query<R>(sql, params);
3377
+ pojo<R = Record<string, unknown>>(): PojoQuery<R> {
3378
+ const run = async (limitOne: boolean): Promise<R[]> => {
3379
+ if (limitOne) this.#limit = 1;
3380
+ const { sql, params } = this.#compiledNative();
3381
+ return this.#db.query<R>(sql, params);
3382
+ };
3383
+ return {
3384
+ // biome-ignore lint/suspicious/noThenProperty: deliberately thenable, which is what PromiseLike means — the rule exists to catch ACCIDENTAL thenables. `await query.pojo()` is the existing API and must keep working; same justification as ModelQuery.then above.
3385
+ then: (onfulfilled, onrejected) =>
3386
+ run(false).then(onfulfilled, onrejected),
3387
+ first: async () => (await run(true))[0] ?? null,
3388
+ };
3345
3389
  }
3346
3390
 
3347
3391
  /**
@@ -3564,9 +3608,10 @@ export class ModelQuery<T extends BaseEntity> {
3564
3608
  const parentLocal =
3565
3609
  relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
3566
3610
  const firstKey =
3567
- relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
3611
+ relation.firstKey ??
3612
+ defaultRelationForeignKey("hasMany", this.#entityClass);
3568
3613
  const secondKey =
3569
- relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
3614
+ relation.secondKey ?? defaultRelationForeignKey("hasMany", throughClass);
3570
3615
  // secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
3571
3616
  // DB column — resolve the through model's key (default: its PK), honouring a
3572
3617
  // multi-word / columnName PK. (parentLocal stays a property: it's read off
@@ -3628,7 +3673,8 @@ export class ModelQuery<T extends BaseEntity> {
3628
3673
  ctx: PreloadContext,
3629
3674
  ): Promise<BaseEntity[]> {
3630
3675
  const fk =
3631
- ctx.relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
3676
+ ctx.relation.foreignKey ??
3677
+ defaultRelationForeignKey("hasMany", this.#entityClass);
3632
3678
  const pk =
3633
3679
  ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
3634
3680
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
@@ -3670,7 +3716,8 @@ export class ModelQuery<T extends BaseEntity> {
3670
3716
  ctx: PreloadContext,
3671
3717
  ): Promise<BaseEntity[]> {
3672
3718
  const fk =
3673
- ctx.relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
3719
+ ctx.relation.foreignKey ??
3720
+ defaultRelationForeignKey("hasMany", this.#entityClass);
3674
3721
  const pk =
3675
3722
  ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
3676
3723
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
@@ -3699,7 +3746,8 @@ export class ModelQuery<T extends BaseEntity> {
3699
3746
  ctx: PreloadContext,
3700
3747
  ): Promise<BaseEntity[]> {
3701
3748
  const fk =
3702
- ctx.relation.foreignKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
3749
+ ctx.relation.foreignKey ??
3750
+ defaultRelationForeignKey("belongsTo", ctx.relatedClass);
3703
3751
  const fkProp = `${relationName}Id`;
3704
3752
  const ids = entities
3705
3753
  .map((e) => e[fkProp] ?? e[fk])
@@ -3739,9 +3787,11 @@ export class ModelQuery<T extends BaseEntity> {
3739
3787
  // singularize the plural TABLE name by stripping a trailing `s` — that breaks
3740
3788
  // on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
3741
3789
  const foreignKey =
3742
- pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
3790
+ pivot.foreignKey ??
3791
+ defaultRelationForeignKey("manyToMany", this.#entityClass);
3743
3792
  const otherKey =
3744
- pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
3793
+ pivot.otherKey ??
3794
+ defaultRelationForeignKey("manyToMany", ctx.relatedClass);
3745
3795
  // The pivot FK stores `parent[localKey]` (default PK) — attach() writes it,
3746
3796
  // so preload MUST read back with the SAME key, else a custom-localKey m2m
3747
3797
  // writes `user_code = code` but reads `user_code IN (id)` and never matches.
@@ -4138,7 +4188,8 @@ export class ModelQuery<T extends BaseEntity> {
4138
4188
  // Honour custom foreignKey/localKey exactly like the eager loader —
4139
4189
  // hard-coding them here produced silently-wrong whereHas/withCount SQL.
4140
4190
  const fk =
4141
- relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
4191
+ relation.foreignKey ??
4192
+ defaultRelationForeignKey("hasMany", this.#entityClass);
4142
4193
  const localKey = resolveParent(relation.localKey ?? parentPk);
4143
4194
  sub.#pushWhereRaw(
4144
4195
  `${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
@@ -4147,7 +4198,8 @@ export class ModelQuery<T extends BaseEntity> {
4147
4198
  }
4148
4199
  case "belongsTo": {
4149
4200
  const fk =
4150
- relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
4201
+ relation.foreignKey ??
4202
+ defaultRelationForeignKey("belongsTo", relatedClass);
4151
4203
  const ownerKey = buildColumnResolver(relatedClass)(
4152
4204
  relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
4153
4205
  );
@@ -4166,9 +4218,11 @@ export class ModelQuery<T extends BaseEntity> {
4166
4218
  // Default pivot FK from the CLASS name (singular), not the plural table
4167
4219
  // name stripped of a trailing `s` — see the eager loader above.
4168
4220
  const foreignKey =
4169
- pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
4221
+ pivot.foreignKey ??
4222
+ defaultRelationForeignKey("manyToMany", this.#entityClass);
4170
4223
  const otherKey =
4171
- pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
4224
+ pivot.otherKey ??
4225
+ defaultRelationForeignKey("manyToMany", relatedClass);
4172
4226
  const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
4173
4227
  const relatedPk =
4174
4228
  getColumnMetadata(relatedClass).find(
@@ -4198,9 +4252,11 @@ export class ModelQuery<T extends BaseEntity> {
4198
4252
  const throughPk = getPrimaryKey(throughClass) ?? "id";
4199
4253
  const parentLocal = resolveParent(relation.localKey ?? parentPk);
4200
4254
  const firstKey =
4201
- relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
4255
+ relation.firstKey ??
4256
+ defaultRelationForeignKey("hasMany", this.#entityClass);
4202
4257
  const secondKey =
4203
- relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
4258
+ relation.secondKey ??
4259
+ defaultRelationForeignKey("hasMany", throughClass);
4204
4260
  const secondLocal = buildColumnResolver(throughClass)(
4205
4261
  relation.secondLocalKey ?? throughPk,
4206
4262
  );
@@ -4462,8 +4518,14 @@ export class ModelQuery<T extends BaseEntity> {
4462
4518
  return !(await this.exists());
4463
4519
  }
4464
4520
 
4465
- /** Flat column projection. Rejects object/relation columns. */
4466
- async pluck(column: string): Promise<unknown[]> {
4521
+ /**
4522
+ * Flat column projection (Knex `pluck`, which Lucid inherits). Rejects
4523
+ * object/relation columns.
4524
+ *
4525
+ * Generic like Knex's, so a caller who knows the column type does not have
4526
+ * to cast the whole array at the call site.
4527
+ */
4528
+ async pluck<V = unknown>(column: string): Promise<V[]> {
4467
4529
  const col = this.#resolveColumn(column);
4468
4530
  const clone = this.clone();
4469
4531
  clone.#select = [col];
@@ -4476,7 +4538,7 @@ export class ModelQuery<T extends BaseEntity> {
4476
4538
  `pluck('${column}') rejected — column is an object/relation`,
4477
4539
  );
4478
4540
  }
4479
- return v;
4541
+ return v as V;
4480
4542
  });
4481
4543
  }
4482
4544
 
@@ -4548,7 +4610,7 @@ export class ModelQuery<T extends BaseEntity> {
4548
4610
  // === Story 29.10 — pagination =====================================================================
4549
4611
 
4550
4612
  /** Offset-based paginator. */
4551
- async paginate(page: number, perPage: number): Promise<Paginator<T>> {
4613
+ async paginate(page: number, perPage = 20): Promise<Paginator<T>> {
4552
4614
  const p = Math.max(1, Math.floor(page));
4553
4615
  const pp = Math.max(1, Math.floor(perPage));
4554
4616
  // Adonis Lucid hook order:
@@ -609,6 +609,27 @@ export function getPrimaryKey(target: Constructor): string | undefined {
609
609
  return Reflect.getMetadata(PRIMARY_KEY, target);
610
610
  }
611
611
 
612
+ /**
613
+ * The foreign key a relation falls back to when none is declared.
614
+ *
615
+ * Goes through the entity's naming strategy rather than hardcoding `_id`, so a
616
+ * model whose primary key is not `id` gets `user_uuid` instead of a `user_id`
617
+ * column that does not exist. Lucid derives it the same way
618
+ * (`relationForeignKey` / `relationPivotForeignKey`), and for the usual `id`
619
+ * the result is unchanged.
620
+ */
621
+ export function defaultRelationForeignKey(
622
+ kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
623
+ entityClass: Constructor,
624
+ ): string {
625
+ const pk = getPrimaryKey(entityClass) ?? "id";
626
+ return getNamingStrategy(entityClass).relationForeignKey(
627
+ kind,
628
+ entityClass.name,
629
+ pk,
630
+ );
631
+ }
632
+
612
633
  /** Get relation metadata for a class (returns a copy). */
613
634
  export function getRelationMetadata(target: Constructor): RelationMetadata[] {
614
635
  return [...(Reflect.getMetadata(RELATIONS_KEY, target) ?? [])];
@@ -30,7 +30,15 @@ export interface NamingStrategy {
30
30
  kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
31
31
  parentPk: string,
32
32
  ): string;
33
- /** Foreign key column name on the owning side of a relation. */
33
+ /**
34
+ * Foreign key COLUMN name on the owning side of a relation.
35
+ *
36
+ * NAMED DEVIATION — Lucid's method of the same name returns the model
37
+ * ATTRIBUTE (camelCase), which it then runs through `columnName()`; the one
38
+ * that returns a column upstream is `relationPivotForeignKey`. Atlas
39
+ * resolves relations by column throughout, so one method answers for both
40
+ * and it answers in snake_case. An override must return a column name.
41
+ */
34
42
  relationForeignKey(
35
43
  kind: "belongsTo" | "hasMany" | "hasOne" | "manyToMany",
36
44
  parentClass: string,
@@ -82,7 +90,12 @@ export class CamelCaseNamingStrategy implements NamingStrategy {
82
90
  parentClass: string,
83
91
  parentPk: string,
84
92
  ): string {
85
- return `${camelToSnake(parentClass)}_${parentPk}`;
93
+ // The PK is snake_cased too, as Lucid does
94
+ // (`snakeCase(`${model.name}_${model.primaryKey}`)`). Identical to the
95
+ // old output for the usual `id`; a multi-word PK now yields a column
96
+ // name a migration would actually have created (`user_user_id`, not
97
+ // `user_userId`).
98
+ return `${camelToSnake(parentClass)}_${camelToSnake(parentPk)}`;
86
99
  }
87
100
 
88
101
  relationPivotTable(aClass: string, bClass: string): string {