@c9up/atlas 0.2.0 → 0.2.1

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.
Files changed (234) hide show
  1. package/README.md +8 -0
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +60 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +204 -38
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +17 -0
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js.map +1 -1
  14. package/dist/BaseModel.d.ts +63 -27
  15. package/dist/BaseModel.d.ts.map +1 -1
  16. package/dist/BaseModel.js +108 -51
  17. package/dist/BaseModel.js.map +1 -1
  18. package/dist/BaseRepository.d.ts +11 -1
  19. package/dist/BaseRepository.d.ts.map +1 -1
  20. package/dist/BaseRepository.js +95 -20
  21. package/dist/BaseRepository.js.map +1 -1
  22. package/dist/ConnectionManager.d.ts +106 -0
  23. package/dist/ConnectionManager.d.ts.map +1 -0
  24. package/dist/ConnectionManager.js +228 -0
  25. package/dist/ConnectionManager.js.map +1 -0
  26. package/dist/ModelQuery.d.ts +256 -43
  27. package/dist/ModelQuery.d.ts.map +1 -1
  28. package/dist/ModelQuery.js +899 -231
  29. package/dist/ModelQuery.js.map +1 -1
  30. package/dist/Transaction.d.ts +54 -1
  31. package/dist/Transaction.d.ts.map +1 -1
  32. package/dist/Transaction.js +121 -59
  33. package/dist/Transaction.js.map +1 -1
  34. package/dist/adapters/NapiDbAdapter.d.ts +11 -0
  35. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  36. package/dist/adapters/NapiDbAdapter.js +35 -7
  37. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  38. package/dist/console/contract.d.ts +62 -0
  39. package/dist/console/contract.d.ts.map +1 -0
  40. package/dist/console/contract.js +38 -0
  41. package/dist/console/contract.js.map +1 -0
  42. package/dist/console/factoryCommands.d.ts +23 -0
  43. package/dist/console/factoryCommands.d.ts.map +1 -0
  44. package/dist/console/factoryCommands.js +62 -0
  45. package/dist/console/factoryCommands.js.map +1 -0
  46. package/dist/console/migrationCommands.d.ts +38 -8
  47. package/dist/console/migrationCommands.d.ts.map +1 -1
  48. package/dist/console/migrationCommands.js +157 -50
  49. package/dist/console/migrationCommands.js.map +1 -1
  50. package/dist/console/schemaCheckCommand.d.ts +11 -17
  51. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  52. package/dist/console/schemaCheckCommand.js +21 -15
  53. package/dist/console/schemaCheckCommand.js.map +1 -1
  54. package/dist/console/schemaDumpCommand.d.ts +30 -0
  55. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  56. package/dist/console/schemaDumpCommand.js +69 -0
  57. package/dist/console/schemaDumpCommand.js.map +1 -0
  58. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  59. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  60. package/dist/console/schemaGenerateCommand.js +246 -0
  61. package/dist/console/schemaGenerateCommand.js.map +1 -0
  62. package/dist/console/seederCommands.d.ts +46 -0
  63. package/dist/console/seederCommands.d.ts.map +1 -0
  64. package/dist/console/seederCommands.js +136 -0
  65. package/dist/console/seederCommands.js.map +1 -0
  66. package/dist/decorators/entity.d.ts +13 -2
  67. package/dist/decorators/entity.d.ts.map +1 -1
  68. package/dist/decorators/entity.js +10 -0
  69. package/dist/decorators/entity.js.map +1 -1
  70. package/dist/decorators/hooks.d.ts +3 -3
  71. package/dist/decorators/hooks.d.ts.map +1 -1
  72. package/dist/decorators/hooks.js.map +1 -1
  73. package/dist/events.d.ts +5 -0
  74. package/dist/events.d.ts.map +1 -1
  75. package/dist/events.js.map +1 -1
  76. package/dist/factories.d.ts +8 -0
  77. package/dist/factories.d.ts.map +1 -0
  78. package/dist/factories.js +8 -0
  79. package/dist/factories.js.map +1 -0
  80. package/dist/index.d.ts +11 -5
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +12 -4
  83. package/dist/index.js.map +1 -1
  84. package/dist/lucid-schema.d.ts +15 -0
  85. package/dist/lucid-schema.d.ts.map +1 -0
  86. package/dist/lucid-schema.js +16 -0
  87. package/dist/lucid-schema.js.map +1 -0
  88. package/dist/orm.d.ts +14 -0
  89. package/dist/orm.d.ts.map +1 -0
  90. package/dist/orm.js +14 -0
  91. package/dist/orm.js.map +1 -0
  92. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  93. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  94. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  95. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  96. package/dist/query/DmlBuilder.d.ts +62 -0
  97. package/dist/query/DmlBuilder.d.ts.map +1 -0
  98. package/dist/query/DmlBuilder.js +84 -0
  99. package/dist/query/DmlBuilder.js.map +1 -0
  100. package/dist/query/QueryBuilder.d.ts +5 -0
  101. package/dist/query/QueryBuilder.d.ts.map +1 -1
  102. package/dist/query/QueryBuilder.js +7 -0
  103. package/dist/query/QueryBuilder.js.map +1 -1
  104. package/dist/query/RawQueryBuilder.d.ts +47 -0
  105. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  106. package/dist/query/RawQueryBuilder.js +138 -0
  107. package/dist/query/RawQueryBuilder.js.map +1 -0
  108. package/dist/query/interpolate.d.ts +45 -0
  109. package/dist/query/interpolate.d.ts.map +1 -0
  110. package/dist/query/interpolate.js +51 -0
  111. package/dist/query/interpolate.js.map +1 -0
  112. package/dist/query/native.d.ts +8 -0
  113. package/dist/query/native.d.ts.map +1 -1
  114. package/dist/query/native.js +14 -0
  115. package/dist/query/native.js.map +1 -1
  116. package/dist/query/operators.d.ts +4 -0
  117. package/dist/query/operators.d.ts.map +1 -0
  118. package/dist/query/operators.js +27 -0
  119. package/dist/query/operators.js.map +1 -0
  120. package/dist/schema/Migration.d.ts +31 -7
  121. package/dist/schema/Migration.d.ts.map +1 -1
  122. package/dist/schema/Migration.js +34 -7
  123. package/dist/schema/Migration.js.map +1 -1
  124. package/dist/schema/MigrationRunner.d.ts +56 -6
  125. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  126. package/dist/schema/MigrationRunner.js +382 -112
  127. package/dist/schema/MigrationRunner.js.map +1 -1
  128. package/dist/schema/Schema.d.ts +59 -2
  129. package/dist/schema/Schema.d.ts.map +1 -1
  130. package/dist/schema/Schema.js +143 -9
  131. package/dist/schema/Schema.js.map +1 -1
  132. package/dist/schema/SchemaDumper.d.ts +121 -0
  133. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  134. package/dist/schema/SchemaDumper.js +365 -0
  135. package/dist/schema/SchemaDumper.js.map +1 -0
  136. package/dist/schema/Seeder.d.ts +13 -0
  137. package/dist/schema/Seeder.d.ts.map +1 -1
  138. package/dist/schema/Seeder.js +28 -5
  139. package/dist/schema/Seeder.js.map +1 -1
  140. package/dist/schema/TableBuilder.d.ts +27 -8
  141. package/dist/schema/TableBuilder.d.ts.map +1 -1
  142. package/dist/schema/TableBuilder.js +50 -12
  143. package/dist/schema/TableBuilder.js.map +1 -1
  144. package/dist/schema/catalog.d.ts +25 -1
  145. package/dist/schema/catalog.d.ts.map +1 -1
  146. package/dist/schema/catalog.js +69 -22
  147. package/dist/schema/catalog.js.map +1 -1
  148. package/dist/schema/introspect.d.ts +1 -1
  149. package/dist/schema/introspect.d.ts.map +1 -1
  150. package/dist/schema/introspect.js +26 -11
  151. package/dist/schema/introspect.js.map +1 -1
  152. package/dist/seeders.d.ts +7 -0
  153. package/dist/seeders.d.ts.map +1 -0
  154. package/dist/seeders.js +7 -0
  155. package/dist/seeders.js.map +1 -0
  156. package/dist/services/db.d.ts +120 -9
  157. package/dist/services/db.d.ts.map +1 -1
  158. package/dist/services/db.js +182 -26
  159. package/dist/services/db.js.map +1 -1
  160. package/dist/testing/DatabaseCleanup.d.ts +29 -6
  161. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  162. package/dist/testing/DatabaseCleanup.js +44 -17
  163. package/dist/testing/DatabaseCleanup.js.map +1 -1
  164. package/dist/testing/DbAssertions.d.ts +48 -0
  165. package/dist/testing/DbAssertions.d.ts.map +1 -0
  166. package/dist/testing/DbAssertions.js +80 -0
  167. package/dist/testing/DbAssertions.js.map +1 -0
  168. package/dist/testing/Factory.d.ts +111 -25
  169. package/dist/testing/Factory.d.ts.map +1 -1
  170. package/dist/testing/Factory.js +331 -56
  171. package/dist/testing/Factory.js.map +1 -1
  172. package/dist/testing/TestUtils.d.ts +49 -0
  173. package/dist/testing/TestUtils.d.ts.map +1 -0
  174. package/dist/testing/TestUtils.js +79 -0
  175. package/dist/testing/TestUtils.js.map +1 -0
  176. package/dist/testing/index.d.ts +3 -1
  177. package/dist/testing/index.d.ts.map +1 -1
  178. package/dist/testing/index.js +3 -1
  179. package/dist/testing/index.js.map +1 -1
  180. package/dist/types/relations.d.ts +31 -0
  181. package/dist/types/relations.d.ts.map +1 -0
  182. package/dist/types/relations.js +20 -0
  183. package/dist/types/relations.js.map +1 -0
  184. package/index.darwin-arm64.node +0 -0
  185. package/index.darwin-x64.node +0 -0
  186. package/index.linux-arm64-gnu.node +0 -0
  187. package/index.linux-x64-gnu.node +0 -0
  188. package/index.win32-x64-msvc.node +0 -0
  189. package/package.json +30 -2
  190. package/src/AtlasProvider.ts +297 -51
  191. package/src/BaseEntity.ts +29 -0
  192. package/src/BaseModel.ts +174 -37
  193. package/src/BaseRepository.ts +136 -17
  194. package/src/ConnectionManager.ts +298 -0
  195. package/src/ModelQuery.ts +1309 -260
  196. package/src/Transaction.ts +194 -60
  197. package/src/adapters/NapiDbAdapter.ts +72 -14
  198. package/src/console/contract.ts +96 -0
  199. package/src/console/factoryCommands.ts +73 -0
  200. package/src/console/migrationCommands.ts +253 -63
  201. package/src/console/schemaCheckCommand.ts +26 -22
  202. package/src/console/schemaDumpCommand.ts +96 -0
  203. package/src/console/schemaGenerateCommand.ts +380 -0
  204. package/src/console/seederCommands.ts +174 -0
  205. package/src/decorators/entity.ts +18 -2
  206. package/src/decorators/hooks.ts +5 -3
  207. package/src/events.ts +5 -0
  208. package/src/factories.ts +10 -0
  209. package/src/index.ts +50 -3
  210. package/src/lucid-schema.ts +22 -0
  211. package/src/orm.ts +13 -0
  212. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  213. package/src/query/DmlBuilder.ts +131 -0
  214. package/src/query/QueryBuilder.ts +8 -0
  215. package/src/query/RawQueryBuilder.ts +173 -0
  216. package/src/query/interpolate.ts +73 -0
  217. package/src/query/native.ts +29 -0
  218. package/src/query/operators.ts +27 -0
  219. package/src/schema/Migration.ts +36 -7
  220. package/src/schema/MigrationRunner.ts +483 -119
  221. package/src/schema/Schema.ts +168 -19
  222. package/src/schema/SchemaDumper.ts +518 -0
  223. package/src/schema/Seeder.ts +47 -10
  224. package/src/schema/TableBuilder.ts +48 -11
  225. package/src/schema/catalog.ts +94 -24
  226. package/src/schema/introspect.ts +26 -9
  227. package/src/seeders.ts +16 -0
  228. package/src/services/db.ts +343 -31
  229. package/src/testing/DatabaseCleanup.ts +73 -21
  230. package/src/testing/DbAssertions.ts +127 -0
  231. package/src/testing/Factory.ts +559 -79
  232. package/src/testing/TestUtils.ts +123 -0
  233. package/src/testing/index.ts +12 -1
  234. package/src/types/relations.ts +43 -0
package/src/ModelQuery.ts CHANGED
@@ -29,11 +29,21 @@ import {
29
29
  } from "./decorators/entity.js";
30
30
  import { fireHooks } from "./decorators/hooks.js";
31
31
  import { getNamingStrategy } from "./naming/NamingStrategy.js";
32
+ import { DmlBuilder, type DmlChainHooks } from "./query/DmlBuilder.js";
33
+ import {
34
+ type CompiledStatement,
35
+ compiledStatement,
36
+ interpolateQuery,
37
+ } from "./query/interpolate.js";
32
38
  import {
33
39
  type AtlasDialect,
34
40
  compileStatementNative,
41
+ type DialectName,
35
42
  getAtlasDialect,
43
+ normalizeDialect,
36
44
  } from "./query/native.js";
45
+ import { negateOperator } from "./query/operators.js";
46
+ import { RawSql } from "./query/QueryBuilder.js";
37
47
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
38
48
 
39
49
  /**
@@ -317,7 +327,7 @@ interface RawWhere {
317
327
  interface JsonWhere {
318
328
  type: "and" | "or";
319
329
  kind: "json";
320
- jsonOp: "path" | "superset" | "subset";
330
+ jsonOp: "path" | "superset" | "subset" | "equals";
321
331
  column: string;
322
332
  negated: boolean;
323
333
  path?: string;
@@ -333,11 +343,13 @@ interface ExistsWhere {
333
343
  subquery: SelectSpec;
334
344
  }
335
345
 
336
- /** Parenthesised group of WHERE conditions — built via `where(cb)`. */
346
+ /** Parenthesised group of WHERE conditions — built via `where(cb)` / `whereNot(cb)`. */
337
347
  interface GroupWhere {
338
348
  type: "and" | "or";
339
349
  kind: "group";
340
350
  conditions: WhereClause[];
351
+ /** `whereNot(cb)` wraps the group in `NOT (…)` (honoured by the Rust compiler). */
352
+ negated?: boolean;
341
353
  }
342
354
 
