@bunyad/orm 0.2.0-beta.0 → 0.2.0-beta.2

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.
@@ -1,11 +1,11 @@
1
1
  import { DatabaseManager as Db, CursorPaginator, LengthAwarePaginator, Paginator, dateTimeForStorage, hasQueryListeners, resolvePaginatorPage, wrapSqlName, } from "@bunyad/database";
2
- import { Collection } from "@bunyad/common";
2
+ import { Collection, LazyCollection } from "@bunyad/common";
3
3
  import { OrmCollection } from "./orm-collection.js";
4
4
  import { bootIfNotBooted, fireModelEvent, hasModelEventListeners, } from "./model-events.js";
5
5
  import { getGlobalScopes, hasGlobalScopes, } from "./scopes.js";
6
6
  import { resolveModelBuilder } from "./decorators.js";
7
7
  import { deletedAtColumn, normalizeWithRelations, nowForConnection, parseRelationAlias, singular, usesSoftDeletes, } from "./model-helpers.js";
8
- import { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, MorphMany, MorphOne, MorphTo, MorphToMany, morphTypeFor, resolveRelation, } from "./relations.js";
8
+ import { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, MorphMany, MorphOne, MorphTo, MorphToMany, morphTypeFor, resolveRelation, resolveAggregateRelation, applyPivotWheres, } from "./relations.js";
9
9
  import { eagerLoadAggregates, eagerLoadModels } from "./eager.js";
10
10
  import {} from "./casts.js";
11
11
  import { Model, ModelNotFoundException, attributesNeedModelCasts, filterFillable, resolveConnection, } from "./model.js";
@@ -19,7 +19,17 @@ export function unwrapModelQuery(query) {
19
19
  const target = query[MODEL_QUERY_TARGET];
20
20
  return target ?? query;
21
21
  }
22
- /** Shared empty array — copy-on-write before mutate (ModelQuery ctor hot path). */
22
+ /** `whereNot('col', v)` / `whereNot('col', op, v)` become a negated one-condition group. */
23
+ function notGroupCallback(callbackOrColumn, opOrValue, value) {
24
+ if (typeof callbackOrColumn === "function")
25
+ return callbackOrColumn;
26
+ return (q) => {
27
+ if (value === undefined)
28
+ q.where(callbackOrColumn, opOrValue);
29
+ else
30
+ q.where(callbackOrColumn, String(opOrValue), value);
31
+ };
32
+ }
23
33
  const MQ_EMPTY = Object.freeze([]);
24
34
  const MQ_EMPTY_OBJ = Object.freeze({});
25
35
  /** Copy-on-write: return a mutable array (clone shared MQ_EMPTY). */
