@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
@@ -15,7 +15,11 @@ import { assertNotPromise, BaseRepository, wrapAdapterError, } from "./BaseRepos
15
15
  import { ensureEntityMetadata, getColumnMetadata, getDateColumnConfig, getPrimaryKey, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
16
16
  import { fireHooks } from "./decorators/hooks.js";
17
17
  import { getNamingStrategy } from "./naming/NamingStrategy.js";
18
- import { compileStatementNative, getAtlasDialect, } from "./query/native.js";
18
+ import { DmlBuilder } from "./query/DmlBuilder.js";
19
+ import { compiledStatement, interpolateQuery, } from "./query/interpolate.js";
20
+ import { compileStatementNative, getAtlasDialect, normalizeDialect, } from "./query/native.js";
21
+ import { negateOperator } from "./query/operators.js";
22
+ import { RawSql } from "./query/QueryBuilder.js";
19
23
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
20
24
  /**
21
25
  * Comparison operators allowed in `whereExpr`'s raw 4-arg form (where
@@ -405,10 +409,27 @@ export class ModelQuery {
405
409
  #orderBys = [];
406
410
  #select = ["*"];
407
411
  #limit;
412
+ /** Top-N-per-parent limit for a has-many preload (Lucid `groupLimit`). */
413
+ #groupLimit;
414
+ /** ORDER BY for the per-parent window (Lucid `groupOrderBy`). */
415
+ #groupOrderBy;
408
416
  #offset;
409
417
  #preloads = new Map();
418
+ // Per-query row transformers (Adonis Lucid `rowTransformer`). Stored via a
419
+ // method-signature wrapper (not a bare `(row: T) => void` property) so T stays
420
+ // in a bivariant position — a function-typed property would force ModelQuery<T>
421
+ // invariant and break the `this: ModelClass<T>` bound on every static finder.
422
+ #rowTransformers = [];
410
423
  /** Correlated subquery projections (withCount / withAggregate). */
411
424
  #selectSubqueries = [];
425
+ /** Raw / subquery SELECT fragments carrying their own params (Lucid `select(raw)`, `select(subquery.as())`). */
426
+ #selectRaw = [];
427
+ /** Caller-facing statement timeout in ms (Lucid `timeout(ms)`), applied via a race in exec. */
428
+ #timeoutMs;
429
+ /** `timeout(ms, { cancel: true })` — also apply a SERVER-side statement timeout. */
430
+ #cancelTimeout = false;
431
+ /** Columns from a chainable `.returning(...)` on a lazy DML builder. */
432
+ #dmlReturning = [];
412
433
  /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
413
434
  #subqueryAlias;
414
435
  /** Raw JOIN fragments — Story 29.4. */
@@ -421,6 +442,10 @@ export class ModelQuery {
421
442
  #sideloaded = null;
422
443
  /** Per-query debug flag — Story 29.11. */
423
444
  #debugFlag = false;
445
+ /** Metadata attached to the db:query event (Adonis Lucid `reporterData`). */
446
+ #reporterData;
447
+ /** SQL comments prepended to the compiled query (Lucid/Knex `comment`). */
448
+ #comments = [];
424
449
  /** Distinct flag — Story 29.5. */
425
450
  #distinct = false;
426
451
  #distinctOn = [];
@@ -434,6 +459,8 @@ export class ModelQuery {
434
459
  #unions = [];
435
460
  /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
436
461
  #pivotWheres = [];
462
+ /** Extra pivot columns requested at query time (Lucid `pivotColumns([...])`). */
463
+ #extraPivotColumns = [];
437
464
  /**
438
465
  * Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
439
466
  * the relation proxy's scoped query; invoked at `#buildSpec()` time with the
@@ -469,6 +496,24 @@ export class ModelQuery {
469
496
  this.#softScope = "only-trashed";
470
497
  return this;
471
498
  }
499
+ /**
500
+ * Apply the first `[guard, callback]` whose guard is truthy (Adonis Lucid
501
+ * `match`); a trailing bare callback is the default, run when none matched.
502
+ */
503
+ match(...blocks) {
504
+ for (const block of blocks) {
505
+ if (typeof block === "function") {
506
+ block(this);
507
+ return this;
508
+ }
509
+ const [guard, callback] = block;
510
+ if (guard) {
511
+ callback(this);
512
+ return this;
513
+ }
514
+ }
515
+ return this;
516
+ }
472
517
  /**
473
518
  * Eager-load a relation (AdonisJS-style).
474
519
  * Relations are never loaded automatically — you must call .preload() explicitly.
@@ -481,14 +526,80 @@ export class ModelQuery {
481
526
  this.#preloads.set(relationName, callback);
482
527
  return this;
483
528
  }
484
- /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
485
- select(columns) {
486
- const list = Array.isArray(columns)
487
- ? columns
488
- : columns.split(",").map((c) => c.trim());
489
- this.#select = list.map((c) => this.#resolveSelect(c));
529
+ /**
530
+ * Eager-load a relation only if it hasn't been registered yet (Adonis Lucid
531
+ * `preloadOnce`) a later `preload`/`preloadOnce` for the same relation is
532
+ * ignored, so a shared scope can safely add a preload without clobbering one
533
+ * the caller already set.
534
+ */
535
+ preloadOnce(relationName, callback) {
536
+ if (!this.#preloads.has(relationName)) {
537
+ this.#preloads.set(relationName, callback);
538
+ }
539
+ return this;
540
+ }
541
+ /**
542
+ * Register a callback run for every hydrated instance after loading (preloads
543
+ * included) but before the query resolves (Adonis Lucid `rowTransformer`). The
544
+ * callback mutates the instance in place — decorate rows with per-query
545
+ * computed values without a model hook/accessor. Multiple transformers run in
546
+ * registration order.
547
+ */
548
+ rowTransformer(callback) {
549
+ this.#rowTransformers.push({ run: callback });
550
+ return this;
551
+ }
552
+ /**
553
+ * Select columns (default: `*`). The model query builder extends the database
554
+ * query builder's `select` surface (Lucid parity): bare names, multiple args,
555
+ * a comma-separated string, an array, an `{ alias: 'column' }` object map, a
556
+ * `db.raw(...)` fragment, and a named subquery — `select(subquery.as('x'))`.
557
+ * Plain columns are resolved to their DB column (honouring `@Column`).
558
+ */
559
+ select(...columns) {
560
+ const plain = [];
561
+ for (const col of columns) {
562
+ if (typeof col === "string") {
563
+ plain.push(...col.split(",").map((c) => c.trim()));
564
+ }
565
+ else if (Array.isArray(col)) {
566
+ plain.push(...col);
567
+ }
568
+ else if (col instanceof RawSql) {
569
+ // Lucid `select(db.raw(sql, bindings))` — verbatim fragment + params.
570
+ this.#selectRaw.push({ sql: col.sql, params: [...col.params] });
571
+ }
572
+ else if (col instanceof _a) {
573
+ // Lucid `select(subquery.as('alias'))` — a correlated subquery column.
574
+ const alias = col.#subqueryAlias;
575
+ if (!alias) {
576
+ throw new Error("select(subquery) requires the subquery to be named with .as('alias')");
577
+ }
578
+ const { sql, params } = col.#compiledNative();
579
+ this.#selectRaw.push({
580
+ sql: `(${sql}) AS ${this.#quoteAliasName(alias)}`,
581
+ params,
582
+ });
583
+ }
584
+ else {
585
+ for (const [alias, expr] of Object.entries(col)) {
586
+ plain.push(`${expr} AS ${alias}`);
587
+ }
588
+ }
589
+ }
590
+ if (plain.length > 0) {
591
+ this.#select = plain.map((c) => this.#resolveSelect(c));
592
+ }
490
593
  return this;
491
594
  }
595
+ /** Validate + dialect-quote a bare alias identifier. */
596
+ #quoteAliasName(alias) {
597
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
598
+ throw new Error(`Invalid alias '${alias}' — expected a bare identifier.`);
599
+ }
600
+ const q = this.#dialect === "mysql" ? "`" : '"';
601
+ return `${q}${alias}${q}`;
602
+ }
492
603
  /**
493
604
  * Resolve a bare model-property select/returning target to its DB column
494
605
  * (honouring `@Column({ columnName })`), leaving expressions / aliases /
@@ -588,33 +699,73 @@ export class ModelQuery {
588
699
  andWhereColumn(left, operator, right) {
589
700
  return this.whereColumn(left, operator, right);
590
701
  }
591
- /** `WHERE col != ?` — negation of `where`. */
592
- whereNot(column, value) {
702
+ whereNot(columnOrCbOrObj, operatorOrValue, value) {
703
+ if (typeof columnOrCbOrObj === "function") {
704
+ this.#wheres.push({
705
+ ...this.#buildGroup("and", columnOrCbOrObj),
706
+ negated: true,
707
+ });
708
+ return this;
709
+ }
710
+ if (typeof columnOrCbOrObj === "object") {
711
+ for (const [col, val] of Object.entries(columnOrCbOrObj)) {
712
+ this.#wheres.push({
713
+ type: "and",
714
+ column: this.#resolveColumn(col),
715
+ operator: "!=",
716
+ value: this.#prep(col, val),
717
+ });
718
+ }
719
+ return this;
720
+ }
721
+ if (value === undefined) {
722
+ this.#wheres.push({
723
+ type: "and",
724
+ column: this.#resolveColumn(columnOrCbOrObj),
725
+ operator: "!=",
726
+ value: this.#prep(columnOrCbOrObj, operatorOrValue),
727
+ });
728
+ return this;
729
+ }
730
+ // (column, operator, value): NOT (col <op> value) via a negated group so any
731
+ // operator negates correctly without an operator-inversion table.
732
+ const col = columnOrCbOrObj;
733
+ const op = operatorOrValue;
593
734
  this.#wheres.push({