343
355
  /** `col IN (SELECT ...)` / `col NOT IN (SELECT ...)` — built via `whereIn(col, subQ)`. */
@@ -433,20 +445,35 @@ interface SelectSpec {
433
445
  unions: UnionSpec[];
434
446
  /** JOIN fragments; each carries its own `?`-style bound params (e.g. `onVal`). */
435
447
  joins: Array<{ sql: string; params: unknown[] }>;
448
+ /** Verbatim SELECT fragments with their own params — `select(raw)` / `select(subquery.as())`. */
449
+ selectRaw?: Array<{ sql: string; params: unknown[] }>;
436
450
  /** Composite lock clause, e.g. `FOR UPDATE`, `FOR NO KEY UPDATE SKIP LOCKED`. */
437
451
  lockMode: string | null;
438
452
  }
439
453
 
454
+ /** `(col1, col2) IN ((v11, v12), …)` / NOT IN — built via `whereIn([cols], [rows])`. */
455
+ interface InTupleWhere {
456
+ type: "and" | "or";
457
+ kind: "inTuple";
458
+ columns: string[];
459
+ rows: unknown[][];
460
+ negated: boolean;
461
+ }
462
+
440
463
  type WhereClause =
441
464
  | StandardWhere
442
465
  | RawWhere
443
466
  | ExistsWhere
444
467
  | GroupWhere
445
468
  | InSubWhere
469
+ | InTupleWhere
446
470
  | JsonWhere;
447
471
 
448
472
  type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
449
473
 
474
+ /** A compound-query (union/intersect/except) arg — a query OR a callback building one. */
475
+ type UnionArg = ModelQuery<BaseEntity> | ((q: ModelQuery<BaseEntity>) => void);
476
+
450
477
  /**
451
478
  * Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
452
479
  * `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
@@ -505,20 +532,48 @@ function isInternalBypass(): boolean {
505
532
  * to a bound VALUE (AdonisJS/Knex parity) — the value flows through the join-params
506
533
  * channel into the compiled parameter list.
507
534
  */
535
+ interface JoinPartMQ {
536
+ kind: "and" | "or";
537
+ left?: string;
538
+ operator?: string;
539
+ right?: string;
540
+ value?: { v: unknown };
541
+ values?: unknown[];
542
+ notIn?: boolean;
543
+ between?: [unknown, unknown];
544
+ notBetween?: boolean;
545
+ nullOp?: "IS NULL" | "IS NOT NULL";
546
+ exists?: { sql: string; params: unknown[]; not: boolean };
547
+ }
548
+
508
549
  interface JoinBuilder {
509
- /** A column-to-column part (`value` absent) or a column-to-value part (`value` set). */
510
- parts: Array<{
511
- kind: "and" | "or";
512
- left: string;
513
- right?: string;
514
- value?: { v: unknown };
515
- }>;
550
+ /** Accumulated `ON` parts — column-to-column, column-to-value, IN, BETWEEN, NULL, EXISTS. */
551
+ parts: JoinPartMQ[];
516
552
  on(left: string, right: string): JoinBuilder;
553
+ on(left: string, operator: string, right: string): JoinBuilder;
517
554
  andOn(left: string, right: string): JoinBuilder;
555
+ andOn(left: string, operator: string, right: string): JoinBuilder;
518
556
  orOn(left: string, right: string): JoinBuilder;
557
+ orOn(left: string, operator: string, right: string): JoinBuilder;
519
558
  onVal(left: string, value: unknown): JoinBuilder;
520
559
  andOnVal(left: string, value: unknown): JoinBuilder;
521
560
  orOnVal(left: string, value: unknown): JoinBuilder;
561
+ /** `ON col IN (?, ?)` (Lucid/Knex `onIn`). */
562
+ onIn(left: string, values: unknown[]): JoinBuilder;
563
+ /** `ON col NOT IN (?, ?)` (Lucid/Knex `onNotIn`). */
564
+ onNotIn(left: string, values: unknown[]): JoinBuilder;
565
+ /** `ON col IS NULL` (Lucid/Knex `onNull`). */
566
+ onNull(left: string): JoinBuilder;
567
+ /** `ON col IS NOT NULL` (Lucid/Knex `onNotNull`). */
568
+ onNotNull(left: string): JoinBuilder;
569
+ /** `ON col BETWEEN ? AND ?` — inclusive (Lucid/Knex `onBetween`). */
570
+ onBetween(left: string, range: readonly [unknown, unknown]): JoinBuilder;
571
+ /** `ON col NOT BETWEEN ? AND ?` (Lucid/Knex `onNotBetween`). */
572
+ onNotBetween(left: string, range: readonly [unknown, unknown]): JoinBuilder;
573
+ /** `ON EXISTS (subquery)` — a builder or a callback (Lucid/Knex `onExists`). */
574
+ onExists(subquery: UnionArg): JoinBuilder;
575
+ /** `ON NOT EXISTS (subquery)` (Lucid/Knex `onNotExists`). */
576
+ onNotExists(subquery: UnionArg): JoinBuilder;
522
577
  }
523
578
 
524
579
  /** Offset-based paginator (Story 29.10). */
@@ -698,10 +753,27 @@ export class ModelQuery<T extends BaseEntity> {
698
753
  #orderBys: OrderByEntry[] = [];
699
754
  #select: string[] = ["*"];
700
755
  #limit?: number;
756
+ /** Top-N-per-parent limit for a has-many preload (Lucid `groupLimit`). */
757
+ #groupLimit?: number;
758
+ /** ORDER BY for the per-parent window (Lucid `groupOrderBy`). */
759
+ #groupOrderBy?: { column: string; direction: "asc" | "desc" };
701
760
  #offset?: number;
702
761
  #preloads = new Map<string, PreloadCallback | undefined>();
762
+ // Per-query row transformers (Adonis Lucid `rowTransformer`). Stored via a
763
+ // method-signature wrapper (not a bare `(row: T) => void` property) so T stays
764
+ // in a bivariant position — a function-typed property would force ModelQuery<T>
765
+ // invariant and break the `this: ModelClass<T>` bound on every static finder.
766
+ #rowTransformers: Array<{ run(row: T): void }> = [];
703
767
  /** Correlated subquery projections (withCount / withAggregate). */
704
768
  #selectSubqueries: SubqueryProjection[] = [];
769
+ /** Raw / subquery SELECT fragments carrying their own params (Lucid `select(raw)`, `select(subquery.as())`). */
770
+ #selectRaw: Array<{ sql: string; params: unknown[] }> = [];
771
+ /** Caller-facing statement timeout in ms (Lucid `timeout(ms)`), applied via a race in exec. */
772
+ #timeoutMs?: number;
773
+ /** `timeout(ms, { cancel: true })` — also apply a SERVER-side statement timeout. */
774
+ #cancelTimeout = false;
775
+ /** Columns from a chainable `.returning(...)` on a lazy DML builder. */
776
+ #dmlReturning: string[] = [];
705
777
  /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
706
778
  #subqueryAlias?: string;
707
779
  /** Raw JOIN fragments — Story 29.4. */
@@ -719,6 +791,10 @@ export class ModelQuery<T extends BaseEntity> {
719
791
  #sideloaded: Record<string, unknown> | null = null;
720
792
  /** Per-query debug flag — Story 29.11. */
721
793
  #debugFlag = false;
794
+ /** Metadata attached to the db:query event (Adonis Lucid `reporterData`). */
795
+ #reporterData?: Record<string, unknown>;
796
+ /** SQL comments prepended to the compiled query (Lucid/Knex `comment`). */
797
+ #comments: string[] = [];
722
798
  /** Distinct flag — Story 29.5. */
723
799
  #distinct = false;
724
800
  #distinctOn: string[] = [];
@@ -732,6 +808,7 @@ export class ModelQuery<T extends BaseEntity> {
732
808
  query: ModelQuery<BaseEntity>;
733
809
  recursive?: boolean;
734
810
  materialized?: boolean;
811
+ columns?: string[];
735
812
  }> = [];
736
813
  /** UNION / UNION ALL branches (Lucid parity). */
737
814
  #unions: Array<{
@@ -747,6 +824,8 @@ export class ModelQuery<T extends BaseEntity> {
747
824
  /** AND/OR within the parenthesised pivot-filter group — see `#runInQuery`. */
748
825
  type: "and" | "or";
749
826
  }> = [];
827
+ /** Extra pivot columns requested at query time (Lucid `pivotColumns([...])`). */
828
+ #extraPivotColumns: string[] = [];
750
829
  /**
751
830
  * Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
752
831
  * the relation proxy's scoped query; invoked at `#buildSpec()` time with the
@@ -803,6 +882,27 @@ export class ModelQuery<T extends BaseEntity> {
803
882
  return this;
804
883
  }
805
884
 
885
+ /**
886
+ * Apply the first `[guard, callback]` whose guard is truthy (Adonis Lucid
887
+ * `match`); a trailing bare callback is the default, run when none matched.
888
+ */
889
+ match(
890
+ ...blocks: Array<[unknown, (query: this) => void] | ((query: this) => void)>
891
+ ): this {
892
+ for (const block of blocks) {
893
+ if (typeof block === "function") {
894
+ block(this);
895
+ return this;
896
+ }
897
+ const [guard, callback] = block;
898
+ if (guard) {
899
+ callback(this);
900
+ return this;
901
+ }
902
+ }
903
+ return this;
904
+ }
905
+
806
906
  /**
807
907
  * Eager-load a relation (AdonisJS-style).
808
908
  * Relations are never loaded automatically — you must call .preload() explicitly.
@@ -816,15 +916,90 @@ export class ModelQuery<T extends BaseEntity> {
816
916
  return this;
817
917
  }
818
918
 
819
- /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
820
- select(columns: string | string[]): this {
821
- const list = Array.isArray(columns)
822
- ? columns
823
- : columns.split(",").map((c) => c.trim());
824
- this.#select = list.map((c) => this.#resolveSelect(c));
919
+ /**
920
+ * Eager-load a relation only if it hasn't been registered yet (Adonis Lucid
921
+ * `preloadOnce`) a later `preload`/`preloadOnce` for the same relation is
922
+ * ignored, so a shared scope can safely add a preload without clobbering one
923
+ * the caller already set.
924
+ */
925
+ preloadOnce(relationName: string, callback?: PreloadCallback): this {
926
+ if (!this.#preloads.has(relationName)) {
927
+ this.#preloads.set(relationName, callback);
928
+ }
825
929
  return this;
826
930
  }
827
931
 
932
+ /**
933
+ * Register a callback run for every hydrated instance after loading (preloads
934
+ * included) but before the query resolves (Adonis Lucid `rowTransformer`). The
935
+ * callback mutates the instance in place — decorate rows with per-query
936
+ * computed values without a model hook/accessor. Multiple transformers run in
937
+ * registration order.
938
+ */
939
+ rowTransformer(callback: (row: T) => void): this {
940
+ this.#rowTransformers.push({ run: callback });
941
+ return this;
942
+ }
943
+
944
+ /**
945
+ * Select columns (default: `*`). The model query builder extends the database
946
+ * query builder's `select` surface (Lucid parity): bare names, multiple args,
947
+ * a comma-separated string, an array, an `{ alias: 'column' }` object map, a
948
+ * `db.raw(...)` fragment, and a named subquery — `select(subquery.as('x'))`.
949
+ * Plain columns are resolved to their DB column (honouring `@Column`).
950
+ */
951
+ select(
952
+ ...columns: Array<
953
+ | string
954
+ | string[]
955
+ | Record<string, string>
956
+ | RawSql
957
+ | ModelQuery<BaseEntity>
958
+ >
959
+ ): this {
960
+ const plain: string[] = [];
961
+ for (const col of columns) {
962
+ if (typeof col === "string") {
963
+ plain.push(...col.split(",").map((c) => c.trim()));
964
+ } else if (Array.isArray(col)) {
965
+ plain.push(...col);
966
+ } else if (col instanceof RawSql) {
967
+ // Lucid `select(db.raw(sql, bindings))` — verbatim fragment + params.
968
+ this.#selectRaw.push({ sql: col.sql, params: [...col.params] });
969
+ } else if (col instanceof ModelQuery) {
970
+ // Lucid `select(subquery.as('alias'))` — a correlated subquery column.
971
+ const alias = col.#subqueryAlias;
972
+ if (!alias) {
973
+ throw new Error(
974
+ "select(subquery) requires the subquery to be named with .as('alias')",
975
+ );
976
+ }
977
+ const { sql, params } = col.#compiledNative();
978
+ this.#selectRaw.push({
979
+ sql: `(${sql}) AS ${this.#quoteAliasName(alias)}`,
980
+ params,
981
+ });
982
+ } else {
983
+ for (const [alias, expr] of Object.entries(col)) {
984
+ plain.push(`${expr} AS ${alias}`);
985
+ }
986
+ }
987
+ }
988
+ if (plain.length > 0) {
989
+ this.#select = plain.map((c) => this.#resolveSelect(c));
990
+ }
991
+ return this;
992
+ }
993
+
994
+ /** Validate + dialect-quote a bare alias identifier. */
995
+ #quoteAliasName(alias: string): string {
996
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
997
+ throw new Error(`Invalid alias '${alias}' — expected a bare identifier.`);
998
+ }
999
+ const q = this.#dialect === "mysql" ? "`" : '"';
1000
+ return `${q}${alias}${q}`;
1001
+ }
1002
+
828
1003
  /**
829
1004
  * Resolve a bare model-property select/returning target to its DB column
830
1005
  * (honouring `@Column({ columnName })`), leaving expressions / aliases /
@@ -968,37 +1143,100 @@ export class ModelQuery<T extends BaseEntity> {
968
1143
  return this.whereColumn(left, operator, right);
969
1144
  }
970
1145
 
971
- /** `WHERE col != ?` — negation of `where`. */
972
- whereNot(column: string, value: unknown): this {
1146
+ /**
1147
+ * Negated WHERE (Lucid/Knex `whereNot`) the same forms as {@link where}: a
1148
+ * `(column, [operator,] value)` comparison, an object (`whereNot({ a: 1 })` →
1149
+ * `a != 1`), or a callback group (`whereNot((q) => …)` → `NOT (…)`).
1150
+ */
1151
+ whereNot(callback: WhereCallback): this;
1152
+ whereNot(conditions: Record<string, unknown>): this;
1153
+ whereNot(column: string, value: unknown): this;
1154
+ whereNot(column: string, operator: string, value: unknown): this;
1155
+ whereNot(
1156
+ columnOrCbOrObj: string | WhereCallback | Record<string, unknown>,
1157
+ operatorOrValue?: unknown,
1158
+ value?: unknown,
1159
+ ): this {
1160
+ if (typeof columnOrCbOrObj === "function") {
1161
+ this.#wheres.push({
1162
+ ...this.#buildGroup("and", columnOrCbOrObj),
1163
+ negated: true,
1164
+ });
1165
+ return this;
1166
+ }
1167
+ if (typeof columnOrCbOrObj === "object") {
1168
+ for (const [col, val] of Object.entries(columnOrCbOrObj)) {
1169
+ this.#wheres.push({
1170
+ type: "and",
1171
+ column: this.#resolveColumn(col),
1172
+ operator: "!=",
1173
+ value: this.#prep(col, val),
1174
+ });
1175
+ }
1176
+ return this;
1177
+ }
1178
+ if (value === undefined) {
1179
+ this.#wheres.push({
1180
+ type: "and",
1181
+ column: this.#resolveColumn(columnOrCbOrObj),
1182
+ operator: "!=",
1183
+ value: this.#prep(columnOrCbOrObj, operatorOrValue),
1184
+ });
1185
+ return this;
1186
+ }
1187
+ // (column, operator, value): NOT (col <op> value) via a negated group so any
1188
+ // operator negates correctly without an operator-inversion table.
1189
+ const col = columnOrCbOrObj;
1190
+ const op = operatorOrValue;
973
1191
  this.#wheres.push({
