@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/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 +115 -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 +135 -17
- package/src/ModelQuery.ts +503 -30
- package/src/schema/SchemaCheck.ts +7 -2
- package/src/schema/introspect.ts +3 -4
package/dist/ModelQuery.js
CHANGED
|
@@ -128,6 +128,14 @@ export class Paginator {
|
|
|
128
128
|
all() {
|
|
129
129
|
return this.items;
|
|
130
130
|
}
|
|
131
|
+
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
132
|
+
get hasPages() {
|
|
133
|
+
return this.meta.lastPage > 1;
|
|
134
|
+
}
|
|
135
|
+
/** True when there is at least one more page after the current one (AdonisJS `hasMorePages`). */
|
|
136
|
+
get hasMorePages() {
|
|
137
|
+
return this.meta.currentPage < this.meta.lastPage;
|
|
138
|
+
}
|
|
131
139
|
serialize(opts) {
|
|
132
140
|
const data = this.items.map((item) => {
|
|
133
141
|
if (!opts?.fields)
|
|
@@ -199,6 +207,16 @@ export class ModelQuery {
|
|
|
199
207
|
#debugFlag = false;
|
|
200
208
|
/** Distinct flag — Story 29.5. */
|
|
201
209
|
#distinct = false;
|
|
210
|
+
/** GROUP BY columns (Lucid parity). */
|
|
211
|
+
#groupBy = [];
|
|
212
|
+
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
213
|
+
#having = [];
|
|
214
|
+
/** CTEs registered via `.with()` (Lucid parity). */
|
|
215
|
+
#ctes = [];
|
|
216
|
+
/** UNION / UNION ALL branches (Lucid parity). */
|
|
217
|
+
#unions = [];
|
|
218
|
+
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
219
|
+
#pivotWheres = [];
|
|
202
220
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
203
221
|
#dialect;
|
|
204
222
|
constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect()) {
|
|
@@ -365,6 +383,119 @@ export class ModelQuery {
|
|
|
365
383
|
});
|
|
366
384
|
return this;
|
|
367
385
|
}
|
|
386
|
+
// ─── OR-combined variants (AdonisJS orWhere* family) ─────────
|
|
387
|
+
// Same predicates as the whereX methods above, combined with OR instead of
|
|
388
|
+
// AND — the named ergonomics Lucid exposes (vs emulating with `orWhere(cb)`).
|
|
389
|
+
/** `OR col IS NULL`. */
|
|
390
|
+
orWhereNull(column) {
|
|
391
|
+
this.#wheres.push({
|
|
392
|
+
type: "or",
|
|
393
|
+
column: this.#resolveColumn(column),
|
|
394
|
+
operator: "IS NULL",
|
|
395
|
+
value: null,
|
|
396
|
+
});
|
|
397
|
+
return this;
|
|
398
|
+
}
|
|
399
|
+
/** `OR col IS NOT NULL`. */
|
|
400
|
+
orWhereNotNull(column) {
|
|
401
|
+
this.#wheres.push({
|
|
402
|
+
type: "or",
|
|
403
|
+
column: this.#resolveColumn(column),
|
|
404
|
+
operator: "IS NOT NULL",
|
|
405
|
+
value: null,
|
|
406
|
+
});
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
/** `OR col != ?`. */
|
|
410
|
+
orWhereNot(column, value) {
|
|
411
|
+
this.#wheres.push({
|
|
412
|
+
type: "or",
|
|
413
|
+
column: this.#resolveColumn(column),
|
|
414
|
+
operator: "!=",
|
|
415
|
+
value,
|
|
416
|
+
});
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
/** `OR col IN (...)` — array or `ModelQuery` subquery source. */
|
|
420
|
+
orWhereIn(column, source) {
|
|
421
|
+
if (source instanceof _a) {
|
|
422
|
+
this.#wheres.push({
|
|
423
|
+
type: "or",
|
|
424
|
+
kind: "inSub",
|
|
425
|
+
negated: false,
|
|
426
|
+
column: this.#resolveColumn(column),
|
|
427
|
+
subquery: source.#buildSpec(),
|
|
428
|
+
});
|
|
429
|
+
return this;
|
|
430
|
+
}
|
|
431
|
+
this.#wheres.push({
|
|
432
|
+
type: "or",
|
|
433
|
+
column: this.#resolveColumn(column),
|
|
434
|
+
operator: "IN",
|
|
435
|
+
value: [...source],
|
|
436
|
+
});
|
|
437
|
+
return this;
|
|
438
|
+
}
|
|
439
|
+
/** `OR col NOT IN (...)` — array or `ModelQuery` subquery source. */
|
|
440
|
+
orWhereNotIn(column, source) {
|
|
441
|
+
if (source instanceof _a) {
|
|
442
|
+
this.#wheres.push({
|
|
443
|
+
type: "or",
|
|
444
|
+
kind: "inSub",
|
|
445
|
+
negated: true,
|
|
446
|
+
column: this.#resolveColumn(column),
|
|
447
|
+
subquery: source.#buildSpec(),
|
|
448
|
+
});
|
|
449
|
+
return this;
|
|
450
|
+
}
|
|
451
|
+
this.#wheres.push({
|
|
452
|
+
type: "or",
|
|
453
|
+
column: this.#resolveColumn(column),
|
|
454
|
+
operator: "NOT IN",
|
|
455
|
+
value: [...source],
|
|
456
|
+
});
|
|
457
|
+
return this;
|
|
458
|
+
}
|
|
459
|
+
/** `OR col BETWEEN ? AND ?`. */
|
|
460
|
+
orWhereBetween(column, range) {
|
|
461
|
+
this.#wheres.push({
|
|
462
|
+
type: "or",
|
|
463
|
+
column: this.#resolveColumn(column),
|
|
464
|
+
operator: "BETWEEN",
|
|
465
|
+
value: [...range],
|
|
466
|
+
});
|
|
467
|
+
return this;
|
|
468
|
+
}
|
|
469
|
+
/** `OR col NOT BETWEEN ? AND ?`. */
|
|
470
|
+
orWhereNotBetween(column, range) {
|
|
471
|
+
this.#wheres.push({
|
|
472
|
+
type: "or",
|
|
473
|
+
column: this.#resolveColumn(column),
|
|
474
|
+
operator: "NOT BETWEEN",
|
|
475
|
+
value: [...range],
|
|
476
|
+
});
|
|
477
|
+
return this;
|
|
478
|
+
}
|
|
479
|
+
/** `OR col LIKE ?`. */
|
|
480
|
+
orWhereLike(column, pattern) {
|
|
481
|
+
this.#wheres.push({
|
|
482
|
+
type: "or",
|
|
483
|
+
column: this.#resolveColumn(column),
|
|
484
|
+
operator: "LIKE",
|
|
485
|
+
value: pattern,
|
|
486
|
+
});
|
|
487
|
+
return this;
|
|
488
|
+
}
|
|
489
|
+
/** `OR col ILIKE ?` (rewritten to LOWER() LIKE LOWER() on sqlite/mysql). */
|
|
490
|
+
orWhereILike(column, pattern) {
|
|
491
|
+
this.#wheres.push({
|
|
492
|
+
type: "or",
|
|
493
|
+
column: this.#resolveColumn(column),
|
|
494
|
+
operator: "ILIKE",
|
|
495
|
+
value: pattern,
|
|
496
|
+
});
|
|
497
|
+
return this;
|
|
498
|
+
}
|
|
368
499
|
/**
|
|
369
500
|
* **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
|
|
370
501
|
* `?`-style bindings. The Rust compiler re-indexes the placeholders so they
|
|
@@ -452,6 +583,44 @@ export class ModelQuery {
|
|
|
452
583
|
this.#wheres.push({ type: "and", column: resolved, operator: op, value });
|
|
453
584
|
return this;
|
|
454
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* Compare two COLUMNS (AdonisJS/Knex `whereColumn`) — `WHERE "a" op "b"`.
|
|
588
|
+
* Both sides go through the identifier quoter (injection-safe) and the
|
|
589
|
+
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
590
|
+
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
591
|
+
*/
|
|
592
|
+
whereColumn(left, operator, right) {
|
|
593
|
+
return this.#whereColumn("and", left, operator, right);
|
|
594
|
+
}
|
|
595
|
+
/** `OR`-combined {@link whereColumn}. */
|
|
596
|
+
orWhereColumn(left, operator, right) {
|
|
597
|
+
return this.#whereColumn("or", left, operator, right);
|
|
598
|
+
}
|
|
599
|
+
#whereColumn(type, left, operator, right) {
|
|
600
|
+
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
601
|
+
throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
|
|
602
|
+
}
|
|
603
|
+
// Both operands are interpolated as raw identifiers (no value binding for a
|
|
604
|
+
// column reference), and #quote is a plain wrapper that does NOT escape an
|
|
605
|
+
// embedded quote — so validate each RESOLVED identifier against a strict
|
|
606
|
+
// `[table.]column` charset. This closes the injection surface regardless of
|
|
607
|
+
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
608
|
+
const safe = (name) => {
|
|
609
|
+
const resolved = this.#resolveColumn(name);
|
|
610
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
|
|
611
|
+
throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
|
|
612
|
+
}
|
|
613
|
+
// Quote each dotted segment separately → `"table"."column"`, never a
|
|
614
|
+
// single mis-quoted `"table.column"`.
|
|
615
|
+
return resolved
|
|
616
|
+
.split(".")
|
|
617
|
+
.map((part) => this.#quote(part))
|
|
618
|
+
.join(".");
|
|
619
|
+
};
|
|
620
|
+
const sql = `${safe(left)} ${operator} ${safe(right)}`;
|
|
621
|
+
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
622
|
+
return this;
|
|
623
|
+
}
|
|
455
624
|
/**
|
|
456
625
|
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
457
626
|
* by the existence of related rows, optionally constrained by a callback.
|
|
@@ -576,6 +745,98 @@ export class ModelQuery {
|
|
|
576
745
|
this.#orderBys.push({ column: this.#resolveColumn(column), direction });
|
|
577
746
|
return this;
|
|
578
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
750
|
+
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
751
|
+
* For a raw grouping expression, use a `whereRaw`-style construct via the
|
|
752
|
+
* fluent {@link QueryBuilder}.
|
|
753
|
+
*/
|
|
754
|
+
groupBy(...columns) {
|
|
755
|
+
for (const c of columns)
|
|
756
|
+
this.#groupBy.push(this.#resolveColumn(c));
|
|
757
|
+
return this;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
761
|
+
* The column is passed verbatim to the Rust HAVING compiler, which quotes a
|
|
762
|
+
* plain identifier or accepts an allow-listed aggregate expression
|
|
763
|
+
* (`COUNT(*)`, `SUM(col)`, …) — it is NOT run through the entity column map,
|
|
764
|
+
* so aggregate expressions and result aliases both work.
|
|
765
|
+
*/
|
|
766
|
+
having(column, operator, value) {
|
|
767
|
+
this.#having.push({ column, operator, value, type: "and" });
|
|
768
|
+
return this;
|
|
769
|
+
}
|
|
770
|
+
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
771
|
+
orHaving(column, operator, value) {
|
|
772
|
+
this.#having.push({ column, operator, value, type: "or" });
|
|
773
|
+
return this;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* **⚠ UNSAFE** — append a raw SQL `HAVING` fragment with `?` bindings
|
|
777
|
+
* (AdonisJS/Lucid `havingRaw`). The Rust compiler re-indexes the placeholders;
|
|
778
|
+
* everything else in `sql` is trusted verbatim. All values must go through
|
|
779
|
+
* `bindings`.
|
|
780
|
+
*
|
|
781
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
782
|
+
*/
|
|
783
|
+
havingRaw(sql, bindings = []) {
|
|
784
|
+
this.#having.push({
|
|
785
|
+
kind: "raw",
|
|
786
|
+
sql,
|
|
787
|
+
bindings: [...bindings],
|
|
788
|
+
type: "and",
|
|
789
|
+
});
|
|
790
|
+
return this;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* `UNION (<query>)` (AdonisJS/Lucid `union`). The other query is compiled and
|
|
794
|
+
* appended as a parenthesised UNION branch; its bindings are re-indexed into
|
|
795
|
+
* the outer parameter list.
|
|
796
|
+
*/
|
|
797
|
+
union(query) {
|
|
798
|
+
this.#unions.push({ query, all: false });
|
|
799
|
+
return this;
|
|
800
|
+
}
|
|
801
|
+
/** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
|
|
802
|
+
unionAll(query) {
|
|
803
|
+
this.#unions.push({ query, all: true });
|
|
804
|
+
return this;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
808
|
+
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
809
|
+
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
810
|
+
*/
|
|
811
|
+
with(name, query) {
|
|
812
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
813
|
+
throw new Error(`with(): CTE name '${name}' is not a valid identifier`);
|
|
814
|
+
}
|
|
815
|
+
this.#ctes.push({ name, query });
|
|
816
|
+
return this;
|
|
817
|
+
}
|
|
818
|
+
wherePivot(column, operatorOrValue, value) {
|
|
819
|
+
if (value === undefined) {
|
|
820
|
+
this.#pivotWheres.push({ column, operator: "=", value: operatorOrValue });
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
this.#pivotWheres.push({
|
|
824
|
+
column,
|
|
825
|
+
operator: operatorOrValue,
|
|
826
|
+
value,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
return this;
|
|
830
|
+
}
|
|
831
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `wherePivotIn`). */
|
|
832
|
+
wherePivotIn(column, values) {
|
|
833
|
+
this.#pivotWheres.push({ column, operator: "IN", value: [...values] });
|
|
834
|
+
return this;
|
|
835
|
+
}
|
|
836
|
+
/** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
|
|
837
|
+
get pivotConstraints() {
|
|
838
|
+
return this.#pivotWheres;
|
|
839
|
+
}
|
|
579
840
|
limit(n) {
|
|
580
841
|
// Guard here with a clear message — the Rust spec types limit as
|
|
581
842
|
// u64, so a negative/non-integer otherwise surfaces as a cryptic
|
|
@@ -611,6 +872,21 @@ export class ModelQuery {
|
|
|
611
872
|
throw new Error(`No ${this.#tableName} found matching query`);
|
|
612
873
|
return result;
|
|
613
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Return the single matching row, or throw if there are zero OR more than one
|
|
877
|
+
* (AdonisJS/Laravel `sole`). Use when exactly one row is a correctness
|
|
878
|
+
* invariant — a second match signals a bug the silent `first()` would hide.
|
|
879
|
+
*/
|
|
880
|
+
async sole() {
|
|
881
|
+
const rows = await this.limit(2).exec();
|
|
882
|
+
if (rows.length === 0) {
|
|
883
|
+
throw new Error(`No ${this.#tableName} found matching query (sole()).`);
|
|
884
|
+
}
|
|
885
|
+
if (rows.length > 1) {
|
|
886
|
+
throw new Error(`Expected exactly one ${this.#tableName} but the query matched multiple rows (sole()).`);
|
|
887
|
+
}
|
|
888
|
+
return rows[0];
|
|
889
|
+
}
|
|
614
890
|
/**
|
|
615
891
|
* Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
|
|
616
892
|
* A chain like `await repo.query().where('active', true).orderBy('id')`
|
|
@@ -656,13 +932,19 @@ export class ModelQuery {
|
|
|
656
932
|
selectSubqueries: this.#selectSubqueries,
|
|
657
933
|
wheres,
|
|
658
934
|
orderBy: this.#orderBys,
|
|
659
|
-
groupBy:
|
|
660
|
-
having:
|
|
935
|
+
groupBy: this.#groupBy,
|
|
936
|
+
having: this.#having,
|
|
661
937
|
limit: this.#limit ?? null,
|
|
662
938
|
offset: this.#offset ?? null,
|
|
663
939
|
distinct: this.#distinct,
|
|
664
|
-
ctes:
|
|
665
|
-
|
|
940
|
+
ctes: this.#ctes.map((c) => {
|
|
941
|
+
const { sql, params } = c.query.toSQL();
|
|
942
|
+
return { name: c.name, sql, params };
|
|
943
|
+
}),
|
|
944
|
+
unions: this.#unions.map((u) => {
|
|
945
|
+
const { sql, params } = u.query.toSQL();
|
|
946
|
+
return { sql, params, all: u.all };
|
|
947
|
+
}),
|
|
666
948
|
joins: this.#joins,
|
|
667
949
|
lockMode: this.#lockMode,
|
|
668
950
|
};
|
|
@@ -946,8 +1228,19 @@ export class ModelQuery {
|
|
|
946
1228
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
947
1229
|
if (ids.length === 0)
|
|
948
1230
|
return [];
|
|
949
|
-
//
|
|
950
|
-
|
|
1231
|
+
// Extract PIVOT-table constraints (wherePivot / wherePivotIn) from the
|
|
1232
|
+
// preload callback by replaying it on a throwaway builder. The callback
|
|
1233
|
+
// also runs (again) inside runRelationQuery against the related table; both
|
|
1234
|
+
// runs are pure builder mutations, and pivot constraints are inert there.
|
|
1235
|
+
const pivotWheres = [];
|
|
1236
|
+
if (ctx.nestedCallback) {
|
|
1237
|
+
const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, (c) => c, false, this.#dialect);
|
|
1238
|
+
ctx.nestedCallback(scratch);
|
|
1239
|
+
for (const c of scratch.pivotConstraints)
|
|
1240
|
+
pivotWheres.push({ ...c });
|
|
1241
|
+
}
|
|
1242
|
+
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
1243
|
+
const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
|
|
951
1244
|
if (pivotRows.length === 0) {
|
|
952
1245
|
for (const entity of entities)
|
|
953
1246
|
entity.setProp(relationName, []);
|
|
@@ -958,17 +1251,44 @@ export class ModelQuery {
|
|
|
958
1251
|
];
|
|
959
1252
|
// Step 2 — load all related entities in one query
|
|
960
1253
|
const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
|
|
1254
|
+
const pivotCols = pivot.pivotColumns ?? [];
|
|
1255
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
1256
|
+
// When pivot extras are projected, each (parent, related) edge gets its OWN
|
|
1257
|
+
// hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
|
|
1258
|
+
// across parents (Lucid gives distinct pivot-bearing instances). Otherwise a
|
|
1259
|
+
// single shared instance per related PK is reused (cheaper, current behaviour).
|
|
1260
|
+
const projectPivot = pivotCols.length > 0;
|
|
1261
|
+
const rawByRelatedPk = new Map();
|
|
961
1262
|
const byRelatedPk = new Map();
|
|
962
1263
|
const allRelated = [];
|
|
963
1264
|
for (const row of relRows) {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1265
|
+
rawByRelatedPk.set(row[ctx.relatedPk], row);
|
|
1266
|
+
if (!projectPivot) {
|
|
1267
|
+
const hydrated = ctx.hydrate(row);
|
|
1268
|
+
byRelatedPk.set(row[ctx.relatedPk], hydrated);
|
|
1269
|
+
allRelated.push(hydrated);
|
|
1270
|
+
}
|
|
967
1271
|
}
|
|
968
|
-
// Step 3 — group via the pivot
|
|
1272
|
+
// Step 3 — group via the pivot, projecting declared pivotColumns into
|
|
1273
|
+
// `$extras.pivot_<col>` (running each column's `consume` adapter if any).
|
|
969
1274
|
const grouped = new Map();
|
|
970
1275
|
for (const pivotRow of pivotRows) {
|
|
971
|
-
|
|
1276
|
+
let related;
|
|
1277
|
+
if (projectPivot) {
|
|
1278
|
+
const raw = rawByRelatedPk.get(pivotRow[otherKey]);
|
|
1279
|
+
if (!raw)
|
|
1280
|
+
continue;
|
|
1281
|
+
related = ctx.hydrate(raw);
|
|
1282
|
+
for (const col of pivotCols) {
|
|
1283
|
+
const rawVal = pivotRow[col];
|
|
1284
|
+
const adapter = pivotAdapters[col];
|
|
1285
|
+
related.setExtra(`pivot_${col}`, adapter?.consume ? adapter.consume(rawVal) : rawVal);
|
|
1286
|
+
}
|
|
1287
|
+
allRelated.push(related);
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
related = byRelatedPk.get(pivotRow[otherKey]);
|
|
1291
|
+
}
|
|
972
1292
|
if (!related)
|
|
973
1293
|
continue;
|
|
974
1294
|
const parentId = pivotRow[foreignKey];
|
|
@@ -992,13 +1312,24 @@ export class ModelQuery {
|
|
|
992
1312
|
}
|
|
993
1313
|
}
|
|
994
1314
|
/** Compile + execute a `SELECT * FROM <table> WHERE <column> IN (...)` via the Rust compiler. */
|
|
995
|
-
async #runInQuery(table, column, values) {
|
|
1315
|
+
async #runInQuery(table, column, values, extraWheres = []) {
|
|
1316
|
+
const wheres = [
|
|
1317
|
+
{ column, operator: "IN", value: values, type: "and" },
|
|
1318
|
+
];
|
|
1319
|
+
for (const w of extraWheres) {
|
|
1320
|
+
wheres.push({
|
|
1321
|
+
column: w.column,
|
|
1322
|
+
operator: w.operator,
|
|
1323
|
+
value: w.value,
|
|
1324
|
+
type: "and",
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
996
1327
|
const spec = {
|
|
997
1328
|
kind: "select",
|
|
998
1329
|
table,
|
|
999
1330
|
select: ["*"],
|
|
1000
1331
|
selectSubqueries: [],
|
|
1001
|
-
wheres
|
|
1332
|
+
wheres,
|
|
1002
1333
|
orderBy: [],
|
|
1003
1334
|
groupBy: [],
|
|
1004
1335
|
having: [],
|
|
@@ -1086,14 +1417,17 @@ export class ModelQuery {
|
|
|
1086
1417
|
switch (relation.type) {
|
|
1087
1418
|
case "hasOne":
|
|
1088
1419
|
case "hasMany": {
|
|
1089
|
-
|
|
1090
|
-
|
|
1420
|
+
// Honour custom foreignKey/localKey exactly like the eager loader —
|
|
1421
|
+
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
1422
|
+
const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1423
|
+
const localKey = relation.localKey ?? parentPk;
|
|
1424
|
+
sub.#pushWhereRaw(`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`);
|
|
1091
1425
|
break;
|
|
1092
1426
|
}
|
|
1093
1427
|
case "belongsTo": {
|
|
1094
|
-
const fk = `${camelToSnake(relatedClass.name)}_id`;
|
|
1095
|
-
const
|
|
1096
|
-
sub.#pushWhereRaw(`${q(relatedTable)}.${q(
|
|
1428
|
+
const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1429
|
+
const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
1430
|
+
sub.#pushWhereRaw(`${q(relatedTable)}.${q(ownerKey)} = ${q(parentTable)}.${q(fk)}`);
|
|
1097
1431
|
break;
|
|
1098
1432
|
}
|
|
1099
1433
|
case "manyToMany": {
|
|
@@ -1106,19 +1440,36 @@ export class ModelQuery {
|
|
|
1106
1440
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1107
1441
|
const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1108
1442
|
const relatedPk = getPrimaryKey(relatedClass) ?? "id";
|
|
1443
|
+
const localKey = relation.localKey ?? parentPk;
|
|
1109
1444
|
sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} IN ` +
|
|
1110
1445
|
`(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
|
|
1111
|
-
`WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(
|
|
1446
|
+
`WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`);
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
case "hasOneThrough":
|
|
1450
|
+
case "hasManyThrough": {
|
|
1451
|
+
// Two-hop correlated EXISTS: parent → through → related. Mirrors the
|
|
1452
|
+
// eager loader's key resolution (`#resolveThrough`) exactly so
|
|
1453
|
+
// whereHas/withCount agree with what preload() would return.
|
|
1454
|
+
if (!relation.through) {
|
|
1455
|
+
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
1456
|
+
}
|
|
1457
|
+
const throughClass = relation.through();
|
|
1458
|
+
const throughMeta = getEntityMetadata(throughClass);
|
|
1459
|
+
if (!throughMeta) {
|
|
1460
|
+
throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
|
|
1461
|
+
}
|
|
1462
|
+
const throughTable = throughMeta.tableName;
|
|
1463
|
+
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1464
|
+
const parentLocal = relation.localKey ?? parentPk;
|
|
1465
|
+
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1466
|
+
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1467
|
+
const secondLocal = relation.secondLocalKey ?? throughPk;
|
|
1468
|
+
sub.#pushWhereRaw(`${q(relatedTable)}.${q(secondKey)} IN ` +
|
|
1469
|
+
`(SELECT ${q(secondLocal)} FROM ${q(throughTable)} ` +
|
|
1470
|
+
`WHERE ${q(throughTable)}.${q(firstKey)} = ${q(parentTable)}.${q(parentLocal)})`);
|
|
1112
1471
|
break;
|
|
1113
1472
|
}
|
|
1114
|
-
default:
|
|
1115
|
-
// hasOneThrough / hasManyThrough build a 2-hop correlated subquery,
|
|
1116
|
-
// which isn't implemented here. Fail loud — falling through would
|
|
1117
|
-
// leave `sub` WITHOUT a join predicate, so whereHas/withCount would
|
|
1118
|
-
// silently match/count EVERY related row.
|
|
1119
|
-
throw new Error(`whereHas/withCount on a '${relation.type}' relation ` +
|
|
1120
|
-
`(${this.#entityClass.name}.${relationName}) is not supported yet. ` +
|
|
1121
|
-
`Use a direct hasMany/belongsTo/manyToMany relation, or filter via a sub-query.`);
|
|
1122
1473
|
}
|
|
1123
1474
|
return sub;
|
|
1124
1475
|
}
|
|
@@ -1374,6 +1725,14 @@ export class ModelQuery {
|
|
|
1374
1725
|
c.#joins = [...this.#joins];
|
|
1375
1726
|
c.#lockMode = this.#lockMode;
|
|
1376
1727
|
c.#distinct = this.#distinct;
|
|
1728
|
+
c.#groupBy = [...this.#groupBy];
|
|
1729
|
+
c.#having = structuredCloneSafe(this.#having);
|
|
1730
|
+
c.#ctes = this.#ctes.map((e) => ({ name: e.name, query: e.query.clone() }));
|
|
1731
|
+
c.#unions = this.#unions.map((u) => ({
|
|
1732
|
+
query: u.query.clone(),
|
|
1733
|
+
all: u.all,
|
|
1734
|
+
}));
|
|
1735
|
+
c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
|
|
1377
1736
|
c.#debugFlag = this.#debugFlag;
|
|
1378
1737
|
return c;
|
|
1379
1738
|
}
|