594
- type: "and",
595
- column: this.#resolveColumn(column),
596
- operator: "!=",
597
- value: this.#prep(column, value),
735
+ ...this.#buildGroup("and", (q) => q.where(col, op, value)),
736
+ negated: true,
598
737
  });
599
738
  return this;
600
739
  }
601
- /** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
602
- whereIn(column, source) {
740
+ whereIn(columnOrColumns, source) {
741
+ // Tuple form (Lucid `whereIn(['id','email'], [[1,'a@b.com']])`).
742
+ if (Array.isArray(columnOrColumns)) {
743
+ const cols = columnOrColumns.map((c) => this.#resolveColumn(c));
744
+ const rows = (Array.isArray(source) ? source : []).map((r) => (Array.isArray(r) ? r : [r]).map((v, j) => this.#prep(cols[j] ?? "", v)));
745
+ this.#wheres.push({
746
+ type: "and",
747
+ kind: "inTuple",
748
+ columns: cols,
749
+ rows,
750
+ negated: false,
751
+ });
752
+ return this;
753
+ }
603
754
  if (source instanceof _a) {
604
755
  this.#wheres.push({
605
756
  type: "and",
606
757
  kind: "inSub",
607
758
  negated: false,
608
- column: this.#resolveColumn(column),
759
+ column: this.#resolveColumn(columnOrColumns),
609
760
  subquery: source.#buildSpec(),
610
761
  });
611
762
  return this;
612
763
  }
613
764
  this.#wheres.push({
614
765
  type: "and",
615
- column: this.#resolveColumn(column),
766
+ column: this.#resolveColumn(columnOrColumns),
616
767
  operator: "IN",
617
- value: this.#prep(column, [...source]),
768
+ value: this.#prep(columnOrColumns, [...source]),
618
769
  });
619
770
  return this;
620
771
  }
@@ -976,28 +1127,48 @@ export class ModelQuery {
976
1127
  orWhereNotExists(subquery) {
977
1128
  return this.#pushExists("or", true, subquery);
978
1129
  }
979
- // ─── JSON ─────────────────────────────────────────────────
980
- //
981
- // Every value crosses the boundary as a bound param — the path and the
982
- // compared value both. Only the column is a quoted identifier. Path access
983
- // and containment are each spelled per dialect, and SQLite has no
984
- // containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
1130
+ whereJsonPath(column, path, operatorOrValue, value) {
1131
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1132
+ }
1133
+ andWhereJsonPath(column, path, operatorOrValue, value) {
1134
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1135
+ }
1136
+ orWhereJsonPath(column, path, operatorOrValue, value) {
1137
+ return this.#pushJsonPathArgs("or", column, path, operatorOrValue, value);
1138
+ }
1139
+ /** Resolve the optional-operator JSONPath form, then push (default op `=`). */
1140
+ #pushJsonPathArgs(type, column, path, operatorOrValue, value) {
1141
+ const [operator, val] = value === undefined
1142
+ ? ["=", operatorOrValue]
1143
+ : [String(operatorOrValue), value];
1144
+ return this.#pushJson(type, false, "path", column, val, path, String(operator));
1145
+ }
985
1146
  /**
986
- * `WHERE <col at path> <op> ?` compare a value inside a JSON column
987
- * (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
988
- *
989
- * query.whereJsonPath('data', '$.address.city', '=', 'Paris')
1147
+ * Structural JSON match (Lucid `whereJson`)the column's JSON must equal
1148
+ * `value` (canonical comparison on Postgres/MySQL). AND is the default.
990
1149
  */
991
- whereJsonPath(column, path, operator, value) {
992
- return this.#pushJson("and", false, "path", column, value, path, operator);
1150
+ whereJson(column, value) {
1151
+ return this.#pushJson("and", false, "equals", column, value);
1152
+ }
1153
+ /** Alias of {@link whereJson} (Lucid `andWhereJson`). */
1154
+ andWhereJson(column, value) {
1155
+ return this.#pushJson("and", false, "equals", column, value);
1156
+ }
1157
+ /** OR form of {@link whereJson} (Lucid `orWhereJson`). */
1158
+ orWhereJson(column, value) {
1159
+ return this.#pushJson("or", false, "equals", column, value);
993
1160
  }
994
- /** Alias of {@link whereJsonPath} (Lucid parity). */
995
- andWhereJsonPath(column, path, operator, value) {
996
- return this.#pushJson("and", false, "path", column, value, path, operator);
1161
+ /** WHERE NOT structural JSON match (Lucid `whereNotJson`). */
1162
+ whereNotJson(column, value) {
1163
+ return this.#pushJson("and", true, "equals", column, value);
997
1164
  }
998
- /** `OR <col at path> <op> ?` (Lucid parity). */
999
- orWhereJsonPath(column, path, operator, value) {
1000
- return this.#pushJson("or", false, "path", column, value, path, operator);
1165
+ /** Alias of {@link whereNotJson} (Lucid `andWhereNotJson`). */
1166
+ andWhereNotJson(column, value) {
1167
+ return this.#pushJson("and", true, "equals", column, value);
1168
+ }
1169
+ /** OR NOT structural JSON match (Lucid `orWhereNotJson`). */
1170
+ orWhereNotJson(column, value) {
1171
+ return this.#pushJson("or", true, "equals", column, value);
1001
1172
  }
1002
1173
  /**
1003
1174
  * `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
@@ -1048,6 +1219,47 @@ export class ModelQuery {
1048
1219
  orWhereNotJsonSubsetOf(column, value) {
1049
1220
  return this.#pushJson("or", true, "subset", column, value);
1050
1221
  }
1222
+ // Lucid's canonical containment names drop the `Of` suffix — kept as aliases.
1223
+ /** Lucid `whereJsonSuperset` (alias of {@link whereJsonSupersetOf}). */
1224
+ whereJsonSuperset(column, value) {
1225
+ return this.#pushJson("and", false, "superset", column, value);
1226
+ }
1227
+ /** Lucid `andWhereJsonSuperset`. */
1228
+ andWhereJsonSuperset(column, value) {
1229
+ return this.#pushJson("and", false, "superset", column, value);
1230
+ }
1231
+ /** Lucid `orWhereJsonSuperset`. */
1232
+ orWhereJsonSuperset(column, value) {
1233
+ return this.#pushJson("or", false, "superset", column, value);
1234
+ }
1235
+ /** Lucid `whereNotJsonSuperset`. */
1236
+ whereNotJsonSuperset(column, value) {
1237
+ return this.#pushJson("and", true, "superset", column, value);
1238
+ }
1239
+ /** Lucid `orWhereNotJsonSuperset`. */
1240
+ orWhereNotJsonSuperset(column, value) {
1241
+ return this.#pushJson("or", true, "superset", column, value);
1242
+ }
1243
+ /** Lucid `whereJsonSubset` (alias of {@link whereJsonSubsetOf}). */
1244
+ whereJsonSubset(column, value) {
1245
+ return this.#pushJson("and", false, "subset", column, value);
1246
+ }
1247
+ /** Lucid `andWhereJsonSubset`. */
1248
+ andWhereJsonSubset(column, value) {
1249
+ return this.#pushJson("and", false, "subset", column, value);
1250
+ }
1251
+ /** Lucid `orWhereJsonSubset`. */
1252
+ orWhereJsonSubset(column, value) {
1253
+ return this.#pushJson("or", false, "subset", column, value);
1254
+ }
1255
+ /** Lucid `whereNotJsonSubset`. */
1256
+ whereNotJsonSubset(column, value) {
1257
+ return this.#pushJson("and", true, "subset", column, value);
1258
+ }
1259
+ /** Lucid `orWhereNotJsonSubset`. */
1260
+ orWhereNotJsonSubset(column, value) {
1261
+ return this.#pushJson("or", true, "subset", column, value);
1262
+ }
1051
1263
  #pushJson(type, negated, jsonOp, column, value, path, operator) {
1052
1264
  // A JSONPath is bound, not interpolated, so injection is not the concern
1053
1265
  // here — a clear early error for a malformed path is. Lucid/Knex paths
@@ -1308,8 +1520,29 @@ export class ModelQuery {
1308
1520
  this.#selectSubqueries.push(this.#buildRelationSubquery(relationName, callback, "aggregate", relationName));
1309
1521
  return this;
1310
1522
  }
1311
- orderBy(column, direction = "asc") {
1312
- this.#orderBys.push({ column: this.#resolveColumn(column), direction });
1523
+ orderBy(columnOrTerms, direction = "asc") {
1524
+ // A raw fragment (e.g. `db.ref('posts.created_at')`) orders verbatim.
1525
+ if (columnOrTerms instanceof RawSql) {
1526
+ const dir = direction === "desc" ? "DESC" : "ASC";
1527
+ this.#orderBys.push({ raw: `${columnOrTerms.sql} ${dir}` });
1528
+ return this;
1529
+ }
1530
+ if (Array.isArray(columnOrTerms)) {
1531
+ for (const t of columnOrTerms) {
1532
+ const [col, dir] = typeof t === "string"
1533
+ ? [t, "asc"]
1534
+ : [t.column, t.order ?? "asc"];
1535
+ this.#orderBys.push({
1536
+ column: this.#resolveColumn(col),
1537
+ direction: dir,
1538
+ });
1539
+ }
1540
+ return this;
1541
+ }
1542
+ this.#orderBys.push({
1543
+ column: this.#resolveColumn(columnOrTerms),
1544
+ direction,
1545
+ });
1313
1546
  return this;
1314
1547
  }