974
- type: "and",
975
- column: this.#resolveColumn(column),
976
- operator: "!=",
977
- value: this.#prep(column, value),
1192
+ ...this.#buildGroup("and", (q) => q.where(col, op as string, value)),
1193
+ negated: true,
978
1194
  });
979
1195
  return this;
980
1196
  }
981
1197
 
982
1198
  /** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
1199
+ whereIn(columns: string[], rows: unknown[][]): this;
983
1200
  whereIn(
984
1201
  column: string,
985
1202
  source: readonly unknown[] | ModelQuery<BaseEntity>,
1203
+ ): this;
1204
+ whereIn(
1205
+ columnOrColumns: string | string[],
1206
+ source: readonly unknown[] | unknown[][] | ModelQuery<BaseEntity>,
986
1207
  ): this {
1208
+ // Tuple form (Lucid `whereIn(['id','email'], [[1,'a@b.com']])`).
1209
+ if (Array.isArray(columnOrColumns)) {
1210
+ const cols = columnOrColumns.map((c) => this.#resolveColumn(c));
1211
+ const rows = (Array.isArray(source) ? source : []).map((r) =>
1212
+ (Array.isArray(r) ? r : [r]).map((v, j) =>
1213
+ this.#prep(cols[j] ?? "", v),
1214
+ ),
1215
+ );
1216
+ this.#wheres.push({
1217
+ type: "and",
1218
+ kind: "inTuple",
1219
+ columns: cols,
1220
+ rows,
1221
+ negated: false,
1222
+ });
1223
+ return this;
1224
+ }
987
1225
  if (source instanceof ModelQuery) {
988
1226
  this.#wheres.push({
989
1227
  type: "and",
990
1228
  kind: "inSub",
991
1229
  negated: false,
992
- column: this.#resolveColumn(column),
1230
+ column: this.#resolveColumn(columnOrColumns),
993
1231
  subquery: source.#buildSpec(),
994
1232
  });
995
1233
  return this;
996
1234
  }
997
1235
  this.#wheres.push({
998
1236
  type: "and",
999
- column: this.#resolveColumn(column),
1237
+ column: this.#resolveColumn(columnOrColumns),
1000
1238
  operator: "IN",
1001
- value: this.#prep(column, [...source]),
1239
+ value: this.#prep(columnOrColumns, [...source]),
1002
1240
  });
1003
1241
  return this;
1004
1242
  }
@@ -1464,33 +1702,105 @@ export class ModelQuery<T extends BaseEntity> {
1464
1702
  *
1465
1703
  * query.whereJsonPath('data', '$.address.city', '=', 'Paris')
1466
1704
  */
1705
+ whereJsonPath(column: string, path: string, value: unknown): this;
1467
1706
  whereJsonPath(
1468
1707
  column: string,
1469
1708
  path: string,
1470
1709
  operator: string,
1471
1710
  value: unknown,
1711
+ ): this;
1712
+ whereJsonPath(
1713
+ column: string,
1714
+ path: string,
1715
+ operatorOrValue: unknown,
1716
+ value?: unknown,
1472
1717
  ): this {
1473
- return this.#pushJson("and", false, "path", column, value, path, operator);
1718
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1474
1719
  }
1475
1720
 
1476
- /** Alias of {@link whereJsonPath} (Lucid parity). */
1721
+ /** Alias of {@link whereJsonPath} (Lucid parity). Operator defaults to `=`. */
1722
+ andWhereJsonPath(column: string, path: string, value: unknown): this;
1477
1723
  andWhereJsonPath(
1478
1724
  column: string,
1479
1725
  path: string,
1480
1726
  operator: string,
1481
1727
  value: unknown,
1728
+ ): this;
1729
+ andWhereJsonPath(
1730
+ column: string,
1731
+ path: string,
1732
+ operatorOrValue: unknown,
1733
+ value?: unknown,
1482
1734
  ): this {
1483
- return this.#pushJson("and", false, "path", column, value, path, operator);
1735
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1484
1736
  }
1485
1737
 
1486
- /** `OR <col at path> <op> ?` (Lucid parity). */
1738
+ /** `OR <col at path> <op> ?` (Lucid parity). Operator defaults to `=`. */
1739
+ orWhereJsonPath(column: string, path: string, value: unknown): this;
1487
1740
  orWhereJsonPath(
1488
1741
  column: string,
1489
1742
  path: string,
1490
1743
  operator: string,
1491
1744
  value: unknown,
1745
+ ): this;
1746
+ orWhereJsonPath(
1747
+ column: string,
1748
+ path: string,
1749
+ operatorOrValue: unknown,
1750
+ value?: unknown,
1751
+ ): this {
1752
+ return this.#pushJsonPathArgs("or", column, path, operatorOrValue, value);
1753
+ }
1754
+
1755
+ /** Resolve the optional-operator JSONPath form, then push (default op `=`). */
1756
+ #pushJsonPathArgs(
1757
+ type: "and" | "or",
1758
+ column: string,
1759
+ path: string,
1760
+ operatorOrValue: unknown,
1761
+ value?: unknown,
1492
1762
  ): this {
1493
- return this.#pushJson("or", false, "path", column, value, path, operator);
1763
+ const [operator, val] =
1764
+ value === undefined
1765
+ ? ["=", operatorOrValue]
1766
+ : [String(operatorOrValue), value];
1767
+ return this.#pushJson(
1768
+ type,
1769
+ false,
1770
+ "path",
1771
+ column,
1772
+ val,
1773
+ path,
1774
+ String(operator),
1775
+ );
1776
+ }
1777
+
1778
+ /**
1779
+ * Structural JSON match (Lucid `whereJson`) — the column's JSON must equal
1780
+ * `value` (canonical comparison on Postgres/MySQL). AND is the default.
1781
+ */
1782
+ whereJson(column: string, value: unknown): this {
1783
+ return this.#pushJson("and", false, "equals", column, value);
1784
+ }
1785
+ /** Alias of {@link whereJson} (Lucid `andWhereJson`). */
1786
+ andWhereJson(column: string, value: unknown): this {
1787
+ return this.#pushJson("and", false, "equals", column, value);
1788
+ }
1789
+ /** OR form of {@link whereJson} (Lucid `orWhereJson`). */
1790
+ orWhereJson(column: string, value: unknown): this {
1791
+ return this.#pushJson("or", false, "equals", column, value);
1792
+ }
1793
+ /** WHERE NOT structural JSON match (Lucid `whereNotJson`). */
1794
+ whereNotJson(column: string, value: unknown): this {
1795
+ return this.#pushJson("and", true, "equals", column, value);
1796
+ }
1797
+ /** Alias of {@link whereNotJson} (Lucid `andWhereNotJson`). */
1798
+ andWhereNotJson(column: string, value: unknown): this {
1799
+ return this.#pushJson("and", true, "equals", column, value);
1800
+ }
1801
+ /** OR NOT structural JSON match (Lucid `orWhereNotJson`). */
1802
+ orWhereNotJson(column: string, value: unknown): this {
1803
+ return this.#pushJson("or", true, "equals", column, value);
1494
1804
  }
1495
1805
 
1496
1806
  /**
@@ -1552,10 +1862,52 @@ export class ModelQuery<T extends BaseEntity> {
1552
1862
  return this.#pushJson("or", true, "subset", column, value);
1553
1863
  }
1554
1864
 
1865
+ // Lucid's canonical containment names drop the `Of` suffix — kept as aliases.
1866
+ /** Lucid `whereJsonSuperset` (alias of {@link whereJsonSupersetOf}). */
1867
+ whereJsonSuperset(column: string, value: unknown): this {
1868
+ return this.#pushJson("and", false, "superset", column, value);
1869
+ }
1870
+ /** Lucid `andWhereJsonSuperset`. */
1871
+ andWhereJsonSuperset(column: string, value: unknown): this {
1872
+ return this.#pushJson("and", false, "superset", column, value);
1873
+ }
1874
+ /** Lucid `orWhereJsonSuperset`. */
1875
+ orWhereJsonSuperset(column: string, value: unknown): this {
1876
+ return this.#pushJson("or", false, "superset", column, value);
1877
+ }
1878
+ /** Lucid `whereNotJsonSuperset`. */
1879
+ whereNotJsonSuperset(column: string, value: unknown): this {
1880
+ return this.#pushJson("and", true, "superset", column, value);
1881
+ }
1882
+ /** Lucid `orWhereNotJsonSuperset`. */
1883
+ orWhereNotJsonSuperset(column: string, value: unknown): this {
1884
+ return this.#pushJson("or", true, "superset", column, value);
1885
+ }
1886
+ /** Lucid `whereJsonSubset` (alias of {@link whereJsonSubsetOf}). */
1887
+ whereJsonSubset(column: string, value: unknown): this {
1888
+ return this.#pushJson("and", false, "subset", column, value);
1889
+ }
1890
+ /** Lucid `andWhereJsonSubset`. */
1891
+ andWhereJsonSubset(column: string, value: unknown): this {
1892
+ return this.#pushJson("and", false, "subset", column, value);
1893
+ }
1894
+ /** Lucid `orWhereJsonSubset`. */
1895
+ orWhereJsonSubset(column: string, value: unknown): this {
1896
+ return this.#pushJson("or", false, "subset", column, value);
1897
+ }
1898
+ /** Lucid `whereNotJsonSubset`. */
1899
+ whereNotJsonSubset(column: string, value: unknown): this {
1900
+ return this.#pushJson("and", true, "subset", column, value);
1901
+ }
1902
+ /** Lucid `orWhereNotJsonSubset`. */
1903
+ orWhereNotJsonSubset(column: string, value: unknown): this {
1904
+ return this.#pushJson("or", true, "subset", column, value);
1905
+ }
1906
+
1555
1907
  #pushJson(
1556
1908
  type: "and" | "or",
1557
1909
  negated: boolean,
1558
- jsonOp: "path" | "superset" | "subset",
1910
+ jsonOp: "path" | "superset" | "subset" | "equals",
1559
1911
  column: string,
1560
1912
  value: unknown,
1561
1913
  path?: string,
@@ -1953,8 +2305,41 @@ export class ModelQuery<T extends BaseEntity> {
1953
2305
  return this;
1954
2306
  }
1955
2307
 
1956
- orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
1957
- this.#orderBys.push({ column: this.#resolveColumn(column), direction });
2308
+ /** ORDER BY a column, a `db.ref()` fragment, or an array of terms (Lucid/Knex `orderBy`). */
2309
+ orderBy(column: string | RawSql, direction?: "asc" | "desc"): this;
2310
+ orderBy(
2311
+ terms: Array<string | { column: string; order?: "asc" | "desc" }>,
2312
+ ): this;
2313
+ orderBy(
2314
+ columnOrTerms:
2315
+ | string
2316
+ | RawSql
2317
+ | Array<string | { column: string; order?: "asc" | "desc" }>,
2318
+ direction: "asc" | "desc" = "asc",
2319
+ ): this {
2320
+ // A raw fragment (e.g. `db.ref('posts.created_at')`) orders verbatim.
2321
+ if (columnOrTerms instanceof RawSql) {
2322
+ const dir = direction === "desc" ? "DESC" : "ASC";
2323
+ this.#orderBys.push({ raw: `${columnOrTerms.sql} ${dir}` });
2324
+ return this;
2325
+ }
2326
+ if (Array.isArray(columnOrTerms)) {
2327
+ for (const t of columnOrTerms) {
2328
+ const [col, dir] =
2329
+ typeof t === "string"
2330
+ ? [t, "asc" as const]
2331
+ : [t.column, t.order ?? "asc"];
2332
+ this.#orderBys.push({
2333
+ column: this.#resolveColumn(col),
2334
+ direction: dir,
2335
+ });
2336
+ }
2337
+ return this;
2338
+ }
2339
+ this.#orderBys.push({
2340
+ column: this.#resolveColumn(columnOrTerms),
2341
+ direction,
2342
+ });
1958
2343
  return this;
1959
2344
  }
1960
2345
 
@@ -2022,6 +2407,47 @@ export class ModelQuery<T extends BaseEntity> {
2022
2407
  return this;
2023
2408
  }
2024
2409
 