@@ -526,15 +536,22 @@ export class ModelQuery {
526
536
  whereUlid(column, value) {
527
537
  return this.where(column, value);
528
538
  }
529
- /** `whereNot(closure)` nested group. */
530
- whereNot(callback) {
539
+ whereNot(callbackOrColumn, opOrValue, value) {
531
540
  this.#simple = false;
532
- (this.#nestedGroups = mqMut(this.#nestedGroups)).push({ boolean: "and", not: true, callback });
541
+ (this.#nestedGroups = mqMut(this.#nestedGroups)).push({
542
+ boolean: "and",
543
+ not: true,
544
+ callback: notGroupCallback(callbackOrColumn, opOrValue, value),
545
+ });
533
546
  return this;
534
547
  }
535
- orWhereNot(callback) {
548
+ orWhereNot(callbackOrColumn, opOrValue, value) {
536
549
  this.#simple = false;
537
- (this.#nestedGroups = mqMut(this.#nestedGroups)).push({ boolean: "or", not: true, callback });
550
+ (this.#nestedGroups = mqMut(this.#nestedGroups)).push({
551
+ boolean: "or",
552
+ not: true,
553
+ callback: notGroupCallback(callbackOrColumn, opOrValue, value),
554
+ });
538
555
  return this;
539
556
  }
540
557
  firstWhere(column, opOrValue, value) {
@@ -581,120 +598,132 @@ export class ModelQuery {
581
598
  const model = await this.sole();
582
599
  return model[column];
583
600
  }
584
- /** `withCount` — `'posts'`, `'posts as post_total'`, a string list, or `{ products: { as: 'productsCount' } }`. */
585
- withCount(...relations) {
586
- for (const arg of relations) {
601
+ #pushAggregate(relation, fn, column, constraint, alias) {
602
+ const parsed = parseRelationAlias(relation);
603
+ const base = parsed.relation;
604
+ const suffix = fn === "count" || fn === "exists" ? fn : `${fn}_${column}`;
605
+ this.#simple = false;
606
+ (this.#withAggregates = mqMut(this.#withAggregates)).push({
607
+ relation: base,
608
+ alias: alias ?? parsed.alias ?? `${base}_${suffix}`,
609
+ fn,
610
+ column: fn === "count" || fn === "exists" ? undefined : column,
611
+ constraint,
612
+ });
613
+ }
614
+ /** `['posts', 'comments as c' => fn]` — string, list, or map of relation → constraint / alias. */
615
+ #pushAggregates(specs, fn, column) {
616
+ for (const arg of specs) {
587
617
  if (Array.isArray(arg)) {
588
- this.withCount(...arg);
618
+ this.#pushAggregates(arg, fn, column);
589
619
  continue;
590
620
  }
591
621
  if (typeof arg === "string") {
592
- const parsed = parseRelationAlias(arg);
593
- this.#simple = false;
594
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
595
- relation: parsed.relation,
596
- alias: parsed.alias ?? `${parsed.relation}_count`,
597
- fn: "count",
598
- });
599
- (this.#withCounts = mqMut(this.#withCounts)).push(arg);
622
+ this.#pushAggregate(arg, fn, column);
600
623
  continue;
601
624
  }
602
625
  for (const [relation, value] of Object.entries(arg)) {
603
- let alias = `${relation}_count`;
604
- if (value === true) {
605
- // default alias
626
+ if (typeof value === "function") {
627
+ this.#pushAggregate(relation, fn, column, value);
606
628
  }
607
629
  else if (typeof value === "string") {
608
- alias = value;
630
+ this.#pushAggregate(relation, fn, column, undefined, value);
609
631
  }
610
- else if (typeof value === "function") {
611
- // constrained count — store as aggregate without callback support yet (count all)
632
+ else if (value && typeof value === "object" && value.as) {
633
+ this.#pushAggregate(relation, fn, column, undefined, value.as);
612
634
  }
613
- else if (value && typeof value === "object" && "as" in value && value.as) {
614
- alias = value.as;
635
+ else {
636
+ this.#pushAggregate(relation, fn, column);
615
637
  }
616
- this.#simple = false;
617
- (this.#withAggregates = mqMut(this.#withAggregates)).push({ relation, alias, fn: "count" });
618
- (this.#withCounts = mqMut(this.#withCounts)).push(relation);
619
638
  }
620
639
  }
640
+ }
641
+ /**
642
+ * `withCount` — `'posts'`, `'posts as post_total'`, a list, or a map such as
643
+ * `{ "payments as paid": (q) => q.where("status", "paid") }`.
644
+ */
645
+ withCount(...relations) {
646
+ this.#pushAggregates(relations, "count");
621
647
  return this;
622
648
  }
623
- /** `withSum($relation, $column)`. */
649
+ /** `withSum($relation, $column)` — relation may be a constrained map. */
624
650
  withSum(relation, column) {
625
- const parsed = parseRelationAlias(relation);
626
- const base = parsed.relation;
627
- this.#simple = false;
628
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
629
- relation: base,
630
- alias: parsed.alias ?? `${base}_sum_${column}`,
631
- fn: "sum",
632
- column,
633
- });
651
+ this.#pushAggregates([relation], "sum", column);
634
652
  return this;
635
653
  }
636
654
  withAvg(relation, column) {
637
- const parsed = parseRelationAlias(relation);
638
- const base = parsed.relation;
639
- this.#simple = false;
640
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
641
- relation: base,
642
- alias: parsed.alias ?? `${base}_avg_${column}`,
643
- fn: "avg",
644
- column,
645
- });
655
+ this.#pushAggregates([relation], "avg", column);
646
656
  return this;
647
657
  }
648
658
  withMin(relation, column) {
649
- const parsed = parseRelationAlias(relation);
650
- const base = parsed.relation;
651
- this.#simple = false;
652
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
653
- relation: base,
654
- alias: parsed.alias ?? `${base}_min_${column}`,
655
- fn: "min",
656
- column,
657
- });
659
+ this.#pushAggregates([relation], "min", column);
658
660
  return this;
659
661
  }
660
662
  withMax(relation, column) {
661
- const parsed = parseRelationAlias(relation);
662
- const base = parsed.relation;
663
- this.#simple = false;
664
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
665
- relation: base,
666
- alias: parsed.alias ?? `${base}_max_${column}`,
667
- fn: "max",
668
- column,
669
- });
663
+ this.#pushAggregates([relation], "max", column);
670
664
  return this;