1315
1548
  /**
@@ -1373,6 +1606,40 @@ export class ModelQuery {
1373
1606
  });
1374
1607
  return this;
1375
1608
  }
1609
+ /** HAVING col IS NULL (Lucid/Knex `havingNull`). */
1610
+ havingNull(column) {
1611
+ return this.#pushHaving(column, "IS NULL", null);
1612
+ }
1613
+ /** HAVING col IS NOT NULL (Lucid/Knex `havingNotNull`). */
1614
+ havingNotNull(column) {
1615
+ return this.#pushHaving(column, "IS NOT NULL", null);
1616
+ }
1617
+ /** HAVING col IN (...) (Lucid/Knex `havingIn`). */
1618
+ havingIn(column, values) {
1619
+ return this.#pushHaving(column, "IN", [...values]);
1620
+ }
1621
+ /** HAVING col NOT IN (...) (Lucid/Knex `havingNotIn`). */
1622
+ havingNotIn(column, values) {
1623
+ return this.#pushHaving(column, "NOT IN", [...values]);
1624
+ }
1625
+ /** HAVING col BETWEEN ? AND ? (Lucid/Knex `havingBetween`). */
1626
+ havingBetween(column, range) {
1627
+ return this.#pushHaving(column, "BETWEEN", [...range]);
1628
+ }
1629
+ /** HAVING col NOT BETWEEN ? AND ? (Lucid/Knex `havingNotBetween`). */
1630
+ havingNotBetween(column, range) {
1631
+ return this.#pushHaving(column, "NOT BETWEEN", [...range]);
1632
+ }
1633
+ /** Push a HAVING entry with a raw (non-`#prep`'d) value — for IN/BETWEEN/NULL. */
1634
+ #pushHaving(column, operator, value) {
1635
+ this.#having.push({
1636
+ column: this.#resolveHavingCol(column),
1637
+ operator,
1638
+ value,
1639
+ type: "and",
1640
+ });
1641
+ return this;
1642
+ }
1376
1643
  /**
1377
1644
  * Resolve a HAVING column: a bare model property maps to its DB column
1378
1645
  * (honouring `@Column({ columnName })`), but an aggregate expression
@@ -1428,17 +1695,21 @@ export class ModelQuery {
1428
1695
  * the outer parameter list.
1429
1696
  */
1430
1697
  union(query) {
1431
- this.#unions.push({ query, all: false });
1698
+ this.#unions.push({ query: this.#resolveUnion(query), all: false });
1432
1699
  return this;
1433
1700
  }
1434
1701
  /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
1435
1702
  unionAll(query) {
1436
- this.#unions.push({ query, all: true });
1703
+ this.#unions.push({ query: this.#resolveUnion(query), all: true });
1437
1704
  return this;
1438
1705
  }
1439
1706
  /** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
1440
1707
  intersect(query) {
1441
- this.#unions.push({ query, all: false, op: "intersect" });
1708
+ this.#unions.push({
1709
+ query: this.#resolveUnion(query),
1710
+ all: false,
1711
+ op: "intersect",
1712
+ });
1442
1713
  return this;
1443
1714
  }
1444
1715
  /**
@@ -1449,19 +1720,43 @@ export class ModelQuery {
1449
1720
  * `E_UNSUPPORTED` there rather than emitting a syntax error.
1450
1721
  */
1451
1722
  intersectAll(query) {
1452
- this.#unions.push({ query, all: true, op: "intersect" });
1723
+ this.#unions.push({
1724
+ query: this.#resolveUnion(query),
1725
+ all: true,
1726
+ op: "intersect",
1727
+ });
1453
1728
  return this;
1454
1729
  }
1455
1730
  /** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
1456
1731
  except(query) {
1457
- this.#unions.push({ query, all: false, op: "except" });
1732
+ this.#unions.push({
1733
+ query: this.#resolveUnion(query),
1734
+ all: false,
1735
+ op: "except",
1736
+ });
1458
1737
  return this;
1459
1738
  }
1460
1739
  /** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
1461
1740
  exceptAll(query) {
1462
- this.#unions.push({ query, all: true, op: "except" });
1741
+ this.#unions.push({
1742
+ query: this.#resolveUnion(query),
1743
+ all: true,
1744
+ op: "except",
1745
+ });
1463
1746
  return this;
1464
1747
  }
1748
+ /** A compound-query arg — an explicit ModelQuery OR a callback building one on this model. */
1749
+ #resolveUnion(query) {
1750
+ if (typeof query !== "function")
1751
+ return query;
1752
+ const sub = this.#freshQuery();
1753
+ query(sub);
1754
+ return sub;
1755
+ }
1756
+ /** A fresh, empty query on the SAME model + connection (for compound callbacks). */
1757
+ #freshQuery() {
1758
+ return new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
1759
+ }
1465
1760
  /**
1466
1761
  * `WITH <name> AS (<query>)` — register a Common Table Expression
1467
1762
  * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
@@ -1481,8 +1776,11 @@ export class ModelQuery {
1481
1776
  * The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
1482
1777
  * an anchor `SELECT` unioned with a select that references `<name>`.
1483
1778
  */
1484
- withRecursive(name, query) {
1485
- return this.#pushCte("withRecursive", name, query, { recursive: true });
1779
+ withRecursive(name, query, columns) {
1780
+ return this.#pushCte("withRecursive", name, query, {
1781
+ recursive: true,
1782
+ columns,
1783
+ });
1486
1784
  }
1487
1785
  /**
1488
1786
  * `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
@@ -1506,7 +1804,18 @@ export class ModelQuery {
1506
1804
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1507
1805
  throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
1508
1806
  }
1509
- this.#ctes.push({ name, query, ...options });
1807
+ this.#ctes.push({ name, query: this.#resolveUnion(query), ...options });
1808
+ return this;
1809
+ }
1810
+ /**
1811
+ * `@ManyToMany` only — project EXTRA pivot columns at query time (AdonisJS/Lucid
1812
+ * `pivotColumns([...])`), on top of any declared in the relation decorator. Read
1813
+ * off each loaded relation as `$extras.pivot_<col>`. Inert on non-m2m relations.
1814
+ *
1815
+ * userRepo.query().preload('skills', q => q.pivotColumns(['notes']))
1816
+ */
1817
+ pivotColumns(columns) {
1818
+ this.#extraPivotColumns.push(...columns);
1510
1819
  return this;
1511
1820
  }