2410
+ /** HAVING col IS NULL (Lucid/Knex `havingNull`). */
2411
+ havingNull(column: string): this {
2412
+ return this.#pushHaving(column, "IS NULL", null);
2413
+ }
2414
+
2415
+ /** HAVING col IS NOT NULL (Lucid/Knex `havingNotNull`). */
2416
+ havingNotNull(column: string): this {
2417
+ return this.#pushHaving(column, "IS NOT NULL", null);
2418
+ }
2419
+
2420
+ /** HAVING col IN (...) (Lucid/Knex `havingIn`). */
2421
+ havingIn(column: string, values: unknown[]): this {
2422
+ return this.#pushHaving(column, "IN", [...values]);
2423
+ }
2424
+
2425
+ /** HAVING col NOT IN (...) (Lucid/Knex `havingNotIn`). */
2426
+ havingNotIn(column: string, values: unknown[]): this {
2427
+ return this.#pushHaving(column, "NOT IN", [...values]);
2428
+ }
2429
+
2430
+ /** HAVING col BETWEEN ? AND ? (Lucid/Knex `havingBetween`). */
2431
+ havingBetween(column: string, range: readonly [unknown, unknown]): this {
2432
+ return this.#pushHaving(column, "BETWEEN", [...range]);
2433
+ }
2434
+
2435
+ /** HAVING col NOT BETWEEN ? AND ? (Lucid/Knex `havingNotBetween`). */
2436
+ havingNotBetween(column: string, range: readonly [unknown, unknown]): this {
2437
+ return this.#pushHaving(column, "NOT BETWEEN", [...range]);
2438
+ }
2439
+
2440
+ /** Push a HAVING entry with a raw (non-`#prep`'d) value — for IN/BETWEEN/NULL. */
2441
+ #pushHaving(column: string, operator: string, value: unknown): this {
2442
+ this.#having.push({
2443
+ column: this.#resolveHavingCol(column),
2444
+ operator,
2445
+ value,
2446
+ type: "and",
2447
+ });
2448
+ return this;
2449
+ }
2450
+
2025
2451
  /**
2026
2452
  * Resolve a HAVING column: a bare model property maps to its DB column
2027
2453
  * (honouring `@Column({ columnName })`), but an aggregate expression
@@ -2079,20 +2505,24 @@ export class ModelQuery<T extends BaseEntity> {
2079
2505
  * appended as a parenthesised UNION branch; its bindings are re-indexed into
2080
2506
  * the outer parameter list.
2081
2507
  */
2082
- union(query: ModelQuery<BaseEntity>): this {
2083
- this.#unions.push({ query, all: false });
2508
+ union(query: UnionArg): this {
2509
+ this.#unions.push({ query: this.#resolveUnion(query), all: false });
2084
2510
  return this;
2085
2511
  }
2086
2512
 
2087
2513
  /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
2088
- unionAll(query: ModelQuery<BaseEntity>): this {
2089
- this.#unions.push({ query, all: true });
2514
+ unionAll(query: UnionArg): this {
2515
+ this.#unions.push({ query: this.#resolveUnion(query), all: true });
2090
2516
  return this;
2091
2517
  }
2092
2518
 
2093
2519
  /** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
2094
- intersect(query: ModelQuery<BaseEntity>): this {
2095
- this.#unions.push({ query, all: false, op: "intersect" });
2520
+ intersect(query: UnionArg): this {
2521
+ this.#unions.push({
2522
+ query: this.#resolveUnion(query),
2523
+ all: false,
2524
+ op: "intersect",
2525
+ });
2096
2526
  return this;
2097
2527
  }
2098
2528
 
@@ -2103,29 +2533,64 @@ export class ModelQuery<T extends BaseEntity> {
2103
2533
  * INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
2104
2534
  * `E_UNSUPPORTED` there rather than emitting a syntax error.
2105
2535
  */
2106
- intersectAll(query: ModelQuery<BaseEntity>): this {
2107
- this.#unions.push({ query, all: true, op: "intersect" });
2536
+ intersectAll(query: UnionArg): this {
2537
+ this.#unions.push({
2538
+ query: this.#resolveUnion(query),
2539
+ all: true,
2540
+ op: "intersect",
2541
+ });
2108
2542
  return this;
2109
2543
  }
2110
2544
 
2111
2545
  /** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
2112
- except(query: ModelQuery<BaseEntity>): this {
2113
- this.#unions.push({ query, all: false, op: "except" });
2546
+ except(query: UnionArg): this {
2547
+ this.#unions.push({
2548
+ query: this.#resolveUnion(query),
2549
+ all: false,
2550
+ op: "except",
2551
+ });
2114
2552
  return this;
2115
2553
  }
2116
2554
 
2117
2555
  /** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
2118
- exceptAll(query: ModelQuery<BaseEntity>): this {
2119
- this.#unions.push({ query, all: true, op: "except" });
2556
+ exceptAll(query: UnionArg): this {
2557
+ this.#unions.push({
2558
+ query: this.#resolveUnion(query),
2559
+ all: true,
2560
+ op: "except",
2561
+ });
2120
2562
  return this;
2121
2563
  }
2122
2564
 
2565
+ /** A compound-query arg — an explicit ModelQuery OR a callback building one on this model. */
2566
+ #resolveUnion(query: UnionArg): ModelQuery<BaseEntity> {
2567
+ if (typeof query !== "function") return query;
2568
+ const sub = this.#freshQuery();
2569
+ query(sub);
2570
+ return sub;
2571
+ }
2572
+
2573
+ /** A fresh, empty query on the SAME model + connection (for compound callbacks). */
2574
+ #freshQuery(): ModelQuery<BaseEntity> {
2575
+ return new ModelQuery<BaseEntity>(
2576
+ this.#tableName,
2577
+ this.#db,
2578
+ this.#hydrateFn,
2579
+ this.#entityClass,
2580
+ this.#resolveColumn,
2581
+ this.#softDeletes,
2582
+ this.#dialect,
2583
+ this.#prepareValue,
2584
+ this.#onDomainEvents,
2585
+ );
2586
+ }
2587
+
2123
2588
  /**
2124
2589
  * `WITH <name> AS (<query>)` — register a Common Table Expression
2125
2590
  * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
2126
2591
  * sub-query is compiled and its bindings are re-indexed into the outer list.
2127
2592
  */
2128
- with(name: string, query: ModelQuery<BaseEntity>): this {
2593
+ with(name: string, query: UnionArg): this {
2129
2594
  return this.#pushCte("with", name, query, {});
2130
2595
  }
2131
2596
 
@@ -2140,8 +2605,11 @@ export class ModelQuery<T extends BaseEntity> {
2140
2605
  * The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
2141
2606
  * an anchor `SELECT` unioned with a select that references `<name>`.
2142
2607
  */
2143
- withRecursive(name: string, query: ModelQuery<BaseEntity>): this {
2144
- return this.#pushCte("withRecursive", name, query, { recursive: true });
2608
+ withRecursive(name: string, query: UnionArg, columns?: string[]): this {
2609
+ return this.#pushCte("withRecursive", name, query, {
2610
+ recursive: true,
2611
+ columns,
2612
+ });
2145
2613
  }
2146
2614
 
2147
2615
  /**
@@ -2151,14 +2619,14 @@ export class ModelQuery<T extends BaseEntity> {
2151
2619
  * Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
2152
2620
  * compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
2153
2621
  */
2154
- withMaterialized(name: string, query: ModelQuery<BaseEntity>): this {
2622
+ withMaterialized(name: string, query: UnionArg): this {
2155
2623
  return this.#pushCte("withMaterialized", name, query, {
2156
2624
  materialized: true,
2157
2625
  });
2158
2626
  }
2159
2627
 
2160
2628
  /** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
2161
- withNotMaterialized(name: string, query: ModelQuery<BaseEntity>): this {
2629
+ withNotMaterialized(name: string, query: UnionArg): this {
2162
2630
  return this.#pushCte("withNotMaterialized", name, query, {
2163
2631
  materialized: false,
2164
2632
  });
@@ -2167,15 +2635,31 @@ export class ModelQuery<T extends BaseEntity> {
2167
2635
  #pushCte(
2168
2636
  method: string,
2169
2637
  name: string,
2170
- query: ModelQuery<BaseEntity>,
2171
- options: { recursive?: boolean; materialized?: boolean },
2638
+ query: UnionArg,
2639
+ options: {
2640
+ recursive?: boolean;
2641
+ materialized?: boolean;
2642
+ columns?: string[];
2643
+ },
2172
2644
  ): this {
2173
2645
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
2174
2646
  throw new Error(
2175
2647
  `${method}(): CTE name '${name}' is not a valid identifier`,
2176
2648
  );
2177
2649
  }
2178
- this.#ctes.push({ name, query, ...options });
2650
+ this.#ctes.push({ name, query: this.#resolveUnion(query), ...options });
2651
+ return this;
2652
+ }
2653
+
2654
+ /**
2655
+ * `@ManyToMany` only — project EXTRA pivot columns at query time (AdonisJS/Lucid
2656
+ * `pivotColumns([...])`), on top of any declared in the relation decorator. Read
2657
+ * off each loaded relation as `$extras.pivot_<col>`. Inert on non-m2m relations.
2658
+ *
2659
+ * userRepo.query().preload('skills', q => q.pivotColumns(['notes']))
2660
+ */
2661
+ pivotColumns(columns: string[]): this {
2662
+ this.#extraPivotColumns.push(...columns);
2179
2663
  return this;
2180
2664
  }
2181
2665
 
@@ -2241,19 +2725,63 @@ export class ModelQuery<T extends BaseEntity> {
2241
2725
  return this.whereInPivot(column, values);
2242
2726
  }
2243
2727
 
2244
- /** `@ManyToMany` only — `WHERE <pivotCol> != <value>` on the pivot table (AdonisJS Lucid `whereNotPivot`). */
2245
- whereNotPivot(column: string, value: unknown): this {
2246
- return this.#pushPivotOp("and", column, "!=", value);
2728
+ /**
2729
+ * `@ManyToMany` only negated pivot filter (AdonisJS Lucid `whereNotPivot`).
2730
+ * `whereNotPivot(col, value)` → `col != value`; `whereNotPivot(col, op, value)`
2731
+ * → the negated comparison (`whereNotPivot('proficiency', '>=', 4)` → `< 4`).
2732
+ */
2733
+ whereNotPivot(column: string, value: unknown): this;
2734
+ whereNotPivot(column: string, operator: string, value: unknown): this;
2735
+ whereNotPivot(
2736
+ column: string,
2737
+ operatorOrValue: unknown,
2738
+ value?: unknown,
2739
+ ): this {
2740
+ if (value === undefined) {
2741
+ return this.#pushPivotOp("and", column, "!=", operatorOrValue);
2742
+ }
2743
+ return this.#pushPivotOp(
2744
+ "and",
2745
+ column,
2746
+ negateOperator(String(operatorOrValue)),
2747
+ value,
2748
+ );
2247
2749
  }
2248
2750
 
2249
- /** Alias of {@link whereNotPivot} (Lucid parity). */
2250
- andWhereNotPivot(column: string, value: unknown): this {
2251
- return this.#pushPivotOp("and", column, "!=", value);
2751
+ /** Alias of {@link whereNotPivot} (Lucid parity). Operator form supported too. */
2752
+ andWhereNotPivot(column: string, value: unknown): this;
2753
+ andWhereNotPivot(column: string, operator: string, value: unknown): this;
2754
+ andWhereNotPivot(
2755
+ column: string,
2756
+ operatorOrValue: unknown,
2757
+ value?: unknown,
2758
+ ): this {
2759
+ return value === undefined
2760
+ ? this.#pushPivotOp("and", column, "!=", operatorOrValue)
2761
+ : this.#pushPivotOp(
2762
+ "and",
2763
+ column,
2764
+ negateOperator(String(operatorOrValue)),
2765
+ value,
2766
+ );
2252
2767
  }
2253
2768
 
2254
2769
  /** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
2255
- orWhereNotPivot(column: string, value: unknown): this {
2256
- return this.#pushPivotOp("or", column, "!=", value);
2770
+ orWhereNotPivot(column: string, value: unknown): this;
2771
+ orWhereNotPivot(column: string, operator: string, value: unknown): this;
2772
+ orWhereNotPivot(
2773
+ column: string,
2774
+ operatorOrValue: unknown,
2775
+ value?: unknown,
2776
+ ): this {
2777
+ return value === undefined
2778
+ ? this.#pushPivotOp("or", column, "!=", operatorOrValue)
2779
+ : this.#pushPivotOp(
2780
+ "or",
2781
+ column,
2782
+ negateOperator(String(operatorOrValue)),
2783
+ value,
2784
+ );
2257
2785
  }
2258
2786
 
2259
2787
  /** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
@@ -2333,6 +2861,11 @@ export class ModelQuery<T extends BaseEntity> {
2333
2861
  return this.#pivotWheres;
2334
2862
  }
2335
2863
 
2864
+ /** Read-only accessor for query-time pivot columns — consumed by the m2m preload resolver. */
2865
+ get pivotColumnRequests(): readonly string[] {
2866
+ return this.#extraPivotColumns;
2867
+ }
2868
+
2336
2869
  limit(n: number): this {
2337
2870
  // Guard here with a clear message — the Rust spec types limit as
2338
2871
  // u64, so a negative/non-integer otherwise surfaces as a cryptic
@@ -2345,6 +2878,26 @@ export class ModelQuery<T extends BaseEntity> {
2345
2878
  return this;
2346
2879
  }
2347
2880
 
2881
+ /**
2882
+ * In a has-many preload callback, cap the related rows PER PARENT (Lucid
2883
+ * `groupLimit`) — a plain `.limit()` caps the whole result set across parents.
2884
+ * Compiled with a `ROW_NUMBER() OVER (PARTITION BY <fk> …)` window. Pair with
2885
+ * {@link groupOrderBy} to pick which N per parent.
2886
+ */
2887
+ groupLimit(n: number): this {
2888
+ if (!Number.isInteger(n) || n < 0) {
2889
+ throw new Error(`groupLimit must be a non-negative integer, got ${n}`);
2890
+ }
2891
+ this.#groupLimit = n;
2892
+ return this;
2893
+ }
2894
+
2895
+ /** ORDER BY for the {@link groupLimit} per-parent window (Lucid `groupOrderBy`). */
2896
+ groupOrderBy(column: string, direction: "asc" | "desc" = "asc"): this {
2897
+ this.#groupOrderBy = { column, direction };
2898
+ return this;
2899
+ }
2900
+
2348
2901
  offset(n: number): this {
2349
2902
  if (!Number.isInteger(n) || n < 0) {
2350
2903
  throw new Error(`offset must be a non-negative integer, got ${n}`);
@@ -2520,6 +3073,7 @@ export class ModelQuery<T extends BaseEntity> {
2520
3073
  table: this.#tableName,
2521
3074
  select: selectCols,
2522
3075
  selectSubqueries: this.#selectSubqueries,
3076
+ selectRaw: this.#selectRaw,
2523
3077
  wheres,
2524
3078
  orderBy: this.#orderBys,
2525
3079
  groupBy: this.#groupBy,
@@ -2528,18 +3082,9 @@ export class ModelQuery<T extends BaseEntity> {
2528
3082
  offset: this.#offset ?? null,
2529
3083
  distinct: this.#distinct,
2530
3084
  distinctOn: this.#distinctOn,
2531
- ctes: this.#ctes.map((c) => {
2532
- const { sql, params } = c.query.toSQL();
2533
- return {
2534
- name: c.name,
2535
- sql,
2536
- params,
2537
- recursive: c.recursive ?? false,
2538
- materialized: c.materialized ?? null,
2539
- };
2540
- }),
3085
+ ctes: this.#compiledCtes(),
2541
3086
  unions: this.#unions.map((u) => {
2542
- const { sql, params } = u.query.toSQL();
3087
+ const { sql, params } = u.query.#compiledNative();
2543
3088
  return { sql, params, all: u.all, op: u.op ?? null };
2544
3089
  }),
2545
3090
  joins: this.#joins,
@@ -2551,10 +3096,133 @@ export class ModelQuery<T extends BaseEntity> {
2551
3096
  };
2552
3097
  }