671
665
  }
672
666
  /** `withExists($relation)`. */
673
667
  withExists(...relations) {
674
- for (const raw of relations.flat()) {
675
- const parsed = parseRelationAlias(raw);
676
- this.#simple = false;
677
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
678
- relation: parsed.relation,
679
- alias: parsed.alias ?? `${parsed.relation}_exists`,
680
- fn: "exists",
681
- });
682
- }
668
+ this.#pushAggregates(relations, "exists");
683
669
  return this;
684
670
  }
685
671
  /** `withAggregate($relation, $column, $function)`. */
686
672
  withAggregate(relation, column, fn) {
687
- const parsed = parseRelationAlias(relation);
688
- const base = parsed.relation;
673
+ this.#pushAggregates([relation], fn, column);
674
+ return this;
675
+ }
676
+ // ── Subqueries, unions and joins on subqueries (forwarded to the query builder) ──
677
+ #sub(query) {
678
+ return query instanceof ModelQuery ? query.toBase() : query;
679
+ }
680
+ #extra(apply) {
689
681
  this.#simple = false;
690
- (this.#withAggregates = mqMut(this.#withAggregates)).push({
691
- relation: base,
692
- alias: parsed.alias ?? `${base}_${fn}_${column}`,
693
- fn,
694
- column: fn === "count" ? undefined : column,
695
- });
682
+ (this.#builderExtras = mqMut(this.#builderExtras)).push(apply);
696
683
  return this;
697
684
  }
685
+ /** `selectSub($query, $as)` — add a subquery as a selected column. */
686
+ selectSub(query, as) {
687
+ return this.#extra((q) => void q.selectSub(this.#sub(query), as));
688
+ }
689
+ /** `whereExists($query)` and its `or` / `not` variants. */
690
+ whereExists(query) {
691
+ return this.#extra((q) => void q.whereExists(this.#sub(query)));
692
+ }
693
+ orWhereExists(query) {
694
+ return this.#extra((q) => void q.orWhereExists(this.#sub(query)));
695
+ }
696
+ whereNotExists(query) {
697
+ return this.#extra((q) => void q.whereNotExists(this.#sub(query)));
698
+ }
699
+ orWhereNotExists(query) {
700
+ return this.#extra((q) => void q.orWhereNotExists(this.#sub(query)));
701
+ }
702
+ /** `union` / `unionAll` — the other query must select the same columns. */
703
+ union(query) {
704
+ return this.#extra((q) => void q.union(this.#sub(query)));
705
+ }
706
+ unionAll(query) {
707
+ return this.#extra((q) => void q.unionAll(this.#sub(query)));
708
+ }
709
+ /** `joinSub($query, $as, $first, $op, $second)`. */
710
+ joinSub(query, as, first, op, second) {
711
+ return this.#extra((q) => void q.joinSub(this.#sub(query), as, first, op, second));
712
+ }
713
+ leftJoinSub(query, as, first, op, second) {
714
+ return this.#extra((q) => void q.leftJoinSub(this.#sub(query), as, first, op, second));
715
+ }
716
+ rightJoinSub(query, as, first, op, second) {
717
+ return this.#extra((q) => void q.rightJoinSub(this.#sub(query), as, first, op, second));
718
+ }
719
+ /** `crossJoin($table)`. */
720
+ crossJoin(table) {
721
+ return this.#extra((q) => void q.crossJoin(table));
722
+ }
723
+ /** `groupByRaw($sql, $bindings)`. */
724
+ groupByRaw(sql, bindings = []) {
725
+ return this.#extra((q) => void q.groupByRaw(sql, bindings));
726
+ }
698
727
  /** `join` (hydrated models still from the base table). */
