@c9up/atlas 0.1.17 → 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/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/BaseEntity.d.ts +7 -2
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +4 -2
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseRepository.d.ts +9 -3
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +123 -17
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +95 -0
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +386 -27
- package/dist/ModelQuery.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts.map +1 -1
- package/dist/schema/SchemaCheck.js +3 -1
- package/dist/schema/SchemaCheck.js.map +1 -1
- package/dist/schema/introspect.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 +2 -1
- package/src/BaseEntity.ts +23 -4
- package/src/BaseRepository.ts +143 -17
- package/src/ModelQuery.ts +503 -30
- package/src/schema/SchemaCheck.ts +7 -2
- package/src/schema/introspect.ts +3 -4
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:
|
|
220
|
+
having: HavingEntry[];
|
|
197
221
|
limit: number | null;
|
|
198
222
|
offset: number | null;
|
|
199
223
|
distinct: boolean;
|
|
200
|
-
ctes:
|
|
201
|
-
unions:
|
|
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
|
-
|
|
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
|
-
//
|
|
1397
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
2031
|
+
`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`,
|
|
1592
2032
|
);
|
|
1593
2033
|
break;
|
|
1594
2034
|
}
|
|
1595
2035
|
case "belongsTo": {
|
|
1596
|
-
const fk =
|
|
1597
|
-
|
|
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(
|
|
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(
|
|
2063
|
+
`WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`,
|
|
1621
2064
|
);
|
|
1622
2065
|
break;
|
|
1623
2066
|
}
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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 (
|
|
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)
|
|
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})`;
|