1512
1821
  wherePivot(column, operatorOrValue, value) {
@@ -1534,17 +1843,21 @@ export class ModelQuery {
1534
1843
  wherePivotIn(column, values) {
1535
1844
  return this.whereInPivot(column, values);
1536
1845
  }
1537
- /** `@ManyToMany` only — `WHERE <pivotCol> != <value>` on the pivot table (AdonisJS Lucid `whereNotPivot`). */
1538
- whereNotPivot(column, value) {
1539
- return this.#pushPivotOp("and", column, "!=", value);
1846
+ whereNotPivot(column, operatorOrValue, value) {
1847
+ if (value === undefined) {
1848
+ return this.#pushPivotOp("and", column, "!=", operatorOrValue);
1849
+ }
1850
+ return this.#pushPivotOp("and", column, negateOperator(String(operatorOrValue)), value);
1540
1851
  }
1541
- /** Alias of {@link whereNotPivot} (Lucid parity). */
1542
- andWhereNotPivot(column, value) {
1543
- return this.#pushPivotOp("and", column, "!=", value);
1852
+ andWhereNotPivot(column, operatorOrValue, value) {
1853
+ return value === undefined
1854
+ ? this.#pushPivotOp("and", column, "!=", operatorOrValue)
1855
+ : this.#pushPivotOp("and", column, negateOperator(String(operatorOrValue)), value);
1544
1856
  }
1545
- /** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
1546
- orWhereNotPivot(column, value) {
1547
- return this.#pushPivotOp("or", column, "!=", value);
1857
+ orWhereNotPivot(column, operatorOrValue, value) {
1858
+ return value === undefined
1859
+ ? this.#pushPivotOp("or", column, "!=", operatorOrValue)
1860
+ : this.#pushPivotOp("or", column, negateOperator(String(operatorOrValue)), value);
1548
1861
  }
1549
1862
  /** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
1550
1863
  whereNotInPivot(column, values) {
@@ -1596,6 +1909,10 @@ export class ModelQuery {
1596
1909
  get pivotConstraints() {
1597
1910
  return this.#pivotWheres;
1598
1911
  }
1912
+ /** Read-only accessor for query-time pivot columns — consumed by the m2m preload resolver. */
1913
+ get pivotColumnRequests() {
1914
+ return this.#extraPivotColumns;
1915
+ }
1599
1916
  limit(n) {
1600
1917
  // Guard here with a clear message — the Rust spec types limit as
1601
1918
  // u64, so a negative/non-integer otherwise surfaces as a cryptic
@@ -1607,6 +1924,24 @@ export class ModelQuery {
1607
1924
  this.#limit = n;
1608
1925
  return this;
1609
1926
  }
1927
+ /**
1928
+ * In a has-many preload callback, cap the related rows PER PARENT (Lucid
1929
+ * `groupLimit`) — a plain `.limit()` caps the whole result set across parents.
1930
+ * Compiled with a `ROW_NUMBER() OVER (PARTITION BY <fk> …)` window. Pair with
1931
+ * {@link groupOrderBy} to pick which N per parent.
1932
+ */
1933
+ groupLimit(n) {
1934
+ if (!Number.isInteger(n) || n < 0) {
1935
+ throw new Error(`groupLimit must be a non-negative integer, got ${n}`);
1936
+ }
1937
+ this.#groupLimit = n;
1938
+ return this;
1939
+ }
1940
+ /** ORDER BY for the {@link groupLimit} per-parent window (Lucid `groupOrderBy`). */
1941
+ groupOrderBy(column, direction = "asc") {
1942
+ this.#groupOrderBy = { column, direction };
1943
+ return this;
1944
+ }
1610
1945
  offset(n) {
1611
1946
  if (!Number.isInteger(n) || n < 0) {
1612
1947
  throw new Error(`offset must be a non-negative integer, got ${n}`);
@@ -1754,6 +2089,7 @@ export class ModelQuery {
1754
2089
  table: this.#tableName,
1755
2090
  select: selectCols,
1756
2091
  selectSubqueries: this.#selectSubqueries,
2092
+ selectRaw: this.#selectRaw,
1757
2093
  wheres,
1758
2094
  orderBy: this.#orderBys,
1759
2095
  groupBy: this.#groupBy,
@@ -1762,18 +2098,9 @@ export class ModelQuery {
1762
2098
  offset: this.#offset ?? null,
1763
2099
  distinct: this.#distinct,
1764
2100
  distinctOn: this.#distinctOn,
1765
- ctes: this.#ctes.map((c) => {
1766
- const { sql, params } = c.query.toSQL();
1767
- return {
1768
- name: c.name,
1769
- sql,
1770
- params,
1771
- recursive: c.recursive ?? false,
1772
- materialized: c.materialized ?? null,
1773
- };
1774
- }),
2101
+ ctes: this.#compiledCtes(),
1775
2102
  unions: this.#unions.map((u) => {
1776
- const { sql, params } = u.query.toSQL();
2103
+ const { sql, params } = u.query.#compiledNative();
1777
2104
  return { sql, params, all: u.all, op: u.op ?? null };
1778
2105
  }),
1779
2106
  joins: this.#joins,
@@ -1784,10 +2111,106 @@ export class ModelQuery {
1784
2111
  : null,
1785
2112
  };
1786
2113
  }
1787
- /** Build SQL + params via the Rust query compiler. */
1788
- toSQL() {
2114
+ /**
2115
+ * Native compiled `{ sql, params }` (Postgres `$N`) — the form atlas executes
2116
+ * and embeds inside parent queries. Public `toSQL()` normalizes to Knex `?`.
2117
+ */
2118
+ #compiledNative() {
1789
2119
  const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
1790
- return { sql: compiled.statements[0], params: compiled.params };
2120
+ const sql = this.#commentPrefix() + compiled.statements[0];
2121
+ return { sql, params: compiled.params };
2122
+ }
2123
+ /**
2124
+ * Build SQL via the Rust query compiler (Lucid `toSQL`). `.sql` uses `?`
2125
+ * placeholders (Knex-normalized, like Lucid); `.toNative()` yields the native
2126
+ * form. Returns `bindings` (Lucid's name) and `params` (atlas's) — same array.
2127
+ */
2128
+ toSQL() {
2129
+ const { sql, params } = this.#compiledNative();
2130
+ return compiledStatement(sql, params);
2131
+ }
2132
+ /** `{ sql, bindings }` — the compiled native query (Lucid/Knex `toNative`). */
2133
+ toNative() {
2134
+ const { sql, params } = this.#compiledNative();
2135
+ return { sql, bindings: params };
2136
+ }
2137
+ /** The model class this query targets (Adonis Lucid `query.model`). */
2138
+ get model() {
2139
+ return this.#entityClass;
2140
+ }
2141
+ /** Apply `cb` only on the given dialect(s) (Lucid `ifDialect`; Lucid names accepted). */
2142
+ ifDialect(dialect, cb) {
2143
+ const set = (Array.isArray(dialect) ? dialect : [dialect]).map(normalizeDialect);
2144
+ if (set.includes(this.#dialect))
2145
+ cb(this);
2146
+ return this;
2147
+ }
2148
+ /** Apply `cb` on every dialect EXCEPT the given one(s) (Lucid `unlessDialect`). */
2149
+ unlessDialect(dialect, cb) {
2150
+ const set = (Array.isArray(dialect) ? dialect : [dialect]).map(normalizeDialect);
2151
+ if (!set.includes(this.#dialect))
2152
+ cb(this);
2153
+ return this;
2154
+ }
2155
+ /** Prepend a `/* … *​/` SQL comment to the compiled query (Lucid/Knex `comment`). */
2156
+ comment(text) {
2157
+ if (text.includes("*/")) {
2158
+ throw new Error("comment() text may not contain '*/'");
2159
+ }
2160
+ this.#comments.push(text);
2161
+ return this;
2162
+ }
2163
+ /**
2164
+ * Set a caller-facing statement timeout in milliseconds (Lucid `timeout(ms)`).
2165
+ * The awaiting promise rejects after `ms` on the primary result fetch. With
2166
+ * `{ cancel: true }` a SERVER-side statement timeout is also applied — Postgres
2167
+ * `statement_timeout`, MySQL `MAX_EXECUTION_TIME` (SELECT) — so the server
2168
+ * aborts the query, not just the client. (SQLite has no server timeout; the
2169
+ * client race applies.) Called with no argument it clears the timeout.
2170
+ */
2171
+ timeout(ms, options) {
2172
+ // `{ cancel: true }` also applies a SERVER-side statement timeout (Postgres
2173
+ // statement_timeout / MySQL MAX_EXECUTION_TIME for SELECT); SQLite has none,
2174
+ // so the client race applies there.
2175
+ this.#timeoutMs = ms;
2176
+ this.#cancelTimeout = options?.cancel === true;
2177
+ return this;
2178
+ }
2179
+ /**
2180
+ * Race `work` against the configured `.timeout(ms)`. Rejects the awaiter after
2181
+ * `ms`; the losing DB promise is swallowed so a post-timeout driver error never
2182
+ * surfaces as an unhandled rejection. No timeout set → returns `work` as-is.
2183
+ */
2184
+ #raceTimeout(work) {
2185
+ const ms = this.#timeoutMs;
2186
+ if (!ms || ms <= 0)
2187
+ return work;
2188
+ let timer;
2189
+ const guard = new Promise((_, reject) => {
2190
+ timer = setTimeout(() => reject(new Error(`Query timed out after ${ms}ms`)), ms);
2191
+ });
2192
+ work.catch(() => { });
2193
+ return Promise.race([work, guard]).finally(() => clearTimeout(timer));
2194
+ }
2195
+ /** The `/* … *​/` prefix for the compiled SQL, or empty when no comments. */
2196
+ /** Compile the registered CTEs to the wire shape — shared by SELECT and DML. */
2197
+ #compiledCtes() {
2198
+ return this.#ctes.map((c) => {
2199
+ const { sql, params } = c.query.#compiledNative();
2200
+ return {
2201
+ name: c.name,
2202
+ sql,
2203
+ params,
2204
+ recursive: c.recursive ?? false,
2205
+ materialized: c.materialized ?? null,
2206
+ columns: c.columns ?? [],
2207
+ };
2208
+ });
2209
+ }
2210
+ #commentPrefix() {
2211
+ return this.#comments.length > 0
2212
+ ? `${this.#comments.map((c) => `/* ${c} */`).join(" ")} `
2213
+ : "";
1791
2214
  }
1792
2215
  /**
1793
2216
  * Cached exec result. Memoizing the promise makes the builder a one-shot
@@ -1811,8 +2234,8 @@ export class ModelQuery {
1811
2234
  return results;
1812
2235
  }
1813
2236
  async #doExec() {
1814
- const { sql, params } = this.toSQL();
1815
- const rawRows = await this.#db.query(sql, params, this.#meta("exec"));
2237
+ const { sql, params } = this.#compiledNative();
2238
+ const rawRows = await this.#raceTimeout(this.#db.query(sql, params, this.#meta("exec")));
1816
2239
  // Peel withCount / withAggregate alias columns off the raw row into $extras
1817
2240
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
1818
2241
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -1836,6 +2259,14 @@ export class ModelQuery {
1836
2259
  if (this.#preloads.size > 0 && this.#entityClass && entities.length > 0) {
1837
2260
  await this.#resolvePreloads(entities);
1838
2261
  }
2262
+ // rowTransformer callbacks run per instance AFTER preloads, before resolve
2263
+ // (Lucid), so a transformer can read the eager-loaded relations.
2264
+ if (this.#rowTransformers.length > 0) {
2265
+ for (const entity of entities) {
2266
+ for (const transform of this.#rowTransformers)
2267
+ transform.run(entity);
2268
+ }
2269
+ }
1839
2270
  return entities;
1840
2271
  }
1841
2272
  /**
@@ -1845,16 +2276,20 @@ export class ModelQuery {
1845
2276
  * instances aren't needed.
1846
2277
  */
1847
2278
  async pojo() {
1848
- const { sql, params } = this.toSQL();
2279
+ const { sql, params } = this.#compiledNative();
1849
2280
  return this.#db.query(sql, params);
1850
2281
  }
1851
2282
  /**
1852
2283
  * Thread arbitrary context onto every instance this query hydrates, exposed as
1853
2284
  * `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
1854
- * user, so hooks/computed can read it. Merges across calls. Chainable.
2285
+ * user, so hooks/computed can read it. REPLACES the current sideloaded data by
2286
+ * default (Lucid); pass `merge = true` to merge instead. The data also
2287
+ * propagates to preloaded relation queries. Chainable.
1855
2288
  */
1856
- sideload(values) {
1857
- this.#sideloaded = { ...this.#sideloaded, ...values };
2289
+ sideload(values, merge = false) {
2290
+ this.#sideloaded = merge
2291
+ ? { ...this.#sideloaded, ...values }
2292
+ : { ...values };
1858
2293
  return this;
1859
2294
  }
1860
2295
  /** Resolve preloaded relations via batched subqueries (no N+1). */
@@ -1870,6 +2305,14 @@ export class ModelQuery {
1870
2305
  if (!ctx)
1871
2306
  continue;
1872
2307
  const allRelated = await this.#resolveOneRelation(entities, relationName, relation.type, ctx);
2308
+ // Propagate the parent query's sideloaded context onto the preloaded
2309
+ // relation instances (Adonis Lucid). The related row's own sideloaded
2310
+ // data (if any) wins over the inherited context.
2311
+ if (this.#sideloaded) {
2312
+ for (const related of allRelated) {
2313
+ related.$sideloaded = { ...this.#sideloaded, ...related.$sideloaded };
2314
+ }
2315
+ }
1873
2316
  await this.#applyNestedPreloads(allRelated, ctx);
1874
2317
  }
1875
2318
  }
@@ -1885,7 +2328,13 @@ export class ModelQuery {
1885
2328
  // entities using Adonis' `declare field: T` pattern have no own-properties
1886
2329
  // on a freshly constructed instance, so `key in entity` is always false and
1887
2330
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
1888
- const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
2331
+ // For m2m the pivot's otherKey references `relatedKey` (default the related
2332
+ // PK); the load must filter/index rows by THAT column, not always the PK.
2333
+ const relatedPkName = (relation.type === "manyToMany"
2334
+ ? relation.pivot?.relatedKey
2335
+ : undefined) ??
2336
+ getPrimaryKey(relatedClass) ??
2337
+ "id";
1889
2338
  const validColumns = new Set();
1890
2339
  // Reverse map (db column → property) so an explicit `@Column({ columnName })`
1891
2340
  // on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
@@ -2148,9 +2597,13 @@ export class ModelQuery {
2148
2597
  // also runs (again) inside runRelationQuery against the related table; both
2149
2598
  // runs are pure builder mutations, and pivot constraints are inert there.
2150
2599
  const pivotWheres = [];
2600
+ // Query-time pivotColumns([...]) requested in the preload callback (replayed
2601
+ // on the scratch builder below) OR directly on this query (related().query()).
2602
+ const extraPivotCols = [...this.#extraPivotColumns];
2151
2603
  if (ctx.nestedCallback) {
2152
2604
  const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
2153
2605
  ctx.nestedCallback(scratch);
2606
+ extraPivotCols.push(...scratch.pivotColumnRequests);
2154
2607
  // Apply the pivot column adapters' `prepare` to wherePivot values, so a
2155
2608
  // filter like wherePivot('amount', new Money(1)) matches what attach()/
2156
2609
  // sync() stored (they prepare the same extras on write).
@@ -2193,7 +2646,11 @@ export class ModelQuery {
2193
2646
  ];
2194
2647
  // Step 2 — load all related entities in one query
2195
2648
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
2196
- const pivotCols = pivot.pivotColumns ?? [];
2649
+ // Declared pivot columns (decorator) + any requested at query time
2650
+ // (`pivotColumns([...])`, from the callback or this query), de-duplicated.
2651
+ const pivotCols = [
2652
+ ...new Set([...(pivot.pivotColumns ?? []), ...extraPivotCols]),
2653
+ ];
2197
2654
  const pivotAdapters = pivot.pivotColumnAdapters ?? {};
2198
2655
  // When pivot extras are projected, each (parent, related) edge gets its OWN
2199
2656
  // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
@@ -2328,9 +2785,52 @@ export class ModelQuery {
2328
2785
  relation.onQuery(sub);
2329
2786
  if (userCallback)
2330
2787
  userCallback(sub);
2331
- const { sql, params } = sub.toSQL();
2788
+ // Top-N per parent (Lucid groupLimit) → a ROW_NUMBER() window over the fk.
2789
+ if (sub.#groupLimit != null) {
2790
+ return this.#runGroupLimited(sub, column, sub.#groupLimit);
2791
+ }
2792
+ const { sql, params } = sub.#compiledNative();
2332
2793
  return this.#db.query(sql, params);
2333
2794
  }
2795
+ /**
2796
+ * Run a has-many preload query capped to `n` rows PER PARENT (Lucid
2797
+ * `groupLimit`). Wraps the inner query with a `ROW_NUMBER() OVER (PARTITION BY
2798
+ * <fk> ORDER BY <groupOrderBy | pk>)` window, then keeps `rn <= n`. Supported on
2799
+ * Postgres, MySQL 8+, and SQLite 3.25+ (all have window functions).
2800
+ */
2801
+ async #runGroupLimited(sub, fkColumn, n) {
2802
+ const quoteSeg = (name) => {
2803
+ const qc = this.#dialect === "mysql" ? "`" : '"';
2804
+ return name
2805
+ .split(".")
2806
+ .map((s) => {
2807
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
2808
+ throw new Error(`groupLimit: unsafe identifier '${name}'`);
2809
+ }
2810
+ return `${qc}${s}${qc}`;
2811
+ })
2812
+ .join(".");
2813
+ };
2814
+ const orderCol = sub.#groupOrderBy
2815
+ ? sub.#resolveColumn(sub.#groupOrderBy.column)
2816
+ : (getPrimaryKey(sub.#entityClass) ?? "id");
2817
+ const dir = sub.#groupOrderBy?.direction === "desc" ? "DESC" : "ASC";
2818
+ sub.#selectRaw.push({
2819
+ sql: `ROW_NUMBER() OVER (PARTITION BY ${quoteSeg(fkColumn)} ORDER BY ${quoteSeg(orderCol)} ${dir}) AS __atlas_rn`,
2820
+ params: [],
2821
+ });
2822
+ const inner = sub.#compiledNative();
2823
+ const ph = this.#dialect === "postgres" ? `$${inner.params.length + 1}` : "?";
2824
+ const outerSql = `SELECT * FROM (${inner.sql}) AS __atlas_grp WHERE __atlas_rn <= ${ph}`;
2825
+ const rows = await this.#db.query(outerSql, [
2826
+ ...inner.params,
2827
+ n,
2828
+ ]);
2829
+ // Strip the window helper column so it doesn't leak into hydration.
2830
+ for (const row of rows)
2831
+ delete row.__atlas_rn;
2832
+ return rows;
2833
+ }
2334
2834
  /**
2335
2835
  * Build a correlated subquery over a relation. Returns `SubqueryProjection`
2336
2836
  * used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
@@ -2448,14 +2948,26 @@ export class ModelQuery {
2448
2948
  }
2449
2949
  return sub;
2450
2950
  }
2451
- innerJoin(table, leftOrBuild, right) {
2452
- return this.#pushJoin("INNER", table, leftOrBuild, right);
2951
+ join(table, leftOrBuild, operatorOrRight, right) {
2952
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
2953
+ }
2954
+ innerJoin(table, leftOrBuild, operatorOrRight, right) {
2955
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
2956
+ }
2957
+ leftJoin(table, leftOrBuild, operatorOrRight, right) {
2958
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
2959
+ }
2960
+ leftOuterJoin(table, leftOrBuild, operatorOrRight, right) {
2961
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
2453
2962
  }
2454
- leftJoin(table, leftOrBuild, right) {
2455
- return this.#pushJoin("LEFT", table, leftOrBuild, right);
2963
+ rightJoin(table, leftOrBuild, operatorOrRight, right) {
2964
+ return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
2456
2965
  }
2457
- rightJoin(table, leftOrBuild, right) {
2458
- return this.#pushJoin("RIGHT", table, leftOrBuild, right);
2966
+ rightOuterJoin(table, leftOrBuild, operatorOrRight, right) {
2967
+ return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
2968
+ }
2969
+ fullOuterJoin(table, leftOrBuild, operatorOrRight, right) {
2970
+ return this.#pushJoin("FULL OUTER", table, leftOrBuild, operatorOrRight, right);
2459
2971
  }
2460
2972
  crossJoin(table) {
2461
2973
  const tq = this.#quoteCol(table);
@@ -2501,8 +3013,15 @@ export class ModelQuery {
2501
3013
  return this.innerJoin(table, left, right);
2502
3014
  }
2503
3015
  // === Story 29.5 — aggregates / exists / pluck =====================================================
2504
- distinct() {
3016
+ /**
3017
+ * SELECT DISTINCT (Lucid/Knex `distinct`). With columns, those replace the
3018
+ * projection too — `distinct('a', 'b')` ≈ `SELECT DISTINCT a, b`.
3019
+ */
3020
+ distinct(...columns) {
2505
3021
  this.#distinct = true;
3022
+ if (columns.length > 0) {
3023
+ this.#select = columns.map((c) => this.#resolveSelect(c));
3024
+ }
2506
3025
  return this;
2507
3026
  }
2508
3027
  /**
@@ -2542,7 +3061,7 @@ export class ModelQuery {
2542
3061
  const clone = this.clone();
2543
3062
  clone.#select = ["1"];
2544
3063
  clone.#limit = 1;
2545
- const { sql, params } = clone.toSQL();
3064
+ const { sql, params } = clone.#compiledNative();
2546
3065
  const rows = await this.#db.query(sql, params, this.#meta("exists"));
2547
3066
  return rows.length > 0;
2548
3067
  }
@@ -2554,7 +3073,7 @@ export class ModelQuery {
2554
3073
  const col = this.#resolveColumn(column);
2555
3074
  const clone = this.clone();
2556
3075
  clone.#select = [col];
2557
- const { sql, params } = clone.toSQL();
3076
+ const { sql, params } = clone.#compiledNative();
2558
3077
  const rows = await this.#db.query(sql, params);
2559
3078
  return rows.map((row) => {
2560
3079
  const v = row[col];
@@ -2599,9 +3118,11 @@ export class ModelQuery {
2599
3118
  elseFn(this);
2600
3119
  return this;
2601
3120
  }
2602
- unless(condition, fn) {
3121
+ unless(condition, fn, elseFn) {
2603
3122
  if (!condition)
2604
3123
  fn(this);
3124
+ else if (elseFn)
3125
+ elseFn(this);
2605
3126
  return this;
2606
3127
  }
2607
3128
  // === Story 29.10 — pagination =====================================================================
@@ -2609,11 +3130,17 @@ export class ModelQuery {
2609
3130
  async paginate(page, perPage) {
2610
3131
  const p = Math.max(1, Math.floor(page));
2611
3132
  const pp = Math.max(1, Math.floor(perPage));
2612
- // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
2613
- // tenant scope) propagates into both the COUNT and the data fetch.
2614
- await fireHooks(this.#entityClass, "beforePaginate", this);
2615
- // COUNT(*) + data fetch
3133
+ // Adonis Lucid hook order:
3134
+ // beforePaginate([countQuery, query]) beforeFetch(query)
3135
+ // → (count + data queries) → afterPaginate(paginator) → afterFetch(rows)
3136
+ // The COUNT and data builders are separate clones so a beforePaginate hook
3137
+ // can constrain BOTH (keeping totals in sync), exactly as documented.
2616
3138
  const countQ = this.clone();
3139
+ const dataQ = this.clone();
3140
+ await fireHooks(this.#entityClass, "beforePaginate", [countQ, dataQ]);
3141
+ // beforeFetch fires on the main (data) query before either query runs.
3142
+ await fireHooks(this.#entityClass, "beforeFetch", dataQ);
3143
+ // COUNT(*) — strip pagination/order noise from the count clone.
2617
3144
  countQ.#limit = undefined;
2618
3145
  countQ.#offset = undefined;
2619
3146
  countQ.#orderBys = [];
@@ -2624,29 +3151,32 @@ export class ModelQuery {
2624
3151
  // group's own size), so `rows[0].count` would be the first group's size, not
2625
3152
  // the number of pages. Lucid counts via a subquery: wrap the grouped query
2626
3153
  // (select + groupBy + having preserved) and count its rows = group count.
2627
- const inner = countQ.toSQL();
3154
+ const inner = countQ.#compiledNative();
2628
3155
  cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
2629
3156
  cParams = inner.params;
2630
3157
  }
2631
3158
  else {
2632
3159
  countQ.#select = ["COUNT(*) AS count"];
2633
- const flat = countQ.toSQL();
3160
+ const flat = countQ.#compiledNative();
2634
3161
  cSql = flat.sql;
2635
3162
  cParams = flat.params;
2636
3163
  }
2637
3164
  const cRows = await this.#db.query(cSql, cParams, this.#meta("paginate"));
2638
3165
  const total = Number(cRows[0]?.count ?? 0);
2639
- const dataQ = this.clone();
2640
3166
  dataQ.#limit = pp;
2641
3167
  dataQ.#offset = (p - 1) * pp;
2642
- // `#doExec` (not `exec`) so the generic beforeFetch/afterFetch don't fire on
2643
- // top of the paginate hooks paginate is its own terminal.
3168
+ // `#doExec` runs the raw fetch + preloads. beforeFetch already fired above;
3169
+ // afterFetch fires AFTER afterPaginate (Lucid order), so #doExec must not
3170
+ // fire either itself — hence #doExec, not exec().
2644
3171
  const items = await dataQ.#doExec();
2645
- await fireHooks(this.#entityClass, "afterPaginate", items);
2646
3172
  const metaKeys = this.#entityClass
2647
3173
  ? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
2648
3174
  : undefined;
2649
- return new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
3175
+ const paginator = new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
3176
+ // Lucid: afterPaginate(paginator) then afterFetch(rows).
3177
+ await fireHooks(this.#entityClass, "afterPaginate", paginator);
3178
+ await fireHooks(this.#entityClass, "afterFetch", items);
3179
+ return paginator;
2650
3180
  }
2651
3181
  /**
2652
3182
  * Cursor-based pagination — base64 opaque keyset, multi-column aware.
@@ -2729,6 +3259,17 @@ export class ModelQuery {
2729
3259
  this.#debugFlag = flag;
2730
3260
  return this;
2731
3261
  }
3262
+ /**
3263
+ * Attach arbitrary metadata to the `db:query` event this query emits (Adonis
3264
+ * Lucid `reporterData`) — request id, user id, feature flag, … A listener
3265
+ * reads it off `event.reporterData`. Repeated calls merge. Setting it also
3266
+ * forces emission (like {@link debug}), so the data actually reaches a listener.
3267
+ */
3268
+ reporterData(data) {
3269
+ this.#reporterData = { ...this.#reporterData, ...data };
3270
+ this.#debugFlag = true;
3271
+ return this;
3272
+ }
2732
3273
  /**
2733
3274
  * Context attached to each statement this query runs, so a `db:query`
2734
3275
  * listener can say which model and which call produced it — and so
@@ -2742,16 +3283,16 @@ export class ModelQuery {
2742
3283
  model: this.#entityClass.name,
2743
3284
  method,
2744
3285
  debug: this.#debugFlag,
3286
+ reporterData: this.#reporterData,
3287
+ serverTimeoutMs: this.#cancelTimeout && this.#timeoutMs != null
3288
+ ? this.#timeoutMs
3289
+ : undefined,
2745
3290
  };
2746
3291
  }
2747
3292
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
2748
3293
  toQuery() {
2749
- const { sql, params } = this.toSQL();
2750
- let i = 0;
2751
- return sql.replace(/\?|\$\d+/g, () => {
2752
- const v = params[i++];
2753
- return this.#literalEscape(v);
2754
- });
3294
+ const { sql, params } = this.#compiledNative();
3295
+ return interpolateQuery(sql, params);
2755
3296
  }
2756
3297
  /** Deep clone of this query — mutations on the clone never affect the original. */
2757
3298
  clone() {
@@ -2761,9 +3302,20 @@ export class ModelQuery {
2761
3302
  c.#orderBys = [...this.#orderBys];
2762
3303
  c.#select = [...this.#select];
2763
3304
  c.#limit = this.#limit;
3305
+ c.#groupLimit = this.#groupLimit;
3306
+ c.#groupOrderBy = this.#groupOrderBy
3307
+ ? { ...this.#groupOrderBy }
3308
+ : undefined;
2764
3309
  c.#offset = this.#offset;
2765
3310
  c.#preloads = new Map(this.#preloads);
3311
+ c.#rowTransformers = [...this.#rowTransformers];
2766
3312
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
3313
+ c.#selectRaw = this.#selectRaw.map((s) => ({
3314
+ ...s,
3315
+ params: [...s.params],
3316
+ }));
3317
+ c.#timeoutMs = this.#timeoutMs;
3318
+ c.#cancelTimeout = this.#cancelTimeout;
2767
3319
  c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
2768
3320
  c.#lockMode = this.#lockMode;
2769
3321
  c.#lockModifier = this.#lockModifier;
@@ -2777,6 +3329,7 @@ export class ModelQuery {
2777
3329
  query: e.query.clone(),
2778
3330
  recursive: e.recursive,
2779
3331
  materialized: e.materialized,
3332
+ columns: e.columns ? [...e.columns] : undefined,
2780
3333
  }));
2781
3334
  c.#unions = this.#unions.map((u) => ({
2782
3335
  query: u.query.clone(),
@@ -2784,34 +3337,41 @@ export class ModelQuery {
2784
3337
  op: u.op,
2785
3338
  }));
2786
3339
  c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
3340
+ c.#extraPivotColumns = [...this.#extraPivotColumns];
2787
3341
  // Pure closure over pivot metadata — safe to share by reference; it reads the
2788
3342
  // clone's own #pivotWheres at build time (passed in), holding no query state.
2789
3343
  c.#pivotExists = this.#pivotExists;
2790
3344
  c.#debugFlag = this.#debugFlag;
3345
+ c.#reporterData = this.#reporterData
3346
+ ? { ...this.#reporterData }
3347
+ : undefined;
3348
+ c.#comments = [...this.#comments];
2791
3349
  return c;
2792
3350
  }
2793
- // === Story 30.2 — update / delete fluent ===========================================================
2794
- /** Execute a fluent UPDATE. Returns affected rows (or rows when `returning` is set). */
2795
- async update(patch, returning) {
3351
+ update(patchOrColumn, valueOrReturning) {
3352
+ const patch = typeof patchOrColumn === "string"
3353
+ ? { [patchOrColumn]: valueOrReturning }
3354
+ : patchOrColumn;
3355
+ const returning = typeof patchOrColumn === "string"
3356
+ ? undefined
3357
+ : valueOrReturning;
2796
3358
  if (!patch || Object.keys(patch).length === 0) {
2797
3359
  throw new Error("update() requires a non-empty payload");
2798
3360
  }
2799
3361
  // Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
2800
3362
  // like BaseRepository's write paths — the fluent update() must not bypass it.
2801
- const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), this.#prepareValue(k, v)]);
2802
- const spec = {
3363
+ // A `db.raw(...)` value is emitted as a raw SET expression instead.
3364
+ const setPairs = Object.entries(patch).map(([k, v]) => v instanceof RawSql
3365
+ ? [this.#resolveColumn(k), { raw: v.sql, rawParams: [...v.params] }]
3366
+ : [this.#resolveColumn(k), this.#prepareValue(k, v)]);
3367
+ return this.#makeDml((r) => ({
2803
3368
  kind: "update",
2804
3369
  table: this.#tableName,
2805
3370
  set: setPairs,
2806
3371
  wheres: this.#wheresForDml(),
2807
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2808
- };
2809
- const compiled = compileStatementNative(spec, this.#dialect);
2810
- if (returning && returning.length > 0) {
2811
- return this.#db.query(compiled.statements[0], compiled.params);
2812
- }
2813
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
2814
- return r.rowsAffected ?? 0;
3372
+ returning: r,
3373
+ ctes: this.#compiledCtes(),
3374
+ }), returning);
2815
3375
  }
2816
3376
  /**
2817
3377
  * Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
@@ -2820,28 +3380,29 @@ export class ModelQuery {
2820
3380
  * model it issues a hard `DELETE`. Returns affected rows (or rows when
2821
3381
  * `returning` is set).
2822
3382
  */
2823
- async delete(returning) {
3383
+ delete(returning) {
2824
3384
  if (this.#softDeletes) {
2825
- const spec = {
3385
+ const stampedAt = new Date().toISOString();
3386
+ return this.#makeDml((r) => ({
2826
3387
  kind: "update",
2827
3388
  table: this.#tableName,
2828
- set: [[this.#deletedAtColumn(), new Date().toISOString()]],
3389
+ set: [[this.#deletedAtColumn(), stampedAt]],
2829
3390
  wheres: this.#wheresForDml(),
2830
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2831
- };
2832
- return this.#runDml(spec, returning);
3391
+ returning: r,
3392
+ ctes: this.#compiledCtes(),
3393
+ }), returning);
2833
3394
  }
2834
3395
  return this.forceDelete(returning);
2835
3396
  }
2836
3397
  /** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
2837
- async forceDelete(returning) {
2838
- const spec = {
3398
+ forceDelete(returning) {
3399
+ return this.#makeDml((r) => ({
2839
3400
  kind: "delete",
2840
3401
  table: this.#tableName,
2841
3402
  wheres: this.#wheresForDml(),
2842
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2843
- };
2844
- return this.#runDml(spec, returning);
3403
+ returning: r,
3404
+ ctes: this.#compiledCtes(),
3405
+ }), returning);
2845
3406
  }
2846
3407
  /**
2847
3408
  * Bulk restore: clear `deleted_at` on the trashed rows matching the user's
@@ -2849,24 +3410,27 @@ export class ModelQuery {
2849
3410
  * on a non-soft-delete model. Independent of the current soft-scope — it always
2850
3411
  * targets trashed rows (`deleted_at IS NOT NULL`).
2851
3412
  */
2852
- async restore(returning) {
2853
- if (!this.#softDeletes)
2854
- return 0;
2855
- const wheres = this.#userWheresForDml();
2856
- wheres.push({
2857
- column: this.#deletedAtColumn(),
2858
- operator: "IS NOT NULL",
2859
- value: null,
2860
- type: "and",
2861
- });
2862
- const spec = {
2863
- kind: "update",
2864
- table: this.#tableName,
2865
- set: [[this.#deletedAtColumn(), null]],
2866
- wheres,
2867
- returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2868
- };
2869
- return this.#runDml(spec, returning);
3413
+ restore(returning) {
3414
+ if (!this.#softDeletes) {
3415
+ return new DmlBuilder(() => Promise.resolve(0), () => ({ sql: "", bindings: [], params: [] }), this.#dmlHooks());
3416
+ }
3417
+ return this.#makeDml((r) => {
3418
+ const wheres = this.#userWheresForDml();
3419
+ wheres.push({
3420
+ column: this.#deletedAtColumn(),
3421
+ operator: "IS NOT NULL",
3422
+ value: null,
3423
+ type: "and",
3424
+ });
3425
+ return {
3426
+ kind: "update",
3427
+ table: this.#tableName,
3428
+ set: [[this.#deletedAtColumn(), null]],
3429
+ wheres,
3430
+ returning: r,
3431
+ ctes: this.#compiledCtes(),
3432
+ };
3433
+ }, returning);
2870
3434
  }
2871
3435
  increment(colOrPatch, amount = 1) {
2872
3436
  return this.#runIncDec("increment", colOrPatch, amount);
@@ -2958,77 +3522,172 @@ export class ModelQuery {
2958
3522
  .map((seg) => this.#quote(seg))
2959
3523
  .join(".");
2960
3524
  }
2961
- #pushJoin(kind, table, leftOrBuild, right) {
3525
+ #pushJoin(kind, table, leftOrBuild, operatorOrRight, right) {
2962
3526
  const tq = this.#quoteCol(table);
2963
3527
  if (typeof leftOrBuild === "function") {
3528
+ const parts = [];
2964
3529
  const jb = {
2965
- parts: [],
2966
- on(l, r) {
2967
- this.parts.push({ kind: "and", left: l, right: r });
2968
- return this;
3530
+ parts,
3531
+ on: (l, opOrR, r) => {
3532
+ parts.push(r === undefined
3533
+ ? { kind: "and", left: l, right: opOrR }
3534
+ : { kind: "and", left: l, operator: opOrR, right: r });
3535
+ return jb;
3536
+ },
3537
+ andOn: (l, opOrR, r) => {
3538
+ parts.push(r === undefined
3539
+ ? { kind: "and", left: l, right: opOrR }
3540
+ : { kind: "and", left: l, operator: opOrR, right: r });
3541
+ return jb;
3542
+ },
3543
+ orOn: (l, opOrR, r) => {
3544
+ parts.push(r === undefined
3545
+ ? { kind: "or", left: l, right: opOrR }
3546
+ : { kind: "or", left: l, operator: opOrR, right: r });
3547
+ return jb;
3548
+ },
3549
+ onVal: (l, v) => {
3550
+ parts.push({ kind: "and", left: l, value: { v } });
3551
+ return jb;
3552
+ },
3553
+ andOnVal: (l, v) => {
3554
+ parts.push({ kind: "and", left: l, value: { v } });
3555
+ return jb;
2969
3556
  },
2970
- andOn(l, r) {
2971
- this.parts.push({ kind: "and", left: l, right: r });
2972
- return this;
3557
+ orOnVal: (l, v) => {
3558
+ parts.push({ kind: "or", left: l, value: { v } });
3559
+ return jb;
2973
3560
  },
2974
- orOn(l, r) {
2975
- this.parts.push({ kind: "or", left: l, right: r });
2976
- return this;
3561
+ onIn: (l, values) => {
3562
+ parts.push({ kind: "and", left: l, values: [...values] });
3563
+ return jb;
2977
3564
  },
2978
- onVal(l, v) {
2979
- this.parts.push({ kind: "and", left: l, value: { v } });
2980
- return this;
3565
+ onNotIn: (l, values) => {
3566
+ parts.push({
3567
+ kind: "and",
3568
+ left: l,
3569
+ values: [...values],
3570
+ notIn: true,
3571
+ });
3572
+ return jb;
3573
+ },
3574
+ onNull: (l) => {
3575
+ parts.push({ kind: "and", left: l, nullOp: "IS NULL" });
3576
+ return jb;
3577
+ },
3578
+ onNotNull: (l) => {
3579
+ parts.push({ kind: "and", left: l, nullOp: "IS NOT NULL" });
3580
+ return jb;
2981
3581
  },
2982
- andOnVal(l, v) {
2983
- this.parts.push({ kind: "and", left: l, value: { v } });
2984
- return this;
3582
+ onBetween: (l, range) => {
3583
+ parts.push({ kind: "and", left: l, between: [range[0], range[1]] });
3584
+ return jb;
3585
+ },
3586
+ onNotBetween: (l, range) => {
3587
+ parts.push({
3588
+ kind: "and",
3589
+ left: l,
3590
+ between: [range[0], range[1]],
3591
+ notBetween: true,
3592
+ });
3593
+ return jb;
2985
3594
  },
2986
- orOnVal(l, v) {
2987
- this.parts.push({ kind: "or", left: l, value: { v } });
2988
- return this;
3595
+ onExists: (sub) => {
3596
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
3597
+ parts.push({ kind: "and", exists: { sql, params, not: false } });
3598
+ return jb;
3599
+ },
3600
+ onNotExists: (sub) => {
3601
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
3602
+ parts.push({ kind: "and", exists: { sql, params, not: true } });
3603
+ return jb;
2989
3604
  },
2990
3605
  };
2991
3606
  leftOrBuild(jb);
2992
- // Collect the bound values in placeholder order as the fragment is built.
3607
+ // A BASE-table column runs the full model prepare (DateTime→ISO + @Column
3608
+ // adapters/casts), keyed by its property. A FOREIGN join column must NOT
3609
+ // borrow the root model's adapter for a same-named column on another
3610
+ // table — apply only universal type-lowering, matching Knex's
3611
+ // model-agnostic join binding.
3612
+ const prep = (col, v) => {
3613
+ const dot = col.lastIndexOf(".");
3614
+ const tablePrefix = dot >= 0 ? col.slice(0, dot) : "";
3615
+ const leaf = dot >= 0 ? col.slice(dot + 1) : col;
3616
+ const isBaseColumn = tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
3617
+ return isBaseColumn ? this.#prepareValue(leaf, v) : lowerJoinValue(v);
3618
+ };
2993
3619
  const params = [];
2994
- const on = jb.parts
3620
+ const on = parts
2995
3621
  .map((p, i) => {
2996
3622
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
3623
+ if (p.exists) {
3624
+ params.push(...p.exists.params);
3625
+ return `${prefix} ${p.exists.not ? "NOT EXISTS" : "EXISTS"} (${p.exists.sql})`;
3626
+ }
3627
+ const col = this.#quoteCol(p.left ?? "");
3628
+ if (p.nullOp) {
3629
+ return `${prefix} ${col} ${p.nullOp}`;
3630
+ }
3631
+ if (p.between) {
3632
+ params.push(prep(p.left ?? "", p.between[0]), prep(p.left ?? "", p.between[1]));
3633
+ return `${prefix} ${col} ${p.notBetween ? "NOT BETWEEN" : "BETWEEN"} ? AND ?`;
3634
+ }
3635
+ if (p.values) {
3636
+ const placeholders = p.values.map(() => "?").join(", ");
3637
+ for (const v of p.values)
3638
+ params.push(prep(p.left ?? "", v));
3639
+ return `${prefix} ${col} ${p.notIn ? "NOT IN" : "IN"} (${placeholders})`;
3640
+ }
2997
3641
  if (p.value) {
2998
- // A BASE-table column runs the full model prepare (DateTime→ISO +
2999
- // @Column adapters/casts), keyed by its property. A FOREIGN join
3000
- // column must NOT borrow the root model's adapter for a same-named
3001
- // column on another table — apply only universal type-lowering
3002
- // (Date/DateTime→ISO), matching Knex's model-agnostic join binding.
3003
- const dot = p.left.lastIndexOf(".");
3004
- const tablePrefix = dot >= 0 ? p.left.slice(0, dot) : "";
3005
- const leaf = dot >= 0 ? p.left.slice(dot + 1) : p.left;
3006
- const isBaseColumn = tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
3007
- params.push(isBaseColumn
3008
- ? this.#prepareValue(leaf, p.value.v)
3009
- : lowerJoinValue(p.value.v));
3010
- return `${prefix} ${this.#quoteCol(p.left)} = ?`;
3642
+ params.push(prep(p.left ?? "", p.value.v));
3643
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ?`;
3011
3644
  }
3012
- return `${prefix} ${this.#quoteCol(p.left)} = ${this.#quoteCol(p.right ?? "")}`;
3645
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ${this.#quoteCol(p.right ?? "")}`;
3013
3646
  })
3014
3647
  .join(" ");
3015
3648
  this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
3016
3649
  return this;
3017
3650
  }
3018
- if (right === undefined)
3651
+ // String form: 3-arg `(left, right)` or 4-arg `(left, operator, right)`.
3652
+ const left = leftOrBuild;
3653
+ const operator = right === undefined ? "=" : (operatorOrRight ?? "=");
3654
+ const rightCol = right === undefined ? operatorOrRight : right;
3655
+ if (rightCol === undefined)
3019
3656
  throw new Error("join() with string form requires both left and right operands");
3020
3657
  this.#joins.push({
3021
- sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
3658
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(left)} ${this.#validateJoinOp(operator)} ${this.#quoteCol(rightCol)}`,
3022
3659
  params: [],
3023
3660
  });
3024
3661
  return this;
3025
3662
  }
3663
+ /** Allowlist the comparison operator embedded verbatim into a JOIN's ON SQL. */
3664
+ #validateJoinOp(op) {
3665
+ const t = op.trim();
3666
+ const up = t.toUpperCase();
3667
+ const allowed = new Set([
3668
+ "=",
3669
+ "<>",
3670
+ "!=",
3671
+ "<",
3672
+ "<=",
3673
+ ">",
3674
+ ">=",
3675
+ "LIKE",
3676
+ "NOT LIKE",
3677
+ "ILIKE",
3678
+ ]);
3679
+ if (allowed.has(t))
3680
+ return t;
3681
+ if (allowed.has(up))
3682
+ return up;
3683
+ throw new Error(`Unsupported join operator '${op}'.`);
3684
+ }
3026
3685
  async #runScalar(expr) {
3027
3686
  const clone = this.clone();
3028
3687
  clone.#select = [`${expr} AS __scalar__`];
3029
3688
  clone.#orderBys = [];
3030
- const { sql, params } = clone.toSQL();
3031
- const rows = await this.#db.query(sql, params);
3689
+ const { sql, params } = clone.#compiledNative();
3690
+ const rows = await this.#raceTimeout(this.#db.query(sql, params));
3032
3691
  const row = rows[0];
3033
3692
  return row ? row.__scalar__ : null;
3034
3693
  }
@@ -3041,42 +3700,21 @@ export class ModelQuery {
3041
3700
  set: setPairs,
3042
3701
  wheres: this.#wheresForDml(),
3043
3702
  returning: [],
3703
+ ctes: this.#compiledCtes(),
3044
3704
  };
3045
3705
  const compiled = compileStatementNative(spec, this.#dialect);
3046
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
3706
+ const r = await this.#raceTimeout(this.#db.execute(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
3047
3707
  return r.rowsAffected ?? 0;
3048
3708
  }
3049
3709
  /**
3050
- * Flatten the SELECT wheres to DML-compatible wheres. Standard predicates
3051
- * and `whereRaw` fragments pass through; `group` / `exists` / `inSub` are
3052
- * still rejected because the DML compiler's WHERE lowering does not yet
3053
- * handle nested sub-queries or correlated EXISTS.
3710
+ * The user's own WHERE predicates for DML (no soft-delete scope). The DML
3711
+ * compiler now REUSES the SELECT compiler's WHERE lowering, so every predicate
3712
+ * the read builder accepts standard, `whereRaw`, `whereExists`, sub-queries,
3713
+ * and nested groups is valid for update/delete too. Passed through unchanged
3714
+ * (same as `#buildSpec` does for reads).
3054
3715
  */
3055
- /** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
3056
3716
  #userWheresForDml() {
3057
- const out = [];
3058
- for (const w of this.#wheres) {
3059
- if ("kind" in w) {
3060
- if (w.kind === "raw") {
3061
- out.push({
3062
- kind: "raw",
3063
- sql: w.sql,
3064
- bindings: w.bindings,
3065
- type: w.type,
3066
- });
3067
- continue;
3068
- }
3069
- throw new Error(`update/delete do not support '${w.kind}' WHERE clauses. ` +
3070
- `Supported: plain predicates and whereRaw. Use a raw UPDATE/DELETE for complex criteria.`);
3071
- }
3072
- out.push({
3073
- column: w.column,
3074
- operator: w.operator,
3075
- value: w.value,
3076
- type: w.type,
3077
- });
3078
- }
3079
- return out;
3717
+ return [...this.#wheres];
3080
3718
  }
3081
3719
  #wheresForDml() {
3082
3720
  const out = this.#userWheresForDml();
@@ -3106,13 +3744,55 @@ export class ModelQuery {
3106
3744
  }
3107
3745
  return out;
3108
3746
  }
3747
+ /** Compile a DML spec with the comment prefix — for a lazy builder's `.toSQL()`. */
3748
+ #compileDmlSpec(spec) {
3749
+ const compiled = compileStatementNative(spec, this.#dialect);
3750
+ const sql = this.#commentPrefix() + compiled.statements[0];
3751
+ return { sql, bindings: compiled.params, params: compiled.params };
3752
+ }
3753
+ /** Hooks a lazy DML builder delegates back to (upsert clauses are N/A on a model query). */
3754
+ #dmlHooks() {
3755
+ return {
3756
+ onConflict: () => { },
3757
+ merge: () => { },
3758
+ ignore: () => { },
3759
+ returning: (...cols) => {
3760
+ for (const c of cols) {
3761
+ if (Array.isArray(c))
3762
+ this.#dmlReturning.push(...c);
3763
+ else
3764
+ this.#dmlReturning.push(c);
3765
+ }
3766
+ },
3767
+ timeout: (ms) => {
3768
+ this.#timeoutMs = ms;
3769
+ },
3770
+ comment: (t) => {
3771
+ this.comment(t);
3772
+ },
3773
+ debug: (enabled) => {
3774
+ this.debug(enabled);
3775
+ },
3776
+ reporterData: (data) => {
3777
+ this.reporterData(data);
3778
+ },
3779
+ };
3780
+ }
3781
+ /** Wrap a DML spec-builder as a lazy, chainable, inspectable {@link DmlBuilder}. */
3782
+ #makeDml(build, paramReturning) {
3783
+ const resolved = () => [...(paramReturning ?? []), ...this.#dmlReturning].map((c) => this.#resolveSelect(c));
3784
+ return new DmlBuilder(() => {
3785
+ const r = resolved();
3786
+ return this.#runDml(build(r), r);
3787
+ }, () => this.#compileDmlSpec(build(resolved())), this.#dmlHooks());
3788
+ }
3109
3789
  /** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
3110
3790
  async #runDml(spec, returning) {
3111
3791
  const compiled = compileStatementNative(spec, this.#dialect);
3112
3792
  if (returning && returning.length > 0) {
3113
- return this.#db.query(compiled.statements[0], compiled.params);
3793
+ return this.#raceTimeout(this.#db.query(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
3114
3794
  }
3115
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
3795
+ const r = await this.#raceTimeout(this.#db.execute(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
3116
3796
  return r.rowsAffected ?? 0;
3117
3797
  }
3118
3798
  /**
@@ -3128,18 +3808,6 @@ export class ModelQuery {
3128
3808
  * parameters via the Rust compiler — this escaper is never on the hot path.
3129
3809
  * If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
3130
3810
  */
3131
- #literalEscape(v) {
3132
- if (v === null || v === undefined)
3133
- return "NULL";
3134
- if (typeof v === "number")
3135
- return String(v);
3136
- if (typeof v === "boolean")
3137
- return v ? "1" : "0";
3138
- if (v instanceof Date)
3139
- return `'${v.toISOString()}'`;
3140
- // Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
3141
- return `'${String(v).replace(/'/g, "''")}'`;
3142
- }
3143
3811
  /**
3144
3812
  * Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
3145
3813
  * on the SAME table is used as the scratch builder so the callback can call