699
728
  join(table, first, op, second) {
700
729
  this.#simple = false;
@@ -1059,7 +1088,7 @@ export class ModelQuery {
1059
1088
  (this.#orders = mqMut(this.#orders)).push({ raw: sql });
1060
1089
  return this;
1061
1090
  }
1062
- /** Karobar / dialect alias for `orderByRaw`. */
1091
+ /** Alias for `orderByRaw`. */
1063
1092
  orderBySql(sql) {
1064
1093
  return this.orderByRaw(sql);
1065
1094
  }
@@ -1102,6 +1131,21 @@ export class ModelQuery {
1102
1131
  take(value) {
1103
1132
  return this.limit(value);
1104
1133
  }
1134
+ /**
1135
+ * Remove and return this query's limit / offset. Eager loading calls it so a
1136
+ * `with({ posts: (q) => q.limit(3) })` limit applies per parent, not to the
1137
+ * whole batched query.
1138
+ */
1139
+ takePaging() {
1140
+ const out = {};
1141
+ if (this.#limitValue !== undefined)
1142
+ out.limit = this.#limitValue;
1143
+ if (this.#offsetValue !== undefined)
1144
+ out.offset = this.#offsetValue;
1145
+ this.#limitValue = undefined;
1146
+ this.#offsetValue = undefined;
1147
+ return out;
1148
+ }
1105
1149
  offset(value) {
1106
1150
  this.#offsetValue = value;
1107
1151
  return this;
@@ -1132,7 +1176,7 @@ export class ModelQuery {
1132
1176
  return this;
1133
1177
  }
1134
1178
  /**
1135
- * OR `LIKE %term%` across columns (Karobar list `search`).
1179
+ * OR `LIKE %term%` across columns (a list `search`).
1136
1180
  * Empty/whitespace term is a no-op.
1137
1181
  */
1138
1182
  search(columns, term) {
@@ -1344,7 +1388,7 @@ export class ModelQuery {
1344
1388
  const params = [];
1345
1389
  // Fingerprint (no values) — reuse quoted SQL for repeated list shapes.
1346
1390
  let key = this.#withTrashed ? "T" : this.#onlyTrashed ? "O" : "N";
1347
- key += `\u001e${this.model.table}\u001e${plan.columns}`;
1391
+ key += `\u001e${conn.driver}\u001e${this.model.table}\u001e${plan.columns}`;
1348
1392
  for (const w of plan.scopeWheres) {
1349
1393
  key += `\u001eG\u001e${w.boolean}\u001e${w.column}\u001e${w.op}`;
1350
1394
  if (w.op !== "__null__" && w.op !== "__notnull__")
@@ -1538,8 +1582,9 @@ export class ModelQuery {
1538
1582
  #compileHasExistsSelect(conn = this.#queryConnection()) {
1539
1583
  const dialect = conn.dialect;
1540
1584
  const params = [];
1585
+ // The compiled SQL is dialect specific (identifier quoting), so the driver is part of the key.
1541
1586
  let key = this.#withTrashed ? "T" : this.#onlyTrashed ? "O" : "N";
1542
- key += `\u001e${this.model.table}`;
1587
+ key += `\u001e${conn.driver}\u001e${this.model.table}`;
1543
1588
  for (const w of this.#wheres) {
1544
1589
  key += `\u001e${w.boolean}\u001e${w.column}\u001e${w.op}`;
1545
1590
  if (w.op !== "__null__" && w.op !== "__notnull__")
@@ -1692,18 +1737,21 @@ export class ModelQuery {
1692
1737
  alias: `${relation}_count`,
1693
1738
  fn: "count",
1694
1739
  column: undefined,
1740
+ constraint: undefined,
1695
1741
  }));
1696
1742
  if (aggregates.length > 0) {
1697
1743
  q = q.select(`${this.model.table}.*`);
1698
1744
  for (const agg of aggregates) {
1699
- const meta = resolveRelation(this.model, agg.relation);
1700
- if (!meta)
1701
- continue;
1702
- const expr = this.#aggregateSubquery(meta, agg.fn, agg.column);
1703
- if (!expr)
1704
- continue;
1745
+ const meta = resolveAggregateRelation(this.model, agg.relation);
1746
+ if (!meta) {
1747
+ throw new Error(`Cannot aggregate unknown or unsupported relation [${agg.relation}] on ${this.model.name}.`);
1748
+ }
1705
1749
  const alias = wrapSqlName(this.model.getConnection().dialect, agg.alias);
1706
- q = q.selectRaw(`(${expr}) as ${alias}`);
1750
+ const built = this.#constrainedAggregate(meta, agg);
1751
+ if (!built) {
1752
+ throw new Error(`Relation [${agg.relation}] (${meta.kind}) does not support aggregates yet.`);
1753
+ }
1754
+ q = q.selectRaw(`(${built.sql}) as ${alias}`, built.bindings);
1707
1755
  }
1708
1756
  }
1709
1757
  if (this.#limitValue !== undefined)
@@ -1716,6 +1764,26 @@ export class ModelQuery {
1716
1764
  q = q.sharedLock();
1717
1765
  return q;
1718
1766
  }
1767
+ /**
1768
+ * Apply this model's global scopes onto a correlated subquery (`whereHas`, `withCount`, …),
1769
+ * qualifying columns with `table` so joined pivot / parent columns never clash.
1770
+ * Honors `withoutGlobalScopes()` called inside the user's constraint closure.
1771
+ */
1772
+ applyGlobalScopesToSubquery(q, table) {
1773
+ if (this.#withoutGlobalScopes === true)
1774
+ return q;
1775
+ if (!hasGlobalScopes(this.model))
1776
+ return q;
1777
+ for (const [name, scope] of getGlobalScopes(this.model)) {
1778
+ if (this.#withoutGlobalScopes?.has(name))
1779
+ continue;
1780
+ const temp = new ModelQuery(this.model, { withoutGlobalScopes: true });
1781
+ scope(temp);
1782
+ temp.qualifyColumns(table);
1783
+ q = temp.applyConstraintsTo(q);
1784
+ }
1785
+ return q;
1786
+ }
1719
1787
  #applyGlobalScopesTo(q) {
1720
1788
  if (this.#withoutGlobalScopes === true)
1721
1789
  return q;
@@ -1910,18 +1978,27 @@ export class ModelQuery {
1910
1978
  meta.kind === "morphToMany") {
1911
1979
  let applyRelated;
1912
1980
  let relatedQuery;
1913
- if (callback) {
1914
- const rq = meta.related.newQuery({
1915
- withoutGlobalScopes: true,
1916
- });
1917
- callback(rq);
1981
+ const scoped = hasGlobalScopes(meta.related);
1982
+ const relatedRef = meta.kind === "has" && meta.related.table === this.model.table
1983
+ ? `${meta.related.table}_has`
1984
+ : meta.related.table;
1985
+ if (callback || scoped) {
1986
+ const rq = meta.related.newQuery();
1987
+ callback?.(rq);
1918
1988
  if (this.#relatedConstraintsAreMergeable(rq)) {
1919
- applyRelated = this.#bindFlatRelatedApply(rq);
1989
+ const flat = this.#bindFlatRelatedApply(rq);
1990
+ applyRelated = scoped
1991
+ ? (sub) => {
1992
+ flat(sub);
1993
+ rq.applyGlobalScopesToSubquery(sub, relatedRef);
1994
+ }
1995
+ : flat;
1920
1996
  }
1921
1997
  else {
1922
1998
  relatedQuery = rq;
1923
1999
  applyRelated = (sub) => {
1924
2000
  rq.applyConstraintsTo(sub);
2001
+ rq.applyGlobalScopesToSubquery(sub, relatedRef);
1925
2002
  };
1926
2003
  }
1927
2004
  }
@@ -1974,6 +2051,9 @@ export class ModelQuery {
1974
2051
  sub.from(relatedTable);
1975
2052
  sub.whereColumn(`${relatedTable}.${foreignKey}`, `${parentTable}.${localKey}`);
1976
2053
  }
2054
+ if (meta.typeColumn) {
2055
+ sub.where(`${selfAlias ?? relatedTable}.${meta.typeColumn}`, meta.morphType);
2056
+ }
1977
2057
  applyRelated?.(sub);
1978
2058
  };
1979
2059
  }
@@ -1999,6 +2079,8 @@ export class ModelQuery {
1999
2079
  if (morphTypeColumn && morphTypes) {
2000
2080
  sub.whereIn(`${pivotTable}.${morphTypeColumn}`, morphTypes);
2001
2081
  }
2082
+ if (meta.kind === "belongsToMany")
2083
+ applyPivotWheres(sub, pivotTable, meta.pivotWheres);
2002
2084
  applyRelated?.(sub);
2003
2085
  sub.select(`${pivotTable}.${foreignPivotKey}`);
2004
2086
  };
@@ -2012,6 +2094,8 @@ export class ModelQuery {
2012
2094
  sub.whereIn(`${pivotTable}.${morphTypeColumn}`, morphTypes);
2013
2095
  sub.whereColumn(`${pivotTable}.${pivotTenantKey}`, `${parentTable}.${parentTenantKey}`);
2014
2096
  }
2097
+ if (meta.kind === "belongsToMany")
2098
+ applyPivotWheres(sub, pivotTable, meta.pivotWheres);
2015
2099
  applyRelated?.(sub);
2016
2100
  };
2017
2101
  }
@@ -2396,6 +2480,8 @@ export class ModelQuery {
2396
2480
  sub.from(relatedTable);
2397
2481
  sub.whereColumn(`${relatedTable}.${meta.foreignKey}`, `${parentTable}.${meta.localKey}`);
2398
2482
  }
2483
+ if (meta.typeColumn)
2484
+ sub.where(`${relatedTable}.${meta.typeColumn}`, meta.morphType);
2399
2485
  }
2400
2486
  else if (meta.kind === "belongsToMany" ||
2401
2487
  meta.kind === "morphToMany") {
@@ -2522,45 +2608,78 @@ export class ModelQuery {
2522
2608
  }
2523
2609
  return constraint.not ? q.whereNotExists(wrap) : q.whereExists(wrap);
2524
2610
  }
2525
- #aggregateSubquery(meta, fn, column) {
2526
- const agg = fn === "exists"
2527
- ? "1"
2528
- : fn === "count"
2529
- ? "COUNT(*)"
2530
- : `${fn.toUpperCase()}(${column ?? "*"})`;
2531
- if (meta.kind === "has") {
2532
- const relatedTable = meta.related.table;
2533
- const parentTable = this.model.table;
2534
- if (relatedTable === parentTable) {
2535
- const alias = `${relatedTable}_has`;
2536
- const where = `${alias}.${meta.foreignKey} = ${parentTable}.${meta.localKey}`;
2537
- if (fn === "exists") {
2538
- return `SELECT 1 FROM ${relatedTable} as ${alias} WHERE ${where} LIMIT 1`;
2611
+ /** Correlated aggregate subquery for `withCount` / `withSum` / … (optional constraint closure). */
2612
+ #constrainedAggregate(meta, agg) {
2613
+ const parentTable = this.model.table;
2614
+ const sub = this.#baseTable();
2615
+ const related = meta.related;
2616
+ const relatedTable = related.table;
2617
+ // Column prefix for the related side (aliased when the relation is self-referencing).
2618
+ let relatedRef = relatedTable;
2619
+ switch (meta.kind) {
2620
+ case "has": {
2621
+ if (relatedTable === parentTable) {
2622
+ relatedRef = `${relatedTable}_has`;
2623
+ sub.from(`${relatedTable} as ${relatedRef}`);
2539
2624
  }
2540
- return `SELECT ${agg} FROM ${relatedTable} as ${alias} WHERE ${where}`;
2625
+ else {
2626
+ sub.from(relatedTable);
2627
+ }
2628
+ sub.whereColumn(`${relatedRef}.${meta.foreignKey}`, `${parentTable}.${meta.localKey}`);
2629
+ if (meta.typeColumn)
2630
+ sub.where(`${relatedRef}.${meta.typeColumn}`, meta.morphType);
2631
+ break;
2541
2632
  }
2542
- const where = `${relatedTable}.${meta.foreignKey} = ${parentTable}.${meta.localKey}`;
2543
- if (fn === "exists") {
2544
- return `SELECT 1 FROM ${relatedTable} WHERE ${where} LIMIT 1`;
2633
+ case "belongsTo":
2634
+ sub.from(relatedTable);
2635
+ sub.whereColumn(`${relatedTable}.${meta.ownerKey}`, `${parentTable}.${meta.foreignKey}`);
2636
+ break;
2637
+ case "belongsToMany":
2638
+ case "morphToMany":
2639
+ sub.from(relatedTable);
2640
+ sub.join(meta.pivotTable, `${meta.pivotTable}.${meta.relatedPivotKey}`, "=", `${relatedTable}.${related.primaryKey}`);
2641
+ sub.whereColumn(`${meta.pivotTable}.${meta.foreignPivotKey}`, `${parentTable}.${meta.localKey}`);
2642
+ if (meta.kind === "morphToMany") {
2643
+ sub.whereIn(`${meta.pivotTable}.${meta.morphTypeColumn}`, meta.morphTypes);
2644
+ }
2645
+ else {
2646
+ applyPivotWheres(sub, meta.pivotTable, meta.pivotWheres);
2647
+ }
2648
+ break;
2649
+ case "hasManyThrough":
2650
+ case "hasOneThrough": {
2651
+ const through = meta.through.table;
2652
+ sub.from(relatedTable);
2653
+ sub.join(through, `${through}.${meta.secondLocalKey}`, "=", `${relatedTable}.${meta.secondKey}`);
2654
+ sub.whereColumn(`${through}.${meta.firstKey}`, `${parentTable}.${meta.localKey}`);
2655
+ if (usesSoftDeletes(meta.through)) {
2656
+ sub.whereNull(`${through}.${deletedAtColumn(meta.through)}`);
2657
+ }
2658
+ break;
2545
2659
  }
2546
- return `SELECT ${agg} FROM ${relatedTable} WHERE ${where}`;
2660
+ default:
2661
+ return null;
2547
2662
  }
2548
- if (meta.kind === "belongsTo") {
2549
- const where = `${meta.related.table}.${meta.ownerKey} = ${this.model.table}.${meta.foreignKey}`;
2550
- if (fn === "exists") {
2551
- return `SELECT 1 FROM ${meta.related.table} WHERE ${where} LIMIT 1`;
2552
- }
2553
- return `SELECT ${agg} FROM ${meta.related.table} WHERE ${where}`;
2663
+ if (usesSoftDeletes(related)) {
2664
+ sub.whereNull(`${relatedRef}.${deletedAtColumn(related)}`);
2554
2665
  }
2555
- if (meta.kind !== "belongsToMany" && meta.kind !== "morphToMany") {
2556
- return null;
2666
+ if (agg.constraint || hasGlobalScopes(related)) {
2667
+ const rq = related.newQuery();
2668
+ agg.constraint?.(rq);
2669
+ rq.applyConstraintsTo(sub);
2670
+ rq.applyGlobalScopesToSubquery(sub, relatedRef);
2557
2671
  }
2558
- const join = `INNER JOIN ${meta.pivotTable} ON ${meta.pivotTable}.${meta.relatedPivotKey} = ${meta.related.table}.${meta.related.primaryKey}`;
2559
- const where = `${meta.pivotTable}.${meta.foreignPivotKey} = ${this.model.table}.${meta.localKey}`;
2560
- if (fn === "exists") {
2561
- return `SELECT 1 FROM ${meta.related.table} ${join} WHERE ${where} LIMIT 1`;
2672
+ if (agg.fn === "exists") {
2673
+ sub.select("1");
2674
+ sub.limit(1);
2562
2675
  }
2563
- return `SELECT ${agg} FROM ${meta.related.table} ${join} WHERE ${where}`;
2676
+ else {
2677
+ const column = agg.column
2678
+ ? agg.column.includes(".") ? agg.column : `${relatedRef}.${agg.column}`
2679
+ : "*";
2680
+ sub.selectRaw(agg.fn === "count" ? "COUNT(*)" : `${agg.fn.toUpperCase()}(${column})`);
2681
+ }
2682
+ return { sql: sub.toSql(), bindings: sub.getBindings() };
2564
2683
  }
2565
2684
  first() {
2566
2685
  const fast = this.#trySimpleFirst();
@@ -2922,8 +3041,45 @@ export class ModelQuery {
2922
3041
  page += 1;
2923
3042
  }
2924
3043
  }
3044
+ /**
3045
+ * `cursor($chunkSize)` — hydrate models one at a time from a single streamed
3046
+ * query, so memory stays flat on large tables. `with()` relations are loaded
3047
+ * per batch of `chunkSize` models rather than per row.
3048
+ */
3049
+ cursor(chunkSize = 1000) {
3050
+ return new LazyCollection(() => this.#cursorModels(chunkSize));
3051
+ }
3052
+ async *#cursorModels(chunkSize) {
3053
+ const size = Math.max(1, Math.floor(chunkSize));
3054
+ const fireRetrieved = hasModelEventListeners(this.model, "retrieved");
3055
+ const eager = this.#eagerLoad.length > 0;
3056
+ let batch = [];
3057
+ for await (const row of this.#buildQuery().cursor(size)) {
3058
+ const model = this.#makeModel(row);
3059
+ if (fireRetrieved) {
3060
+ await fireModelEvent(model, "retrieved");
3061
+ }
3062
+ if (!eager) {
3063
+ yield model;
3064
+ continue;
3065
+ }
3066
+ batch.push(model);
3067
+ if (batch.length >= size) {
3068
+ await this.#loadEager(batch);
3069
+ yield* batch;
3070
+ batch = [];
3071
+ }
3072
+ }
3073
+ if (batch.length > 0) {
3074
+ await this.#loadEager(batch);
3075
+ yield* batch;
3076
+ }
3077
+ }
2925
3078
  /** `lazy($chunkSize)`. */
2926
- async *lazy(chunkSize = 1000) {
3079
+ lazy(chunkSize = 1000) {
3080
+ return new LazyCollection(() => this.#lazyPages(chunkSize));
3081
+ }
3082
+ async *#lazyPages(chunkSize) {
2927
3083
  let page = 1;
2928
3084
  const fireRetrieved = hasModelEventListeners(this.model, "retrieved");
2929
3085
  for (;;) {
@@ -2976,11 +3132,11 @@ export class ModelQuery {
2976
3132
  }
2977
3133
  }
2978
3134
  /** `lazyById`. */
2979
- async *lazyById(chunkSize = 1000, column) {
2980
- yield* this.#lazyById(chunkSize, column ?? this.model.primaryKey, "asc");
3135
+ lazyById(chunkSize = 1000, column) {
3136
+ return new LazyCollection(() => this.#lazyById(chunkSize, column ?? this.model.primaryKey, "asc"));
2981
3137
  }
2982
- async *lazyByIdDesc(chunkSize = 1000, column) {
2983
- yield* this.#lazyById(chunkSize, column ?? this.model.primaryKey, "desc");
3138
+ lazyByIdDesc(chunkSize = 1000, column) {
3139
+ return new LazyCollection(() => this.#lazyById(chunkSize, column ?? this.model.primaryKey, "desc"));
2984
3140
  }
2985
3141
  async *#lazyById(chunkSize, column, direction) {
2986
3142
  const size = Math.max(1, Math.floor(chunkSize));