@c9up/atlas 0.1.18 → 0.1.19

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
@@ -180,6 +180,30 @@ interface HavingClause {
180
180
  type: "and" | "or";
181
181
  }
182
182
 
183
+ /** A raw SQL HAVING fragment with `?` bindings — kind-tagged for the Rust compiler. */
184
+ interface HavingRawClause {
185
+ kind: "raw";
186
+ sql: string;
187
+ bindings: unknown[];
188
+ type: "and" | "or";
189
+ }
190
+
191
+ type HavingEntry = HavingClause | HavingRawClause;
192
+
193
+ /** A compiled CTE (`WITH name AS (...)`) — the sub-select is pre-compiled to SQL + params. */
194
+ interface CteSpec {
195
+ name: string;
196
+ sql: string;
197
+ params: unknown[];
198
+ }
199
+
200
+ /** A compiled UNION / UNION ALL branch — pre-compiled to SQL + params. */
201
+ interface UnionSpec {
202
+ sql: string;
203
+ params: unknown[];
204
+ all: boolean;
205
+ }
206
+
183
207
  interface SubqueryProjection {
184
208
  alias: string;
185
209
  subquery: SelectSpec;
@@ -193,12 +217,12 @@ interface SelectSpec {
193
217
  wheres: WhereClause[];
194
218
  orderBy: Array<{ column: string; direction: "asc" | "desc" }>;
195
219
  groupBy: string[];
196
- having: HavingClause[];
220
+ having: HavingEntry[];
197
221
  limit: number | null;
198
222
  offset: number | null;
199
223
  distinct: boolean;
200
- ctes: unknown[];
201
- unions: unknown[];
224
+ ctes: CteSpec[];
225
+ unions: UnionSpec[];
202
226
  joins: string[];
203
227
  lockMode: "FOR UPDATE" | "FOR SHARE" | null;
204
228
  }
@@ -296,6 +320,16 @@ export class Paginator<T> {
296
320
  return this.items;
297
321
  }
298
322
 
323
+ /** True when there is more than one page of results (AdonisJS `hasPages`). */
324
+ get hasPages(): boolean {
325
+ return this.meta.lastPage > 1;
326
+ }
327
+
328
+ /** True when there is at least one more page after the current one (AdonisJS `hasMorePages`). */
329
+ get hasMorePages(): boolean {
330
+ return this.meta.currentPage < this.meta.lastPage;
331
+ }
332
+
299
333
  serialize(opts?: { fields?: string[] }): {
300
334
  data: unknown[];
301
335
  meta: Paginator<T>["meta"];
@@ -376,6 +410,17 @@ export class ModelQuery<T extends BaseEntity> {
376
410
  #debugFlag = false;
377
411
  /** Distinct flag — Story 29.5. */
378
412
  #distinct = false;
413
+ /** GROUP BY columns (Lucid parity). */
414
+ #groupBy: string[] = [];
415
+ /** HAVING clauses — structured + raw (Lucid parity). */
416
+ #having: HavingEntry[] = [];
417
+ /** CTEs registered via `.with()` (Lucid parity). */
418
+ #ctes: Array<{ name: string; query: ModelQuery<BaseEntity> }> = [];
419
+ /** UNION / UNION ALL branches (Lucid parity). */
420
+ #unions: Array<{ query: ModelQuery<BaseEntity>; all: boolean }> = [];
421
+ /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
422
+ #pivotWheres: Array<{ column: string; operator: string; value: unknown }> =
423
+ [];
379
424
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
380
425
  #dialect: AtlasDialect;
381
426
 
@@ -587,6 +632,135 @@ export class ModelQuery<T extends BaseEntity> {
587
632
  return this;
588
633
  }
589
634
 
635
+ // ─── OR-combined variants (AdonisJS orWhere* family) ─────────
636
+ // Same predicates as the whereX methods above, combined with OR instead of
637
+ // AND — the named ergonomics Lucid exposes (vs emulating with `orWhere(cb)`).
638
+
639
+ /** `OR col IS NULL`. */
640
+ orWhereNull(column: string): this {
641
+ this.#wheres.push({
642
+ type: "or",
643
+ column: this.#resolveColumn(column),
644
+ operator: "IS NULL",
645
+ value: null,
646
+ });
647
+ return this;
648
+ }
649
+
650
+ /** `OR col IS NOT NULL`. */
651
+ orWhereNotNull(column: string): this {
652
+ this.#wheres.push({
653
+ type: "or",
654
+ column: this.#resolveColumn(column),
655
+ operator: "IS NOT NULL",
656
+ value: null,
657
+ });
658
+ return this;
659
+ }
660
+
661
+ /** `OR col != ?`. */
662
+ orWhereNot(column: string, value: unknown): this {
663
+ this.#wheres.push({
664
+ type: "or",
665
+ column: this.#resolveColumn(column),
666
+ operator: "!=",
667
+ value,
668
+ });
669
+ return this;
670
+ }
671
+
672
+ /** `OR col IN (...)` — array or `ModelQuery` subquery source. */
673
+ orWhereIn(
674
+ column: string,
675
+ source: readonly unknown[] | ModelQuery<BaseEntity>,
676
+ ): this {
677
+ if (source instanceof ModelQuery) {
678
+ this.#wheres.push({
679
+ type: "or",
680
+ kind: "inSub",
681
+ negated: false,
682
+ column: this.#resolveColumn(column),
683
+ subquery: source.#buildSpec(),
684
+ });
685
+ return this;
686
+ }
687
+ this.#wheres.push({
688
+ type: "or",
689
+ column: this.#resolveColumn(column),
690
+ operator: "IN",
691
+ value: [...source],
692
+ });
693
+ return this;
694
+ }
695
+
696
+ /** `OR col NOT IN (...)` — array or `ModelQuery` subquery source. */
697
+ orWhereNotIn(
698
+ column: string,
699
+ source: readonly unknown[] | ModelQuery<BaseEntity>,
700
+ ): this {
701
+ if (source instanceof ModelQuery) {
702
+ this.#wheres.push({
703
+ type: "or",
704
+ kind: "inSub",
705
+ negated: true,
706
+ column: this.#resolveColumn(column),
707
+ subquery: source.#buildSpec(),
708
+ });
709
+ return this;
710
+ }
711
+ this.#wheres.push({
712
+ type: "or",
713
+ column: this.#resolveColumn(column),
714
+ operator: "NOT IN",
715
+ value: [...source],
716
+ });
717
+ return this;
718
+ }
719
+
720
+ /** `OR col BETWEEN ? AND ?`. */
721
+ orWhereBetween(column: string, range: readonly [unknown, unknown]): this {
722
+ this.#wheres.push({
723
+ type: "or",
724
+ column: this.#resolveColumn(column),
725
+ operator: "BETWEEN",
726
+ value: [...range],
727
+ });
728
+ return this;
729
+ }
730
+
731
+ /** `OR col NOT BETWEEN ? AND ?`. */
732
+ orWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
733
+ this.#wheres.push({
734
+ type: "or",
735
+ column: this.#resolveColumn(column),
736
+ operator: "NOT BETWEEN",
737
+ value: [...range],
738
+ });
739
+ return this;
740
+ }
741
+
742
+ /** `OR col LIKE ?`. */
743
+ orWhereLike(column: string, pattern: string): this {
744
+ this.#wheres.push({
745
+ type: "or",
746
+ column: this.#resolveColumn(column),
747
+ operator: "LIKE",
748
+ value: pattern,
749
+ });
750
+ return this;
751
+ }
752
+
753
+ /** `OR col ILIKE ?` (rewritten to LOWER() LIKE LOWER() on sqlite/mysql). */
754
+ orWhereILike(column: string, pattern: string): this {
755
+ this.#wheres.push({
756
+ type: "or",
757
+ column: this.#resolveColumn(column),
758
+ operator: "ILIKE",
759
+ value: pattern,
760
+ });
761
+ return this;
762
+ }
763
+
590
764
  /**
591
765
  * **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
592
766
  * `?`-style bindings. The Rust compiler re-indexes the placeholders so they
@@ -713,6 +887,58 @@ export class ModelQuery<T extends BaseEntity> {
713
887
  return this;
714
888
  }
715
889
 
890
+ /**
891
+ * Compare two COLUMNS (AdonisJS/Knex `whereColumn`) — `WHERE "a" op "b"`.
892
+ * Both sides go through the identifier quoter (injection-safe) and the
893
+ * operator is allow-listed; nothing is bound (it's a column reference, not a
894
+ * value), which the standard `where`/`whereExpr` value-binding path can't do.
895
+ */
896
+ whereColumn(left: string, operator: string, right: string): this {
897
+ return this.#whereColumn("and", left, operator, right);
898
+ }
899
+
900
+ /** `OR`-combined {@link whereColumn}. */
901
+ orWhereColumn(left: string, operator: string, right: string): this {
902
+ return this.#whereColumn("or", left, operator, right);
903
+ }
904
+
905
+ #whereColumn(
906
+ type: "and" | "or",
907
+ left: string,
908
+ operator: string,
909
+ right: string,
910
+ ): this {
911
+ if (!WHEREEXPR_OPERATORS.has(operator)) {
912
+ throw new Error(
913
+ `whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`,
914
+ );
915
+ }
916
+ // Both operands are interpolated as raw identifiers (no value binding for a
917
+ // column reference), and #quote is a plain wrapper that does NOT escape an
918
+ // embedded quote — so validate each RESOLVED identifier against a strict
919
+ // `[table.]column` charset. This closes the injection surface regardless of
920
+ // what #resolveColumn returns (it can be an identity resolver on sub-queries).
921
+ const safe = (name: string): string => {
922
+ const resolved = this.#resolveColumn(name);
923
+ if (
924
+ !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
925
+ ) {
926
+ throw new Error(
927
+ `whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`,
928
+ );
929
+ }
930
+ // Quote each dotted segment separately → `"table"."column"`, never a
931
+ // single mis-quoted `"table.column"`.
932
+ return resolved
933
+ .split(".")
934
+ .map((part) => this.#quote(part))
935
+ .join(".");
936
+ };
937
+ const sql = `${safe(left)} ${operator} ${safe(right)}`;
938
+ this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
939
+ return this;
940
+ }
941
+
716
942
  /**
717
943
  * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
718
944
  * by the existence of related rows, optionally constrained by a callback.
@@ -919,6 +1145,120 @@ export class ModelQuery<T extends BaseEntity> {
919
1145
  return this;
920
1146
  }
921
1147
 
1148
+ /**
1149
+ * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
1150
+ * through the entity's column map (camelCase → snake_case) like `orderBy`.
1151
+ * For a raw grouping expression, use a `whereRaw`-style construct via the
1152
+ * fluent {@link QueryBuilder}.
1153
+ */
1154
+ groupBy(...columns: string[]): this {
1155
+ for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
1156
+ return this;
1157
+ }
1158
+
1159
+ /**
1160
+ * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
1161
+ * The column is passed verbatim to the Rust HAVING compiler, which quotes a
1162
+ * plain identifier or accepts an allow-listed aggregate expression
1163
+ * (`COUNT(*)`, `SUM(col)`, …) — it is NOT run through the entity column map,
1164
+ * so aggregate expressions and result aliases both work.
1165
+ */
1166
+ having(column: string, operator: string, value: unknown): this {
1167
+ this.#having.push({ column, operator, value, type: "and" });
1168
+ return this;
1169
+ }
1170
+
1171
+ /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
1172
+ orHaving(column: string, operator: string, value: unknown): this {
1173
+ this.#having.push({ column, operator, value, type: "or" });
1174
+ return this;
1175
+ }
1176
+
1177
+ /**
1178
+ * **⚠ UNSAFE** — append a raw SQL `HAVING` fragment with `?` bindings
1179
+ * (AdonisJS/Lucid `havingRaw`). The Rust compiler re-indexes the placeholders;
1180
+ * everything else in `sql` is trusted verbatim. All values must go through
1181
+ * `bindings`.
1182
+ *
1183
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1184
+ */
1185
+ havingRaw(sql: string, bindings: readonly unknown[] = []): this {
1186
+ this.#having.push({
1187
+ kind: "raw",
1188
+ sql,
1189
+ bindings: [...bindings],
1190
+ type: "and",
1191
+ });
1192
+ return this;
1193
+ }
1194
+
1195
+ /**
1196
+ * `UNION (<query>)` (AdonisJS/Lucid `union`). The other query is compiled and
1197
+ * appended as a parenthesised UNION branch; its bindings are re-indexed into
1198
+ * the outer parameter list.
1199
+ */
1200
+ union(query: ModelQuery<BaseEntity>): this {
1201
+ this.#unions.push({ query, all: false });
1202
+ return this;
1203
+ }
1204
+
1205
+ /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
1206
+ unionAll(query: ModelQuery<BaseEntity>): this {
1207
+ this.#unions.push({ query, all: true });
1208
+ return this;
1209
+ }
1210
+
1211
+ /**
1212
+ * `WITH <name> AS (<query>)` — register a Common Table Expression
1213
+ * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
1214
+ * sub-query is compiled and its bindings are re-indexed into the outer list.
1215
+ */
1216
+ with(name: string, query: ModelQuery<BaseEntity>): this {
1217
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1218
+ throw new Error(`with(): CTE name '${name}' is not a valid identifier`);
1219
+ }
1220
+ this.#ctes.push({ name, query });
1221
+ return this;
1222
+ }
1223
+
1224
+ /**
1225
+ * `@ManyToMany` only — filter loaded relations by a PIVOT-table column
1226
+ * (AdonisJS/Lucid `wherePivot`). Recorded separately from the related-table
1227
+ * WHEREs and applied to the pivot lookup query by the m2m preload resolver;
1228
+ * inert on non-m2m relations.
1229
+ *
1230
+ * userRepo.query().preload('roles', q => q.wherePivot('active', true))
1231
+ */
1232
+ wherePivot(column: string, value: unknown): this;
1233
+ wherePivot(column: string, operator: string, value: unknown): this;
1234
+ wherePivot(column: string, operatorOrValue: unknown, value?: unknown): this {
1235
+ if (value === undefined) {
1236
+ this.#pivotWheres.push({ column, operator: "=", value: operatorOrValue });
1237
+ } else {
1238
+ this.#pivotWheres.push({
1239
+ column,
1240
+ operator: operatorOrValue as string,
1241
+ value,
1242
+ });
1243
+ }
1244
+ return this;
1245
+ }
1246
+
1247
+ /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `wherePivotIn`). */
1248
+ wherePivotIn(column: string, values: readonly unknown[]): this {
1249
+ this.#pivotWheres.push({ column, operator: "IN", value: [...values] });
1250
+ return this;
1251
+ }
1252
+
1253
+ /** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
1254
+ get pivotConstraints(): ReadonlyArray<{
1255
+ column: string;
1256
+ operator: string;
1257
+ value: unknown;
1258
+ }> {
1259
+ return this.#pivotWheres;
1260
+ }
1261
+
922
1262
  limit(n: number): this {
923
1263
  // Guard here with a clear message — the Rust spec types limit as
924
1264
  // u64, so a negative/non-integer otherwise surfaces as a cryptic
@@ -957,6 +1297,24 @@ export class ModelQuery<T extends BaseEntity> {
957
1297
  return result;
958
1298
  }
959
1299
 
1300
+ /**
1301
+ * Return the single matching row, or throw if there are zero OR more than one
1302
+ * (AdonisJS/Laravel `sole`). Use when exactly one row is a correctness
1303
+ * invariant — a second match signals a bug the silent `first()` would hide.
1304
+ */
1305
+ async sole(): Promise<T> {
1306
+ const rows = await this.limit(2).exec();
1307
+ if (rows.length === 0) {
1308
+ throw new Error(`No ${this.#tableName} found matching query (sole()).`);
1309
+ }
1310
+ if (rows.length > 1) {
1311
+ throw new Error(
1312
+ `Expected exactly one ${this.#tableName} but the query matched multiple rows (sole()).`,
1313
+ );
1314
+ }
1315
+ return rows[0];
1316
+ }
1317
+
960
1318
  /**
961
1319
  * Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
962
1320
  * A chain like `await repo.query().where('active', true).orderBy('id')`
@@ -1012,13 +1370,19 @@ export class ModelQuery<T extends BaseEntity> {
1012
1370
  selectSubqueries: this.#selectSubqueries,
1013
1371
  wheres,
1014
1372
  orderBy: this.#orderBys,
1015
- groupBy: [],
1016
- having: [],
1373
+ groupBy: this.#groupBy,
1374
+ having: this.#having,
1017
1375
  limit: this.#limit ?? null,
1018
1376
  offset: this.#offset ?? null,
1019
1377
  distinct: this.#distinct,
1020
- ctes: [],
1021
- unions: [],
1378
+ ctes: this.#ctes.map((c) => {
1379
+ const { sql, params } = c.query.toSQL();
1380
+ return { name: c.name, sql, params };
1381
+ }),
1382
+ unions: this.#unions.map((u) => {
1383
+ const { sql, params } = u.query.toSQL();
1384
+ return { sql, params, all: u.all };
1385
+ }),
1022
1386
  joins: this.#joins,
1023
1387
  lockMode: this.#lockMode,
1024
1388
  };
@@ -1393,8 +1757,36 @@ export class ModelQuery<T extends BaseEntity> {
1393
1757
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1394
1758
  if (ids.length === 0) return [];
1395
1759
 
1396
- // Step 1 — pivot table: find (foreignKey otherKey) pairs
1397
- const pivotRows = await ctx.runInQuery(pivot.pivotTable, foreignKey, ids);
1760
+ // Extract PIVOT-table constraints (wherePivot / wherePivotIn) from the
1761
+ // preload callback by replaying it on a throwaway builder. The callback
1762
+ // also runs (again) inside runRelationQuery against the related table; both
1763
+ // runs are pure builder mutations, and pivot constraints are inert there.
1764
+ const pivotWheres: Array<{
1765
+ column: string;
1766
+ operator: string;
1767
+ value: unknown;
1768
+ }> = [];
1769
+ if (ctx.nestedCallback) {
1770
+ const scratch = new ModelQuery<BaseEntity>(
1771
+ ctx.relatedTable,
1772
+ this.#db,
1773
+ (r) => r as BaseEntity,
1774
+ ctx.relatedClass,
1775
+ (c) => c,
1776
+ false,
1777
+ this.#dialect,
1778
+ );
1779
+ ctx.nestedCallback(scratch);
1780
+ for (const c of scratch.pivotConstraints) pivotWheres.push({ ...c });
1781
+ }
1782
+
1783
+ // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
1784
+ const pivotRows = await this.#runInQuery(
1785
+ pivot.pivotTable,
1786
+ foreignKey,
1787
+ ids,
1788
+ pivotWheres,
1789
+ );
1398
1790
  if (pivotRows.length === 0) {
1399
1791
  for (const entity of entities) entity.setProp(relationName, []);
1400
1792
  return [];
@@ -1405,18 +1797,46 @@ export class ModelQuery<T extends BaseEntity> {
1405
1797
 
1406
1798
  // Step 2 — load all related entities in one query
1407
1799
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
1800
+ const pivotCols = pivot.pivotColumns ?? [];
1801
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
1802
+ // When pivot extras are projected, each (parent, related) edge gets its OWN
1803
+ // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
1804
+ // across parents (Lucid gives distinct pivot-bearing instances). Otherwise a
1805
+ // single shared instance per related PK is reused (cheaper, current behaviour).
1806
+ const projectPivot = pivotCols.length > 0;
1807
+ const rawByRelatedPk = new Map<unknown, Record<string, unknown>>();
1408
1808
  const byRelatedPk = new Map<unknown, BaseEntity>();
1409
1809
  const allRelated: BaseEntity[] = [];
1410
1810
  for (const row of relRows) {
1411
- const hydrated = ctx.hydrate(row);
1412
- byRelatedPk.set(row[ctx.relatedPk], hydrated);
1413
- allRelated.push(hydrated);
1811
+ rawByRelatedPk.set(row[ctx.relatedPk], row);
1812
+ if (!projectPivot) {
1813
+ const hydrated = ctx.hydrate(row);
1814
+ byRelatedPk.set(row[ctx.relatedPk], hydrated);
1815
+ allRelated.push(hydrated);
1816
+ }
1414
1817
  }
1415
1818
 
1416
- // Step 3 — group via the pivot
1819
+ // Step 3 — group via the pivot, projecting declared pivotColumns into
1820
+ // `$extras.pivot_<col>` (running each column's `consume` adapter if any).
1417
1821
  const grouped = new Map<unknown, BaseEntity[]>();
1418
1822
  for (const pivotRow of pivotRows) {
1419
- const related = byRelatedPk.get(pivotRow[otherKey]);
1823
+ let related: BaseEntity | undefined;
1824
+ if (projectPivot) {
1825
+ const raw = rawByRelatedPk.get(pivotRow[otherKey]);
1826
+ if (!raw) continue;
1827
+ related = ctx.hydrate(raw);
1828
+ for (const col of pivotCols) {
1829
+ const rawVal = pivotRow[col];
1830
+ const adapter = pivotAdapters[col];
1831
+ related.setExtra(
1832
+ `pivot_${col}`,
1833
+ adapter?.consume ? adapter.consume(rawVal) : rawVal,
1834
+ );
1835
+ }
1836
+ allRelated.push(related);
1837
+ } else {
1838
+ related = byRelatedPk.get(pivotRow[otherKey]);
1839
+ }
1420
1840
  if (!related) continue;
1421
1841
  const parentId = pivotRow[foreignKey];
1422
1842
  if (!grouped.has(parentId)) grouped.set(parentId, []);
@@ -1452,13 +1872,29 @@ export class ModelQuery<T extends BaseEntity> {
1452
1872
  table: string,
1453
1873
  column: string,
1454
1874
  values: unknown[],
1875
+ extraWheres: ReadonlyArray<{
1876
+ column: string;
1877
+ operator: string;
1878
+ value: unknown;
1879
+ }> = [],
1455
1880
  ): Promise<Record<string, unknown>[]> {
1881
+ const wheres: Array<Record<string, unknown>> = [
1882
+ { column, operator: "IN", value: values, type: "and" },
1883
+ ];
1884
+ for (const w of extraWheres) {
1885
+ wheres.push({
1886
+ column: w.column,
1887
+ operator: w.operator,
1888
+ value: w.value,
1889
+ type: "and",
1890
+ });
1891
+ }
1456
1892
  const spec = {
1457
1893
  kind: "select",
1458
1894
  table,
1459
1895
  select: ["*"],
1460
1896
  selectSubqueries: [],
1461
- wheres: [{ column, operator: "IN", value: values, type: "and" }],
1897
+ wheres,
1462
1898
  orderBy: [],
1463
1899
  groupBy: [],
1464
1900
  having: [],
@@ -1586,17 +2022,23 @@ export class ModelQuery<T extends BaseEntity> {
1586
2022
  switch (relation.type) {
1587
2023
  case "hasOne":
1588
2024
  case "hasMany": {
1589
- const fk = `${camelToSnake(this.#entityClass.name)}_id`;
2025
+ // Honour custom foreignKey/localKey exactly like the eager loader —
2026
+ // hard-coding them here produced silently-wrong whereHas/withCount SQL.
2027
+ const fk =
2028
+ relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2029
+ const localKey = relation.localKey ?? parentPk;
1590
2030
  sub.#pushWhereRaw(
1591
- `${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(parentPk)}`,
2031
+ `${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`,
1592
2032
  );
1593
2033
  break;
1594
2034
  }
1595
2035
  case "belongsTo": {
1596
- const fk = `${camelToSnake(relatedClass.name)}_id`;
1597
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
2036
+ const fk =
2037
+ relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
2038
+ const ownerKey =
2039
+ relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1598
2040
  sub.#pushWhereRaw(
1599
- `${q(relatedTable)}.${q(relatedPk)} = ${q(parentTable)}.${q(fk)}`,
2041
+ `${q(relatedTable)}.${q(ownerKey)} = ${q(parentTable)}.${q(fk)}`,
1600
2042
  );
1601
2043
  break;
1602
2044
  }
@@ -1614,23 +2056,46 @@ export class ModelQuery<T extends BaseEntity> {
1614
2056
  const otherKey =
1615
2057
  pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1616
2058
  const relatedPk = getPrimaryKey(relatedClass) ?? "id";
2059
+ const localKey = relation.localKey ?? parentPk;
1617
2060
  sub.#pushWhereRaw(
1618
2061
  `${q(relatedTable)}.${q(relatedPk)} IN ` +
1619
2062
  `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1620
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(parentPk)})`,
2063
+ `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`,
1621
2064
  );
1622
2065
  break;
1623
2066
  }
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.`,
2067
+ case "hasOneThrough":
2068
+ case "hasManyThrough": {
2069
+ // Two-hop correlated EXISTS: parent through related. Mirrors the
2070
+ // eager loader's key resolution (`#resolveThrough`) exactly so
2071
+ // whereHas/withCount agree with what preload() would return.
2072
+ if (!relation.through) {
2073
+ throw new Error(
2074
+ `@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`,
2075
+ );
2076
+ }
2077
+ const throughClass = relation.through() as new () => BaseEntity;
2078
+ const throughMeta = getEntityMetadata(throughClass);
2079
+ if (!throughMeta) {
2080
+ throw new Error(
2081
+ `Entity metadata missing on through class ${throughClass.name}`,
2082
+ );
2083
+ }
2084
+ const throughTable = throughMeta.tableName;
2085
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
2086
+ const parentLocal = relation.localKey ?? parentPk;
2087
+ const firstKey =
2088
+ relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2089
+ const secondKey =
2090
+ relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
2091
+ const secondLocal = relation.secondLocalKey ?? throughPk;
2092
+ sub.#pushWhereRaw(
2093
+ `${q(relatedTable)}.${q(secondKey)} IN ` +
2094
+ `(SELECT ${q(secondLocal)} FROM ${q(throughTable)} ` +
2095
+ `WHERE ${q(throughTable)}.${q(firstKey)} = ${q(parentTable)}.${q(parentLocal)})`,
1633
2096
  );
2097
+ break;
2098
+ }
1634
2099
  }
1635
2100
  return sub;
1636
2101
  }
@@ -1990,6 +2455,14 @@ export class ModelQuery<T extends BaseEntity> {
1990
2455
  c.#joins = [...this.#joins];
1991
2456
  c.#lockMode = this.#lockMode;
1992
2457
  c.#distinct = this.#distinct;
2458
+ c.#groupBy = [...this.#groupBy];
2459
+ c.#having = structuredCloneSafe(this.#having);
2460
+ c.#ctes = this.#ctes.map((e) => ({ name: e.name, query: e.query.clone() }));
2461
+ c.#unions = this.#unions.map((u) => ({
2462
+ query: u.query.clone(),
2463
+ all: u.all,
2464
+ }));
2465
+ c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
1993
2466
  c.#debugFlag = this.#debugFlag;
1994
2467
  return c;
1995
2468
  }
@@ -152,7 +152,11 @@ function reconcile(
152
152
  // excluded (DB-generated).
153
153
  for (const dbCol of dbCols) {
154
154
  if (dbCol.primaryKey) continue;
155
- if (!dbCol.nullable && !dbCol.hasDefault && !mappedDbColumns.has(dbCol.name)) {
155
+ if (
156
+ !dbCol.nullable &&
157
+ !dbCol.hasDefault &&
158
+ !mappedDbColumns.has(dbCol.name)
159
+ ) {
156
160
  findings.push({
157
161
  entity: entityName,
158
162
  table,
@@ -213,7 +217,8 @@ export async function checkSchema(
213
217
 
214
218
  /** Render findings as a didactic, Adonis-style diff (grouped per table). */
215
219
  export function formatSchemaFindings(findings: SchemaFinding[]): string {
216
- if (findings.length === 0) return "[atlas:check] schema OK — models match the database.";
220
+ if (findings.length === 0)
221
+ return "[atlas:check] schema OK — models match the database.";
217
222
  const byTable = new Map<string, SchemaFinding[]>();
218
223
  for (const f of findings) {
219
224
  const key = `${f.table} (${f.entity})`;