2553
3098
 
2554
- /** Build SQL + params via the Rust query compiler. */
2555
- toSQL(): { sql: string; params: unknown[] } {
3099
+ /**
3100
+ * Native compiled `{ sql, params }` (Postgres `$N`) — the form atlas executes
3101
+ * and embeds inside parent queries. Public `toSQL()` normalizes to Knex `?`.
3102
+ */
3103
+ #compiledNative(): { sql: string; params: unknown[] } {
2556
3104
  const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
2557
- return { sql: compiled.statements[0], params: compiled.params };
3105
+ const sql = this.#commentPrefix() + compiled.statements[0];
3106
+ return { sql, params: compiled.params };
3107
+ }
3108
+
3109
+ /**
3110
+ * Build SQL via the Rust query compiler (Lucid `toSQL`). `.sql` uses `?`
3111
+ * placeholders (Knex-normalized, like Lucid); `.toNative()` yields the native
3112
+ * form. Returns `bindings` (Lucid's name) and `params` (atlas's) — same array.
3113
+ */
3114
+ toSQL(): CompiledStatement {
3115
+ const { sql, params } = this.#compiledNative();
3116
+ return compiledStatement(sql, params);
3117
+ }
3118
+
3119
+ /** `{ sql, bindings }` — the compiled native query (Lucid/Knex `toNative`). */
3120
+ toNative(): { sql: string; bindings: unknown[] } {
3121
+ const { sql, params } = this.#compiledNative();
3122
+ return { sql, bindings: params };
3123
+ }
3124
+
3125
+ /** The model class this query targets (Adonis Lucid `query.model`). */
3126
+ get model(): new () => T {
3127
+ return this.#entityClass;
3128
+ }
3129
+
3130
+ /** Apply `cb` only on the given dialect(s) (Lucid `ifDialect`; Lucid names accepted). */
3131
+ ifDialect(
3132
+ dialect: DialectName | DialectName[],
3133
+ cb: (query: this) => void,
3134
+ ): this {
3135
+ const set = (Array.isArray(dialect) ? dialect : [dialect]).map(
3136
+ normalizeDialect,
3137
+ );
3138
+ if (set.includes(this.#dialect)) cb(this);
3139
+ return this;
3140
+ }
3141
+
3142
+ /** Apply `cb` on every dialect EXCEPT the given one(s) (Lucid `unlessDialect`). */
3143
+ unlessDialect(
3144
+ dialect: DialectName | DialectName[],
3145
+ cb: (query: this) => void,
3146
+ ): this {
3147
+ const set = (Array.isArray(dialect) ? dialect : [dialect]).map(
3148
+ normalizeDialect,
3149
+ );
3150
+ if (!set.includes(this.#dialect)) cb(this);
3151
+ return this;
3152
+ }
3153
+
3154
+ /** Prepend a `/* … *​/` SQL comment to the compiled query (Lucid/Knex `comment`). */
3155
+ comment(text: string): this {
3156
+ if (text.includes("*/")) {
3157
+ throw new Error("comment() text may not contain '*/'");
3158
+ }
3159
+ this.#comments.push(text);
3160
+ return this;
3161
+ }
3162
+
3163
+ /**
3164
+ * Set a caller-facing statement timeout in milliseconds (Lucid `timeout(ms)`).
3165
+ * The awaiting promise rejects after `ms` on the primary result fetch. With
3166
+ * `{ cancel: true }` a SERVER-side statement timeout is also applied — Postgres
3167
+ * `statement_timeout`, MySQL `MAX_EXECUTION_TIME` (SELECT) — so the server
3168
+ * aborts the query, not just the client. (SQLite has no server timeout; the
3169
+ * client race applies.) Called with no argument it clears the timeout.
3170
+ */
3171
+ timeout(ms?: number, options?: { cancel?: boolean }): this {
3172
+ // `{ cancel: true }` also applies a SERVER-side statement timeout (Postgres
3173
+ // statement_timeout / MySQL MAX_EXECUTION_TIME for SELECT); SQLite has none,
3174
+ // so the client race applies there.
3175
+ this.#timeoutMs = ms;
3176
+ this.#cancelTimeout = options?.cancel === true;
3177
+ return this;
3178
+ }
3179
+
3180
+ /**
3181
+ * Race `work` against the configured `.timeout(ms)`. Rejects the awaiter after
3182
+ * `ms`; the losing DB promise is swallowed so a post-timeout driver error never
3183
+ * surfaces as an unhandled rejection. No timeout set → returns `work` as-is.
3184
+ */
3185
+ #raceTimeout<R>(work: Promise<R>): Promise<R> {
3186
+ const ms = this.#timeoutMs;
3187
+ if (!ms || ms <= 0) return work;
3188
+ let timer: ReturnType<typeof setTimeout> | undefined;
3189
+ const guard = new Promise<never>((_, reject) => {
3190
+ timer = setTimeout(
3191
+ () => reject(new Error(`Query timed out after ${ms}ms`)),
3192
+ ms,
3193
+ );
3194
+ });
3195
+ work.catch(() => {});
3196
+ return Promise.race([work, guard]).finally(() => clearTimeout(timer));
3197
+ }
3198
+
3199
+ /** The `/* … *​/` prefix for the compiled SQL, or empty when no comments. */
3200
+ /** Compile the registered CTEs to the wire shape — shared by SELECT and DML. */
3201
+ #compiledCtes(): Array<{
3202
+ name: string;
3203
+ sql: string;
3204
+ params: unknown[];
3205
+ recursive: boolean;
3206
+ materialized: boolean | null;
3207
+ columns: string[];
3208
+ }> {
3209
+ return this.#ctes.map((c) => {
3210
+ const { sql, params } = c.query.#compiledNative();
3211
+ return {
3212
+ name: c.name,
3213
+ sql,
3214
+ params,
3215
+ recursive: c.recursive ?? false,
3216
+ materialized: c.materialized ?? null,
3217
+ columns: c.columns ?? [],
3218
+ };
3219
+ });
3220
+ }
3221
+
3222
+ #commentPrefix(): string {
3223
+ return this.#comments.length > 0
3224
+ ? `${this.#comments.map((c) => `/* ${c} */`).join(" ")} `
3225
+ : "";
2558
3226
  }
2559
3227
 
2560
3228
  /**
@@ -2582,11 +3250,9 @@ export class ModelQuery<T extends BaseEntity> {
2582
3250
  }
2583
3251
 
2584
3252
  async #doExec(): Promise<T[]> {
2585
- const { sql, params } = this.toSQL();
2586
- const rawRows = await this.#db.query<Record<string, unknown>>(
2587
- sql,
2588
- params,
2589
- this.#meta("exec"),
3253
+ const { sql, params } = this.#compiledNative();
3254
+ const rawRows = await this.#raceTimeout(
3255
+ this.#db.query<Record<string, unknown>>(sql, params, this.#meta("exec")),
2590
3256
  );
2591
3257
  // Peel withCount / withAggregate alias columns off the raw row into $extras
2592
3258
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
@@ -2611,6 +3277,14 @@ export class ModelQuery<T extends BaseEntity> {
2611
3277
  await this.#resolvePreloads(entities);
2612
3278
  }
2613
3279
 
3280
+ // rowTransformer callbacks run per instance AFTER preloads, before resolve
3281
+ // (Lucid), so a transformer can read the eager-loaded relations.
3282
+ if (this.#rowTransformers.length > 0) {
3283
+ for (const entity of entities) {
3284
+ for (const transform of this.#rowTransformers) transform.run(entity);
3285
+ }
3286
+ }
3287
+
2614
3288
  return entities;
2615
3289
  }
2616
3290
 
@@ -2621,17 +3295,21 @@ export class ModelQuery<T extends BaseEntity> {
2621
3295
  * instances aren't needed.
2622
3296
  */
2623
3297
  async pojo<R = Record<string, unknown>>(): Promise<R[]> {
2624
- const { sql, params } = this.toSQL();
3298
+ const { sql, params } = this.#compiledNative();
2625
3299
  return this.#db.query<R>(sql, params);
2626
3300
  }
2627
3301
 
2628
3302
  /**
2629
3303
  * Thread arbitrary context onto every instance this query hydrates, exposed as
2630
3304
  * `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
2631
- * user, so hooks/computed can read it. Merges across calls. Chainable.
3305
+ * user, so hooks/computed can read it. REPLACES the current sideloaded data by
3306
+ * default (Lucid); pass `merge = true` to merge instead. The data also
3307
+ * propagates to preloaded relation queries. Chainable.
2632
3308
  */
