@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.
@@ -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
- unions: [],
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
- // Step 1 — pivot table: find (foreignKey otherKey) pairs
950
- const pivotRows = await ctx.runInQuery(pivot.pivotTable, foreignKey, ids);
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
- const hydrated = ctx.hydrate(row);
965
- byRelatedPk.set(row[ctx.relatedPk], hydrated);
966
- allRelated.push(hydrated);
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
- const related = byRelatedPk.get(pivotRow[otherKey]);
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: [{ column, operator: "IN", value: values, type: "and" }],
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
- const fk = `${camelToSnake(this.#entityClass.name)}_id`;
1090
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(parentPk)}`);
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 relatedPk = getPrimaryKey(relatedClass) ?? "id";
1096
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} = ${q(parentTable)}.${q(fk)}`);
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(parentPk)})`);
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
  }