2633
- sideload(values: Record<string, unknown>): this {
2634
- this.#sideloaded = { ...this.#sideloaded, ...values };
3309
+ sideload(values: Record<string, unknown>, merge = false): this {
3310
+ this.#sideloaded = merge
3311
+ ? { ...this.#sideloaded, ...values }
3312
+ : { ...values };
2635
3313
  return this;
2636
3314
  }
2637
3315
 
@@ -2653,6 +3331,14 @@ export class ModelQuery<T extends BaseEntity> {
2653
3331
  relation.type,
2654
3332
  ctx,
2655
3333
  );
3334
+ // Propagate the parent query's sideloaded context onto the preloaded
3335
+ // relation instances (Adonis Lucid). The related row's own sideloaded
3336
+ // data (if any) wins over the inherited context.
3337
+ if (this.#sideloaded) {
3338
+ for (const related of allRelated) {
3339
+ related.$sideloaded = { ...this.#sideloaded, ...related.$sideloaded };
3340
+ }
3341
+ }
2656
3342
  await this.#applyNestedPreloads(allRelated, ctx);
2657
3343
  }
2658
3344
  }
@@ -2673,7 +3359,14 @@ export class ModelQuery<T extends BaseEntity> {
2673
3359
  // entities using Adonis' `declare field: T` pattern have no own-properties
2674
3360
  // on a freshly constructed instance, so `key in entity` is always false and
2675
3361
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
2676
- const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
3362
+ // For m2m the pivot's otherKey references `relatedKey` (default the related
3363
+ // PK); the load must filter/index rows by THAT column, not always the PK.
3364
+ const relatedPkName =
3365
+ (relation.type === "manyToMany"
3366
+ ? relation.pivot?.relatedKey
3367
+ : undefined) ??
3368
+ getPrimaryKey(relatedClass) ??
3369
+ "id";
2677
3370
  const validColumns = new Set<string>();
2678
3371
  // Reverse map (db column → property) so an explicit `@Column({ columnName })`
2679
3372
  // on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
@@ -3022,6 +3715,9 @@ export class ModelQuery<T extends BaseEntity> {
3022
3715
  operator: string;
3023
3716
  value: unknown;
3024
3717
  }> = [];
3718
+ // Query-time pivotColumns([...]) requested in the preload callback (replayed
3719
+ // on the scratch builder below) OR directly on this query (related().query()).
3720
+ const extraPivotCols: string[] = [...this.#extraPivotColumns];
3025
3721
  if (ctx.nestedCallback) {
3026
3722
  const scratch = new ModelQuery<BaseEntity>(
3027
3723
  ctx.relatedTable,
@@ -3034,6 +3730,7 @@ export class ModelQuery<T extends BaseEntity> {
3034
3730
  buildValuePreparer(ctx.relatedClass),
3035
3731
  );
3036
3732
  ctx.nestedCallback(scratch);
3733
+ extraPivotCols.push(...scratch.pivotColumnRequests);
3037
3734
  // Apply the pivot column adapters' `prepare` to wherePivot values, so a
3038
3735
  // filter like wherePivot('amount', new Money(1)) matches what attach()/
3039
3736
  // sync() stored (they prepare the same extras on write).
@@ -3080,7 +3777,11 @@ export class ModelQuery<T extends BaseEntity> {
3080
3777
 
3081
3778
  // Step 2 — load all related entities in one query
3082
3779
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
3083
- const pivotCols = pivot.pivotColumns ?? [];
3780
+ // Declared pivot columns (decorator) + any requested at query time
3781
+ // (`pivotColumns([...])`, from the callback or this query), de-duplicated.
3782
+ const pivotCols = [
3783
+ ...new Set([...(pivot.pivotColumns ?? []), ...extraPivotCols]),
3784
+ ];
3084
3785
  const pivotAdapters = pivot.pivotColumnAdapters ?? {};
3085
3786
  // When pivot extras are projected, each (parent, related) edge gets its OWN
3086
3787
  // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
@@ -3254,10 +3955,58 @@ export class ModelQuery<T extends BaseEntity> {
3254
3955
  sub.whereIn(column, values);
3255
3956
  if (relation.onQuery) relation.onQuery(sub as unknown);
3256
3957
  if (userCallback) userCallback(sub);
3257
- const { sql, params } = sub.toSQL();
3958
+ // Top-N per parent (Lucid groupLimit) → a ROW_NUMBER() window over the fk.
3959
+ if (sub.#groupLimit != null) {
3960
+ return this.#runGroupLimited(sub, column, sub.#groupLimit);
3961
+ }
3962
+ const { sql, params } = sub.#compiledNative();
3258
3963
  return this.#db.query<Record<string, unknown>>(sql, params);
3259
3964
  }
3260
3965
 
3966
+ /**
3967
+ * Run a has-many preload query capped to `n` rows PER PARENT (Lucid
3968
+ * `groupLimit`). Wraps the inner query with a `ROW_NUMBER() OVER (PARTITION BY
3969
+ * <fk> ORDER BY <groupOrderBy | pk>)` window, then keeps `rn <= n`. Supported on
3970
+ * Postgres, MySQL 8+, and SQLite 3.25+ (all have window functions).
3971
+ */
3972
+ async #runGroupLimited(
3973
+ sub: ModelQuery<BaseEntity>,
3974
+ fkColumn: string,
3975
+ n: number,
3976
+ ): Promise<Record<string, unknown>[]> {
3977
+ const quoteSeg = (name: string): string => {
3978
+ const qc = this.#dialect === "mysql" ? "`" : '"';
3979
+ return name
3980
+ .split(".")
3981
+ .map((s) => {
3982
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
3983
+ throw new Error(`groupLimit: unsafe identifier '${name}'`);
3984
+ }
3985
+ return `${qc}${s}${qc}`;
3986
+ })
3987
+ .join(".");
3988
+ };
3989
+ const orderCol = sub.#groupOrderBy
3990
+ ? sub.#resolveColumn(sub.#groupOrderBy.column)
3991
+ : (getPrimaryKey(sub.#entityClass) ?? "id");
3992
+ const dir = sub.#groupOrderBy?.direction === "desc" ? "DESC" : "ASC";
3993
+ sub.#selectRaw.push({
3994
+ sql: `ROW_NUMBER() OVER (PARTITION BY ${quoteSeg(fkColumn)} ORDER BY ${quoteSeg(orderCol)} ${dir}) AS __atlas_rn`,
3995
+ params: [],
3996
+ });
3997
+ const inner = sub.#compiledNative();
3998
+ const ph =
3999
+ this.#dialect === "postgres" ? `$${inner.params.length + 1}` : "?";
4000
+ const outerSql = `SELECT * FROM (${inner.sql}) AS __atlas_grp WHERE __atlas_rn <= ${ph}`;
4001
+ const rows = await this.#db.query<Record<string, unknown>>(outerSql, [
4002
+ ...inner.params,
4003
+ n,
4004
+ ]);
4005
+ // Strip the window helper column so it doesn't leak into hydration.
4006
+ for (const row of rows) delete row.__atlas_rn;
4007
+ return rows;
4008
+ }
4009
+
3261
4010
  /**
3262
4011
  * Build a correlated subquery over a relation. Returns `SubqueryProjection`
3263
4012
  * used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
@@ -3435,35 +4184,114 @@ export class ModelQuery<T extends BaseEntity> {
3435
4184
  */
3436
4185
  // === Story 29.4 — joins ===========================================================================
3437
4186
 
3438
- /** `INNER JOIN <table> ON <left> = <right>`. */
4187
+ /** `INNER JOIN` alias of {@link innerJoin} (Lucid/Knex `join`). */
4188
+ join(table: string, left: string, right: string): this;
4189
+ join(table: string, left: string, operator: string, right: string): this;
4190
+ join(table: string, build: (j: JoinBuilder) => void): this;
4191
+ join(
4192
+ table: string,
4193
+ leftOrBuild: string | ((j: JoinBuilder) => void),
4194
+ operatorOrRight?: string,
4195
+ right?: string,
4196
+ ): this {
4197
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
4198
+ }
4199
+
4200
+ /** `INNER JOIN <table> ON <left> [op] <right>` or a callback `ON` builder. */
3439
4201
  innerJoin(table: string, left: string, right: string): this;
4202
+ innerJoin(table: string, left: string, operator: string, right: string): this;
3440
4203
  innerJoin(table: string, build: (j: JoinBuilder) => void): this;
3441
4204
  innerJoin(
3442
4205
  table: string,
3443
4206
  leftOrBuild: string | ((j: JoinBuilder) => void),
4207
+ operatorOrRight?: string,
3444
4208
  right?: string,
3445
4209
  ): this {
3446
- return this.#pushJoin("INNER", table, leftOrBuild, right);
4210
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
3447
4211
  }
3448
4212
 
3449
4213
  leftJoin(table: string, left: string, right: string): this;
4214
+ leftJoin(table: string, left: string, operator: string, right: string): this;
3450
4215
  leftJoin(table: string, build: (j: JoinBuilder) => void): this;
3451
4216
  leftJoin(
3452
4217
  table: string,
3453
4218
  leftOrBuild: string | ((j: JoinBuilder) => void),
4219
+ operatorOrRight?: string,
4220
+ right?: string,
4221
+ ): this {
4222
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
4223
+ }
4224
+
4225
+ /** `LEFT OUTER JOIN` — alias of {@link leftJoin} (Lucid/Knex `leftOuterJoin`). */
4226
+ leftOuterJoin(table: string, left: string, right: string): this;
4227
+ leftOuterJoin(
4228
+ table: string,
4229
+ left: string,
4230
+ operator: string,
4231
+ right: string,
4232
+ ): this;
4233
+ leftOuterJoin(table: string, build: (j: JoinBuilder) => void): this;
4234
+ leftOuterJoin(
4235
+ table: string,
4236
+ leftOrBuild: string | ((j: JoinBuilder) => void),
4237
+ operatorOrRight?: string,
3454
4238
  right?: string,
3455
4239
  ): this {
3456
- return this.#pushJoin("LEFT", table, leftOrBuild, right);
4240
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
3457
4241
  }
3458
4242
 
3459
4243
  rightJoin(table: string, left: string, right: string): this;
4244
+ rightJoin(table: string, left: string, operator: string, right: string): this;
3460
4245
  rightJoin(table: string, build: (j: JoinBuilder) => void): this;
3461
4246
  rightJoin(
3462
4247
  table: string,
3463
4248
  leftOrBuild: string | ((j: JoinBuilder) => void),
4249
+ operatorOrRight?: string,
4250
+ right?: string,
4251
+ ): this {
4252
+ return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
4253
+ }
4254
+
4255
+ /** `RIGHT OUTER JOIN` — alias of {@link rightJoin} (Lucid/Knex `rightOuterJoin`). */
4256
+ rightOuterJoin(table: string, left: string, right: string): this;
4257
+ rightOuterJoin(
4258
+ table: string,
4259
+ left: string,
4260
+ operator: string,
4261
+ right: string,
4262
+ ): this;
4263
+ rightOuterJoin(table: string, build: (j: JoinBuilder) => void): this;
4264
+ rightOuterJoin(
4265
+ table: string,
4266
+ leftOrBuild: string | ((j: JoinBuilder) => void),
4267
+ operatorOrRight?: string,
3464
4268
  right?: string,
3465
4269
  ): this {
3466
- return this.#pushJoin("RIGHT", table, leftOrBuild, right);
4270
+ return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
4271
+ }
4272
+
4273
+ /** `FULL OUTER JOIN` (Lucid/Knex `fullOuterJoin`; Postgres — MySQL/SQLite lack it). */
4274
+ fullOuterJoin(table: string, left: string, right: string): this;
4275
+ fullOuterJoin(
4276
+ table: string,
4277
+ left: string,
4278
+ operator: string,
4279
+ right: string,
4280
+ ): this;
4281
+ fullOuterJoin(table: string, build: (j: JoinBuilder) => void): this;
4282
+ fullOuterJoin(
4283
+ table: string,
4284
+ leftOrBuild: string | ((j: JoinBuilder) => void),
4285
+ operatorOrRight?: string,
4286
+ right?: string,
4287
+ ): this {
4288
+ return this.#pushJoin(
4289
+ "FULL OUTER",
4290
+ table,
4291
+ leftOrBuild,
4292
+ operatorOrRight,
4293
+ right,
4294
+ );
3467
4295
  }
3468
4296
 
3469
4297
  crossJoin(table: string): this {
@@ -3516,8 +4344,15 @@ export class ModelQuery<T extends BaseEntity> {
3516
4344
 
3517
4345
  // === Story 29.5 — aggregates / exists / pluck =====================================================
3518
4346
 
3519
- distinct(): this {
4347
+ /**
4348
+ * SELECT DISTINCT (Lucid/Knex `distinct`). With columns, those replace the
4349
+ * projection too — `distinct('a', 'b')` ≈ `SELECT DISTINCT a, b`.
4350
+ */
4351
+ distinct(...columns: string[]): this {
3520
4352
  this.#distinct = true;
4353
+ if (columns.length > 0) {
4354
+ this.#select = columns.map((c) => this.#resolveSelect(c));
4355
+ }
3521
4356
  return this;
3522
4357
  }
3523
4358
 
@@ -3569,7 +4404,7 @@ export class ModelQuery<T extends BaseEntity> {
3569
4404
  const clone = this.clone();
3570
4405
  clone.#select = ["1"];
3571
4406
  clone.#limit = 1;
3572
- const { sql, params } = clone.toSQL();
4407
+ const { sql, params } = clone.#compiledNative();
3573
4408
  const rows = await this.#db.query<Record<string, unknown>>(
3574
4409
  sql,
3575
4410
  params,
@@ -3587,7 +4422,7 @@ export class ModelQuery<T extends BaseEntity> {
3587
4422
  const col = this.#resolveColumn(column);
3588
4423
  const clone = this.clone();
3589
4424
  clone.#select = [col];
3590
- const { sql, params } = clone.toSQL();
4425
+ const { sql, params } = clone.#compiledNative();
3591
4426
  const rows = await this.#db.query<Record<string, unknown>>(sql, params);
3592
4427
  return rows.map((row) => {
3593
4428
  const v = row[col];
@@ -3658,8 +4493,10 @@ export class ModelQuery<T extends BaseEntity> {
3658
4493
  unless<V>(
3659
4494
  condition: V | undefined | null | false,
3660
4495
  fn: (q: this) => void,
4496
+ elseFn?: (q: this) => void,
3661
4497
  ): this {
3662
4498
  if (!condition) fn(this);
4499
+ else if (elseFn) elseFn(this);
3663
4500
  return this;
3664
4501
  }
3665
4502
 
@@ -3669,11 +4506,17 @@ export class ModelQuery<T extends BaseEntity> {
3669
4506
  async paginate(page: number, perPage: number): Promise<Paginator<T>> {
3670
4507
  const p = Math.max(1, Math.floor(page));
3671
4508
  const pp = Math.max(1, Math.floor(perPage));
3672
- // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
3673
- // tenant scope) propagates into both the COUNT and the data fetch.
3674
- await fireHooks(this.#entityClass, "beforePaginate", this);
3675
- // COUNT(*) + data fetch
4509
+ // Adonis Lucid hook order:
4510
+ // beforePaginate([countQuery, query]) beforeFetch(query)
4511
+ // → (count + data queries) → afterPaginate(paginator) → afterFetch(rows)
4512
+ // The COUNT and data builders are separate clones so a beforePaginate hook
4513
+ // can constrain BOTH (keeping totals in sync), exactly as documented.
3676
4514
  const countQ = this.clone();
4515
+ const dataQ = this.clone();
4516
+ await fireHooks(this.#entityClass, "beforePaginate", [countQ, dataQ]);
4517
+ // beforeFetch fires on the main (data) query before either query runs.
4518
+ await fireHooks(this.#entityClass, "beforeFetch", dataQ);
4519
+ // COUNT(*) — strip pagination/order noise from the count clone.
3677
4520
  countQ.#limit = undefined;
3678
4521
  countQ.#offset = undefined;
3679
4522
  countQ.#orderBys = [];
@@ -3684,12 +4527,12 @@ export class ModelQuery<T extends BaseEntity> {
3684
4527
  // group's own size), so `rows[0].count` would be the first group's size, not
3685
4528
  // the number of pages. Lucid counts via a subquery: wrap the grouped query
3686
4529
  // (select + groupBy + having preserved) and count its rows = group count.
3687
- const inner = countQ.toSQL();
4530
+ const inner = countQ.#compiledNative();
3688
4531
  cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
3689
4532
  cParams = inner.params;
3690
4533
  } else {
3691
4534
  countQ.#select = ["COUNT(*) AS count"];
3692
- const flat = countQ.toSQL();
4535
+ const flat = countQ.#compiledNative();
3693
4536
  cSql = flat.sql;
3694
4537
  cParams = flat.params;
3695
4538
  }
@@ -3700,21 +4543,24 @@ export class ModelQuery<T extends BaseEntity> {
3700
4543
  );
3701
4544
  const total = Number(cRows[0]?.count ?? 0);
3702
4545
 
3703
- const dataQ = this.clone();
3704
4546
  dataQ.#limit = pp;
3705
4547
  dataQ.#offset = (p - 1) * pp;
3706
- // `#doExec` (not `exec`) so the generic beforeFetch/afterFetch don't fire on
3707
- // top of the paginate hooks paginate is its own terminal.
4548
+ // `#doExec` runs the raw fetch + preloads. beforeFetch already fired above;
4549
+ // afterFetch fires AFTER afterPaginate (Lucid order), so #doExec must not
4550
+ // fire either itself — hence #doExec, not exec().
3708
4551
  const items = await dataQ.#doExec();
3709
- await fireHooks(this.#entityClass, "afterPaginate", items);
3710
4552
  const metaKeys = this.#entityClass
3711
4553
  ? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
3712
4554
  : undefined;
3713
- return new Paginator<T>(
4555
+ const paginator = new Paginator<T>(
3714
4556
  items,
3715
4557
  { total, perPage: pp, currentPage: p },
3716
4558
  metaKeys,
3717
4559
  );
4560
+ // Lucid: afterPaginate(paginator) then afterFetch(rows).
4561
+ await fireHooks(this.#entityClass, "afterPaginate", paginator);
4562
+ await fireHooks(this.#entityClass, "afterFetch", items);
4563
+ return paginator;
3718
4564
  }
3719
4565
 
3720
4566
  /**
@@ -3813,6 +4659,18 @@ export class ModelQuery<T extends BaseEntity> {
3813
4659
  return this;
3814
4660
  }
3815
4661
 
4662
+ /**
4663
+ * Attach arbitrary metadata to the `db:query` event this query emits (Adonis
4664
+ * Lucid `reporterData`) — request id, user id, feature flag, … A listener
4665
+ * reads it off `event.reporterData`. Repeated calls merge. Setting it also
4666
+ * forces emission (like {@link debug}), so the data actually reaches a listener.
4667
+ */
4668
+ reporterData(data: Record<string, unknown>): this {
4669
+ this.#reporterData = { ...this.#reporterData, ...data };
4670
+ this.#debugFlag = true;
4671
+ return this;
4672
+ }
4673
+
3816
4674
  /**
3817
4675
  * Context attached to each statement this query runs, so a `db:query`
3818
4676
  * listener can say which model and which call produced it — and so
@@ -3826,17 +4684,18 @@ export class ModelQuery<T extends BaseEntity> {
3826
4684
  model: this.#entityClass.name,
3827
4685
  method,
3828
4686
  debug: this.#debugFlag,
4687
+ reporterData: this.#reporterData,
4688
+ serverTimeoutMs:
4689
+ this.#cancelTimeout && this.#timeoutMs != null
4690
+ ? this.#timeoutMs
4691
+ : undefined,
3829
4692
  };
3830
4693
  }
3831
4694
 
3832
4695
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
3833
4696
  toQuery(): string {
3834
- const { sql, params } = this.toSQL();
3835
- let i = 0;
3836
- return sql.replace(/\?|\$\d+/g, () => {
3837
- const v = params[i++];
3838
- return this.#literalEscape(v);
3839
- });
4697
+ const { sql, params } = this.#compiledNative();
4698
+ return interpolateQuery(sql, params);
3840
4699
  }
3841
4700
 
3842
4701
  /** Deep clone of this query — mutations on the clone never affect the original. */
@@ -3857,9 +4716,20 @@ export class ModelQuery<T extends BaseEntity> {
3857
4716
  c.#orderBys = [...this.#orderBys];
3858
4717
  c.#select = [...this.#select];
3859
4718
  c.#limit = this.#limit;
4719
+ c.#groupLimit = this.#groupLimit;
4720
+ c.#groupOrderBy = this.#groupOrderBy
4721
+ ? { ...this.#groupOrderBy }
4722
+ : undefined;
3860
4723
  c.#offset = this.#offset;
3861
4724
  c.#preloads = new Map(this.#preloads);
4725
+ c.#rowTransformers = [...this.#rowTransformers];
3862
4726
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
4727
+ c.#selectRaw = this.#selectRaw.map((s) => ({
4728
+ ...s,
4729
+ params: [...s.params],
4730
+ }));
4731
+ c.#timeoutMs = this.#timeoutMs;
4732
+ c.#cancelTimeout = this.#cancelTimeout;
3863
4733
  c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
3864
4734
  c.#lockMode = this.#lockMode;
3865
4735
  c.#lockModifier = this.#lockModifier;
@@ -3873,6 +4743,7 @@ export class ModelQuery<T extends BaseEntity> {
3873
4743
  query: e.query.clone(),
3874
4744
  recursive: e.recursive,
3875
4745
  materialized: e.materialized,
4746
+ columns: e.columns ? [...e.columns] : undefined,
3876
4747
  }));
3877
4748
  c.#unions = this.#unions.map((u) => ({
3878
4749
  query: u.query.clone(),
@@ -3880,45 +4751,67 @@ export class ModelQuery<T extends BaseEntity> {
3880
4751
  op: u.op,
3881
4752
  }));
3882
4753
  c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
4754
+ c.#extraPivotColumns = [...this.#extraPivotColumns];
3883
4755
  // Pure closure over pivot metadata — safe to share by reference; it reads the
3884
4756
  // clone's own #pivotWheres at build time (passed in), holding no query state.
3885
4757
  c.#pivotExists = this.#pivotExists;
3886
4758
  c.#debugFlag = this.#debugFlag;
4759
+ c.#reporterData = this.#reporterData
4760
+ ? { ...this.#reporterData }
4761
+ : undefined;
4762
+ c.#comments = [...this.#comments];
3887
4763
  return c;
3888
4764
  }
3889
4765
 
3890
4766
  // === Story 30.2 — update / delete fluent ===========================================================
3891
4767
 
3892
- /** Execute a fluent UPDATE. Returns affected rows (or rows when `returning` is set). */
3893
- async update(
4768
+ /**
4769
+ * Execute a fluent UPDATE (Lucid/Knex `update`). Accepts a `{ col: value }` map
4770
+ * OR a single `(column, value)` pair; a value may be a `db.raw(...)` expression.
4771
+ * Returns affected rows (or the RETURNING rows when `returning` is set).
4772
+ */
4773
+ update(
4774
+ column: string,
4775
+ value: unknown,
4776
+ ): DmlBuilder<number | Record<string, unknown>[]>;
4777
+ update(
3894
4778
  patch: Record<string, unknown>,
3895
4779
  returning?: string[],
3896
- ): Promise<number | Record<string, unknown>[]> {
4780
+ ): DmlBuilder<number | Record<string, unknown>[]>;
4781
+ update(
4782
+ patchOrColumn: Record<string, unknown> | string,
4783
+ valueOrReturning?: unknown,
4784
+ ): DmlBuilder<number | Record<string, unknown>[]> {
4785
+ const patch =
4786
+ typeof patchOrColumn === "string"
4787
+ ? { [patchOrColumn]: valueOrReturning }
4788
+ : patchOrColumn;
4789
+ const returning =
4790
+ typeof patchOrColumn === "string"
4791
+ ? undefined
4792
+ : (valueOrReturning as string[] | undefined);
3897
4793
  if (!patch || Object.keys(patch).length === 0) {
3898
4794
  throw new Error("update() requires a non-empty payload");
3899
4795
  }
3900
4796
  // Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
3901
4797
  // like BaseRepository's write paths — the fluent update() must not bypass it.
3902
- const setPairs = Object.entries(patch).map(
3903
- ([k, v]) =>
3904
- [this.#resolveColumn(k), this.#prepareValue(k, v)] as [string, unknown],
4798
+ // A `db.raw(...)` value is emitted as a raw SET expression instead.
4799
+ const setPairs = Object.entries(patch).map(([k, v]): [string, unknown] =>
4800
+ v instanceof RawSql
4801
+ ? [this.#resolveColumn(k), { raw: v.sql, rawParams: [...v.params] }]
4802
+ : [this.#resolveColumn(k), this.#prepareValue(k, v)],
4803
+ );
4804
+ return this.#makeDml(
4805
+ (r) => ({
4806
+ kind: "update",
4807
+ table: this.#tableName,
4808
+ set: setPairs,
4809
+ wheres: this.#wheresForDml(),
4810
+ returning: r,
4811
+ ctes: this.#compiledCtes(),
4812
+ }),
4813
+ returning,
3905
4814
  );
3906
- const spec = {
3907
- kind: "update",
3908
- table: this.#tableName,
3909
- set: setPairs,
3910
- wheres: this.#wheresForDml(),
3911
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
3912
- };
3913
- const compiled = compileStatementNative(spec, this.#dialect);
3914
- if (returning && returning.length > 0) {
3915
- return this.#db.query<Record<string, unknown>>(
3916
- compiled.statements[0],
3917
- compiled.params,
3918
- );
3919
- }
3920
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
3921
- return r.rowsAffected ?? 0;
3922
4815
  }
3923
4816
 
3924
4817
  /**
@@ -3928,33 +4821,38 @@ export class ModelQuery<T extends BaseEntity> {
3928
4821
  * model it issues a hard `DELETE`. Returns affected rows (or rows when
3929
4822
  * `returning` is set).
3930
4823
  */
3931
- async delete(
3932
- returning?: string[],
3933
- ): Promise<number | Record<string, unknown>[]> {
4824
+ delete(returning?: string[]): DmlBuilder<number | Record<string, unknown>[]> {
3934
4825
  if (this.#softDeletes) {
3935
- const spec = {
3936
- kind: "update",
3937
- table: this.#tableName,
3938
- set: [[this.#deletedAtColumn(), new Date().toISOString()]],
3939
- wheres: this.#wheresForDml(),
3940
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
3941
- };
3942
- return this.#runDml(spec, returning);
4826
+ const stampedAt = new Date().toISOString();
4827
+ return this.#makeDml(
4828
+ (r) => ({
4829
+ kind: "update",
4830
+ table: this.#tableName,
4831
+ set: [[this.#deletedAtColumn(), stampedAt]],
4832
+ wheres: this.#wheresForDml(),
4833
+ returning: r,
4834
+ ctes: this.#compiledCtes(),
4835
+ }),
4836
+ returning,
4837
+ );
3943
4838
  }
3944
4839
  return this.forceDelete(returning);
3945
4840
  }
3946
4841
 
3947
4842
  /** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
3948
- async forceDelete(
4843
+ forceDelete(
3949
4844
  returning?: string[],
3950
- ): Promise<number | Record<string, unknown>[]> {
3951
- const spec = {
3952
- kind: "delete",
3953
- table: this.#tableName,
3954
- wheres: this.#wheresForDml(),
3955
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
3956
- };
3957
- return this.#runDml(spec, returning);
4845
+ ): DmlBuilder<number | Record<string, unknown>[]> {
4846
+ return this.#makeDml(
4847
+ (r) => ({
4848
+ kind: "delete",
4849
+ table: this.#tableName,
4850
+ wheres: this.#wheresForDml(),
4851
+ returning: r,
4852
+ ctes: this.#compiledCtes(),
4853
+ }),
4854
+ returning,
4855
+ );
3958
4856
  }
3959
4857
 
3960
4858
  /**
@@ -3963,30 +4861,38 @@ export class ModelQuery<T extends BaseEntity> {
3963
4861
  * on a non-soft-delete model. Independent of the current soft-scope — it always
3964
4862
  * targets trashed rows (`deleted_at IS NOT NULL`).
3965
4863
  */
3966
- async restore(
4864
+ restore(
3967
4865
  returning?: string[],
3968
- ): Promise<number | Record<string, unknown>[]> {
3969
- if (!this.#softDeletes) return 0;
3970
- const wheres = this.#userWheresForDml();
3971
- wheres.push({
3972
- column: this.#deletedAtColumn(),
3973
- operator: "IS NOT NULL",
3974
- value: null,
3975
- type: "and",
3976
- });
3977
- const spec = {
3978
- kind: "update",
3979
- table: this.#tableName,
3980
- set: [[this.#deletedAtColumn(), null]],
3981
- wheres,
3982
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
3983
- };
3984
- return this.#runDml(spec, returning);
4866
+ ): DmlBuilder<number | Record<string, unknown>[]> {
4867
+ if (!this.#softDeletes) {
4868
+ return new DmlBuilder(
4869
+ () => Promise.resolve<number | Record<string, unknown>[]>(0),
4870
+ () => ({ sql: "", bindings: [], params: [] }),
4871
+ this.#dmlHooks(),
4872
+ );
4873
+ }
4874
+ return this.#makeDml((r) => {
4875
+ const wheres = this.#userWheresForDml();
4876
+ wheres.push({
4877
+ column: this.#deletedAtColumn(),
4878
+ operator: "IS NOT NULL",
4879
+ value: null,
4880
+ type: "and",
4881
+ });
4882
+ return {
4883
+ kind: "update",
4884
+ table: this.#tableName,
4885
+ set: [[this.#deletedAtColumn(), null]],
4886
+ wheres,
4887
+ returning: r,
4888
+ ctes: this.#compiledCtes(),
4889
+ };
4890
+ }, returning);
3985
4891
  }
3986
4892
 
3987
4893
  // === Story 30.3 — increment / decrement already implemented? check ================================
3988
4894
 
3989
- increment(column: string, amount: number): Promise<number>;
4895
+ increment(column: string, amount?: number): Promise<number>;
3990
4896
  increment(patch: Record<string, number>): Promise<number>;
3991
4897
  increment(
3992
4898
  colOrPatch: string | Record<string, number>,
@@ -3995,7 +4901,7 @@ export class ModelQuery<T extends BaseEntity> {
3995
4901
  return this.#runIncDec("increment", colOrPatch, amount);
3996
4902
  }
3997
4903
 
3998
- decrement(column: string, amount: number): Promise<number>;
4904
+ decrement(column: string, amount?: number): Promise<number>;
3999
4905
  decrement(patch: Record<string, number>): Promise<number>;
4000
4906
  decrement(
4001
4907
  colOrPatch: string | Record<string, number>,
@@ -4103,87 +5009,190 @@ export class ModelQuery<T extends BaseEntity> {
4103
5009
  }
4104
5010
 
4105
5011
  #pushJoin(
4106
- kind: "INNER" | "LEFT" | "RIGHT",
5012
+ kind: "INNER" | "LEFT" | "RIGHT" | "FULL OUTER",
4107
5013
  table: string,
4108
5014
  leftOrBuild: string | ((j: JoinBuilder) => void),
5015
+ operatorOrRight?: string,
4109
5016
  right?: string,
4110
5017
  ): this {
4111
5018
  const tq = this.#quoteCol(table);
4112
5019
  if (typeof leftOrBuild === "function") {
5020
+ const parts: JoinPartMQ[] = [];
4113
5021
  const jb: JoinBuilder = {
4114
- parts: [],
4115
- on(l: string, r: string) {
4116
- this.parts.push({ kind: "and", left: l, right: r });
4117
- return this;
5022
+ parts,
5023
+ on: (l: string, opOrR: string, r?: string) => {
5024
+ parts.push(
5025
+ r === undefined
5026
+ ? { kind: "and", left: l, right: opOrR }
5027
+ : { kind: "and", left: l, operator: opOrR, right: r },
5028
+ );
5029
+ return jb;
4118
5030
  },
4119
- andOn(l: string, r: string) {
4120
- this.parts.push({ kind: "and", left: l, right: r });
4121
- return this;
5031
+ andOn: (l: string, opOrR: string, r?: string) => {
5032
+ parts.push(
5033
+ r === undefined
5034
+ ? { kind: "and", left: l, right: opOrR }
5035
+ : { kind: "and", left: l, operator: opOrR, right: r },
5036
+ );
5037
+ return jb;
5038
+ },
5039
+ orOn: (l: string, opOrR: string, r?: string) => {
5040
+ parts.push(
5041
+ r === undefined
5042
+ ? { kind: "or", left: l, right: opOrR }
5043
+ : { kind: "or", left: l, operator: opOrR, right: r },
5044
+ );
5045
+ return jb;
5046
+ },
5047
+ onVal: (l, v) => {
5048
+ parts.push({ kind: "and", left: l, value: { v } });
5049
+ return jb;
4122
5050
  },
4123
- orOn(l: string, r: string) {
4124
- this.parts.push({ kind: "or", left: l, right: r });
4125
- return this;
5051
+ andOnVal: (l, v) => {
5052
+ parts.push({ kind: "and", left: l, value: { v } });
5053
+ return jb;
5054
+ },
5055
+ orOnVal: (l, v) => {
5056
+ parts.push({ kind: "or", left: l, value: { v } });
5057
+ return jb;
5058
+ },
5059
+ onIn: (l, values) => {
5060
+ parts.push({ kind: "and", left: l, values: [...values] });
5061
+ return jb;
5062
+ },
5063
+ onNotIn: (l, values) => {
5064
+ parts.push({
5065
+ kind: "and",
5066
+ left: l,
5067
+ values: [...values],
5068
+ notIn: true,
5069
+ });
5070
+ return jb;
4126
5071
  },
4127
- onVal(l: string, v: unknown) {
4128
- this.parts.push({ kind: "and", left: l, value: { v } });
4129
- return this;
5072
+ onNull: (l) => {
5073
+ parts.push({ kind: "and", left: l, nullOp: "IS NULL" });
5074
+ return jb;
4130
5075
  },
4131
- andOnVal(l: string, v: unknown) {
4132
- this.parts.push({ kind: "and", left: l, value: { v } });
4133
- return this;
5076
+ onNotNull: (l) => {
5077
+ parts.push({ kind: "and", left: l, nullOp: "IS NOT NULL" });
5078
+ return jb;
4134
5079
  },
4135
- orOnVal(l: string, v: unknown) {
4136
- this.parts.push({ kind: "or", left: l, value: { v } });
4137
- return this;
5080
+ onBetween: (l, range) => {
5081
+ parts.push({ kind: "and", left: l, between: [range[0], range[1]] });
5082
+ return jb;
5083
+ },
5084
+ onNotBetween: (l, range) => {
5085
+ parts.push({
5086
+ kind: "and",
5087
+ left: l,
5088
+ between: [range[0], range[1]],
5089
+ notBetween: true,
5090
+ });
5091
+ return jb;
5092
+ },
5093
+ onExists: (sub) => {
5094
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
5095
+ parts.push({ kind: "and", exists: { sql, params, not: false } });
5096
+ return jb;
5097
+ },
5098
+ onNotExists: (sub) => {
5099
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
5100
+ parts.push({ kind: "and", exists: { sql, params, not: true } });
5101
+ return jb;
4138
5102
  },
4139
5103
  };
4140
5104
  leftOrBuild(jb);
4141
- // Collect the bound values in placeholder order as the fragment is built.
5105
+ // A BASE-table column runs the full model prepare (DateTime→ISO + @Column
5106
+ // adapters/casts), keyed by its property. A FOREIGN join column must NOT
5107
+ // borrow the root model's adapter for a same-named column on another
5108
+ // table — apply only universal type-lowering, matching Knex's
5109
+ // model-agnostic join binding.
5110
+ const prep = (col: string, v: unknown): unknown => {
5111
+ const dot = col.lastIndexOf(".");
5112
+ const tablePrefix = dot >= 0 ? col.slice(0, dot) : "";
5113
+ const leaf = dot >= 0 ? col.slice(dot + 1) : col;
5114
+ const isBaseColumn =
5115
+ tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
5116
+ return isBaseColumn ? this.#prepareValue(leaf, v) : lowerJoinValue(v);
5117
+ };
4142
5118
  const params: unknown[] = [];
4143
- const on = jb.parts
5119
+ const on = parts
4144
5120
  .map((p, i) => {
4145
5121
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
4146
- if (p.value) {
4147
- // A BASE-table column runs the full model prepare (DateTime→ISO +
4148
- // @Column adapters/casts), keyed by its property. A FOREIGN join
4149
- // column must NOT borrow the root model's adapter for a same-named
4150
- // column on another table — apply only universal type-lowering
4151
- // (Date/DateTime→ISO), matching Knex's model-agnostic join binding.
4152
- const dot = p.left.lastIndexOf(".");
4153
- const tablePrefix = dot >= 0 ? p.left.slice(0, dot) : "";
4154
- const leaf = dot >= 0 ? p.left.slice(dot + 1) : p.left;
4155
- const isBaseColumn =
4156
- tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
5122
+ if (p.exists) {
5123
+ params.push(...p.exists.params);
5124
+ return `${prefix} ${p.exists.not ? "NOT EXISTS" : "EXISTS"} (${p.exists.sql})`;
5125
+ }
5126
+ const col = this.#quoteCol(p.left ?? "");
5127
+ if (p.nullOp) {
5128
+ return `${prefix} ${col} ${p.nullOp}`;
5129
+ }
5130
+ if (p.between) {
4157
5131
  params.push(
4158
- isBaseColumn
4159
- ? this.#prepareValue(leaf, p.value.v)
4160
- : lowerJoinValue(p.value.v),
5132
+ prep(p.left ?? "", p.between[0]),
5133
+ prep(p.left ?? "", p.between[1]),
4161
5134
  );
4162
- return `${prefix} ${this.#quoteCol(p.left)} = ?`;
5135
+ return `${prefix} ${col} ${p.notBetween ? "NOT BETWEEN" : "BETWEEN"} ? AND ?`;
5136
+ }
5137
+ if (p.values) {
5138
+ const placeholders = p.values.map(() => "?").join(", ");
5139
+ for (const v of p.values) params.push(prep(p.left ?? "", v));
5140
+ return `${prefix} ${col} ${p.notIn ? "NOT IN" : "IN"} (${placeholders})`;
5141
+ }
5142
+ if (p.value) {
5143
+ params.push(prep(p.left ?? "", p.value.v));
5144
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ?`;
4163
5145
  }
4164
- return `${prefix} ${this.#quoteCol(p.left)} = ${this.#quoteCol(p.right ?? "")}`;
5146
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ${this.#quoteCol(p.right ?? "")}`;
4165
5147
  })
4166
5148
  .join(" ");
4167
5149
  this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
4168
5150
  return this;
4169
5151
  }
4170
- if (right === undefined)
5152
+ // String form: 3-arg `(left, right)` or 4-arg `(left, operator, right)`.
5153
+ const left = leftOrBuild;
5154
+ const operator = right === undefined ? "=" : (operatorOrRight ?? "=");
5155
+ const rightCol = right === undefined ? operatorOrRight : right;
5156
+ if (rightCol === undefined)
4171
5157
  throw new Error(
4172
5158
  "join() with string form requires both left and right operands",
4173
5159
  );
4174
5160
  this.#joins.push({
4175
- sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
5161
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(left)} ${this.#validateJoinOp(operator)} ${this.#quoteCol(rightCol)}`,
4176
5162
  params: [],
4177
5163
  });
4178
5164
  return this;
4179
5165
  }
4180
5166
 
5167
+ /** Allowlist the comparison operator embedded verbatim into a JOIN's ON SQL. */
5168
+ #validateJoinOp(op: string): string {
5169
+ const t = op.trim();
5170
+ const up = t.toUpperCase();
5171
+ const allowed = new Set([
5172
+ "=",
5173
+ "<>",
5174
+ "!=",
5175
+ "<",
5176
+ "<=",
5177
+ ">",
5178
+ ">=",
5179
+ "LIKE",
5180
+ "NOT LIKE",
5181
+ "ILIKE",
5182
+ ]);
5183
+ if (allowed.has(t)) return t;
5184
+ if (allowed.has(up)) return up;
5185
+ throw new Error(`Unsupported join operator '${op}'.`);
5186
+ }
5187
+
4181
5188
  async #runScalar(expr: string): Promise<unknown> {
4182
5189
  const clone = this.clone();
4183
5190
  clone.#select = [`${expr} AS __scalar__`];
4184
5191
  clone.#orderBys = [];
4185
- const { sql, params } = clone.toSQL();
4186
- const rows = await this.#db.query<Record<string, unknown>>(sql, params);
5192
+ const { sql, params } = clone.#compiledNative();
5193
+ const rows = await this.#raceTimeout(
5194
+ this.#db.query<Record<string, unknown>>(sql, params),
5195
+ );
4187
5196
  const row = rows[0];
4188
5197
  return row ? row.__scalar__ : null;
4189
5198
  }
@@ -4208,48 +5217,31 @@ export class ModelQuery<T extends BaseEntity> {
4208
5217
  set: setPairs,
4209
5218
  wheres: this.#wheresForDml(),
4210
5219
  returning: [],
5220
+ ctes: this.#compiledCtes(),
4211
5221
  };
4212
5222
  const compiled = compileStatementNative(spec, this.#dialect);
4213
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
5223
+ const r = await this.#raceTimeout(
5224
+ this.#db.execute(
5225
+ this.#commentPrefix() + compiled.statements[0],
5226
+ compiled.params,
5227
+ this.#meta("dml"),
5228
+ ),
5229
+ );
4214
5230
  return r.rowsAffected ?? 0;
4215
5231
  }
4216
5232
 
4217
5233
  /**
4218
- * Flatten the SELECT wheres to DML-compatible wheres. Standard predicates
4219
- * and `whereRaw` fragments pass through; `group` / `exists` / `inSub` are
4220
- * still rejected because the DML compiler's WHERE lowering does not yet
4221
- * handle nested sub-queries or correlated EXISTS.
5234
+ * The user's own WHERE predicates for DML (no soft-delete scope). The DML
5235
+ * compiler now REUSES the SELECT compiler's WHERE lowering, so every predicate
5236
+ * the read builder accepts standard, `whereRaw`, `whereExists`, sub-queries,
5237
+ * and nested groups is valid for update/delete too. Passed through unchanged
5238
+ * (same as `#buildSpec` does for reads).
4222
5239
  */
4223
- /** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
4224
- #userWheresForDml(): Array<Record<string, unknown>> {
4225
- const out: Array<Record<string, unknown>> = [];
4226
- for (const w of this.#wheres) {
4227
- if ("kind" in w) {
4228
- if (w.kind === "raw") {
4229
- out.push({
4230
- kind: "raw",
4231
- sql: w.sql,
4232
- bindings: w.bindings,
4233
- type: w.type,
4234
- });
4235
- continue;
4236
- }
4237
- throw new Error(
4238
- `update/delete do not support '${w.kind}' WHERE clauses. ` +
4239
- `Supported: plain predicates and whereRaw. Use a raw UPDATE/DELETE for complex criteria.`,
4240
- );
4241
- }
4242
- out.push({
4243
- column: w.column,
4244
- operator: w.operator,
4245
- value: w.value,
4246
- type: w.type,
4247
- });
4248
- }
4249
- return out;
5240
+ #userWheresForDml(): WhereClause[] {
5241
+ return [...this.#wheres];
4250
5242
  }
4251
5243
 
4252
- #wheresForDml(): Array<Record<string, unknown>> {
5244
+ #wheresForDml(): WhereClause[] {
4253
5245
  const out = this.#userWheresForDml();
4254
5246
  // Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
4255
5247
  // update/delete/increment/decrement must NOT touch trashed rows under the
@@ -4277,6 +5269,63 @@ export class ModelQuery<T extends BaseEntity> {
4277
5269
  return out;
4278
5270
  }
4279
5271
 
5272
+ /** Compile a DML spec with the comment prefix — for a lazy builder's `.toSQL()`. */
5273
+ #compileDmlSpec(spec: Record<string, unknown>): {
5274
+ sql: string;
5275
+ bindings: unknown[];
5276
+ params: unknown[];
5277
+ } {
5278
+ const compiled = compileStatementNative(spec, this.#dialect);
5279
+ const sql = this.#commentPrefix() + compiled.statements[0];
5280
+ return { sql, bindings: compiled.params, params: compiled.params };
5281
+ }
5282
+
5283
+ /** Hooks a lazy DML builder delegates back to (upsert clauses are N/A on a model query). */
5284
+ #dmlHooks(): DmlChainHooks {
5285
+ return {
5286
+ onConflict: () => {},
5287
+ merge: () => {},
5288
+ ignore: () => {},
5289
+ returning: (...cols) => {
5290
+ for (const c of cols) {
5291
+ if (Array.isArray(c)) this.#dmlReturning.push(...c);
5292
+ else this.#dmlReturning.push(c);
5293
+ }
5294
+ },
5295
+ timeout: (ms) => {
5296
+ this.#timeoutMs = ms;
5297
+ },
5298
+ comment: (t) => {
5299
+ this.comment(t);
5300
+ },
5301
+ debug: (enabled) => {
5302
+ this.debug(enabled);
5303
+ },
5304
+ reporterData: (data) => {
5305
+ this.reporterData(data);
5306
+ },
5307
+ };
5308
+ }
5309
+
5310
+ /** Wrap a DML spec-builder as a lazy, chainable, inspectable {@link DmlBuilder}. */
5311
+ #makeDml(
5312
+ build: (returning: string[]) => Record<string, unknown>,
5313
+ paramReturning?: string[],
5314
+ ): DmlBuilder<number | Record<string, unknown>[]> {
5315
+ const resolved = () =>
5316
+ [...(paramReturning ?? []), ...this.#dmlReturning].map((c) =>
5317
+ this.#resolveSelect(c),
5318
+ );
5319
+ return new DmlBuilder(
5320
+ () => {
5321
+ const r = resolved();
5322
+ return this.#runDml(build(r), r);
5323
+ },
5324
+ () => this.#compileDmlSpec(build(resolved())),
5325
+ this.#dmlHooks(),
5326
+ );
5327
+ }
5328
+
4280
5329
  /** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
4281
5330
  async #runDml(
4282
5331
  spec: Record<string, unknown>,
@@ -4284,12 +5333,21 @@ export class ModelQuery<T extends BaseEntity> {
4284
5333
  ): Promise<number | Record<string, unknown>[]> {
4285
5334
  const compiled = compileStatementNative(spec, this.#dialect);
4286
5335
  if (returning && returning.length > 0) {
4287
- return this.#db.query<Record<string, unknown>>(
4288
- compiled.statements[0],
4289
- compiled.params,
5336
+ return this.#raceTimeout(
5337
+ this.#db.query<Record<string, unknown>>(
5338
+ this.#commentPrefix() + compiled.statements[0],
5339
+ compiled.params,
5340
+ this.#meta("dml"),
5341
+ ),
4290
5342
  );
4291
5343
  }
4292
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
5344
+ const r = await this.#raceTimeout(
5345
+ this.#db.execute(
5346
+ this.#commentPrefix() + compiled.statements[0],
5347
+ compiled.params,
5348
+ this.#meta("dml"),
5349
+ ),
5350
+ );
4293
5351
  return r.rowsAffected ?? 0;
4294
5352
  }
4295
5353
 
@@ -4306,15 +5364,6 @@ export class ModelQuery<T extends BaseEntity> {
4306
5364
  * parameters via the Rust compiler — this escaper is never on the hot path.
4307
5365
  * If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
4308
5366
  */
4309
- #literalEscape(v: unknown): string {
4310
- if (v === null || v === undefined) return "NULL";
4311
- if (typeof v === "number") return String(v);
4312
- if (typeof v === "boolean") return v ? "1" : "0";
4313
- if (v instanceof Date) return `'${v.toISOString()}'`;
4314
- // Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
4315
- return `'${String(v).replace(/'/g, "''")}'`;
4316
- }
4317
-
4318
5367
  /**
4319
5368
  * Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
4320
5369
  * on the SAME table is used as the scratch builder so the callback can call