@c9up/atlas 0.1.19 → 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 (250) hide show
  1. package/README.md +63 -14
  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 +66 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +205 -39
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +181 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +127 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +250 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +81 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1433 -387
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ConnectionManager.d.ts +106 -0
  24. package/dist/ConnectionManager.d.ts.map +1 -0
  25. package/dist/ConnectionManager.js +228 -0
  26. package/dist/ConnectionManager.js.map +1 -0
  27. package/dist/ModelQuery.d.ts +577 -41
  28. package/dist/ModelQuery.d.ts.map +1 -1
  29. package/dist/ModelQuery.js +2188 -291
  30. package/dist/ModelQuery.js.map +1 -1
  31. package/dist/Transaction.d.ts +71 -1
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +138 -24
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/adapters/NapiDbAdapter.d.ts +44 -4
  36. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  37. package/dist/adapters/NapiDbAdapter.js +131 -13
  38. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  39. package/dist/console/contract.d.ts +62 -0
  40. package/dist/console/contract.d.ts.map +1 -0
  41. package/dist/console/contract.js +38 -0
  42. package/dist/console/contract.js.map +1 -0
  43. package/dist/console/factoryCommands.d.ts +23 -0
  44. package/dist/console/factoryCommands.d.ts.map +1 -0
  45. package/dist/console/factoryCommands.js +62 -0
  46. package/dist/console/factoryCommands.js.map +1 -0
  47. package/dist/console/migrationCommands.d.ts +78 -0
  48. package/dist/console/migrationCommands.d.ts.map +1 -0
  49. package/dist/console/migrationCommands.js +327 -0
  50. package/dist/console/migrationCommands.js.map +1 -0
  51. package/dist/console/schemaCheckCommand.d.ts +11 -17
  52. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  53. package/dist/console/schemaCheckCommand.js +21 -15
  54. package/dist/console/schemaCheckCommand.js.map +1 -1
  55. package/dist/console/schemaDumpCommand.d.ts +30 -0
  56. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  57. package/dist/console/schemaDumpCommand.js +69 -0
  58. package/dist/console/schemaDumpCommand.js.map +1 -0
  59. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  60. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  61. package/dist/console/schemaGenerateCommand.js +246 -0
  62. package/dist/console/schemaGenerateCommand.js.map +1 -0
  63. package/dist/console/seederCommands.d.ts +46 -0
  64. package/dist/console/seederCommands.d.ts.map +1 -0
  65. package/dist/console/seederCommands.js +136 -0
  66. package/dist/console/seederCommands.js.map +1 -0
  67. package/dist/decorators/entity.d.ts +50 -8
  68. package/dist/decorators/entity.d.ts.map +1 -1
  69. package/dist/decorators/entity.js +42 -2
  70. package/dist/decorators/entity.js.map +1 -1
  71. package/dist/decorators/hooks.d.ts +3 -3
  72. package/dist/decorators/hooks.d.ts.map +1 -1
  73. package/dist/decorators/hooks.js.map +1 -1
  74. package/dist/events.d.ts +69 -0
  75. package/dist/events.d.ts.map +1 -0
  76. package/dist/events.js +82 -0
  77. package/dist/events.js.map +1 -0
  78. package/dist/factories.d.ts +8 -0
  79. package/dist/factories.d.ts.map +1 -0
  80. package/dist/factories.js +8 -0
  81. package/dist/factories.js.map +1 -0
  82. package/dist/index.d.ts +15 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +14 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/lucid-schema.d.ts +15 -0
  87. package/dist/lucid-schema.d.ts.map +1 -0
  88. package/dist/lucid-schema.js +16 -0
  89. package/dist/lucid-schema.js.map +1 -0
  90. package/dist/metadata-keys.d.ts +3 -2
  91. package/dist/metadata-keys.d.ts.map +1 -1
  92. package/dist/naming/NamingStrategy.d.ts +7 -0
  93. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  94. package/dist/naming/NamingStrategy.js +16 -0
  95. package/dist/naming/NamingStrategy.js.map +1 -1
  96. package/dist/orm.d.ts +14 -0
  97. package/dist/orm.d.ts.map +1 -0
  98. package/dist/orm.js +14 -0
  99. package/dist/orm.js.map +1 -0
  100. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  101. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  102. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  103. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  104. package/dist/query/DmlBuilder.d.ts +62 -0
  105. package/dist/query/DmlBuilder.d.ts.map +1 -0
  106. package/dist/query/DmlBuilder.js +84 -0
  107. package/dist/query/DmlBuilder.js.map +1 -0
  108. package/dist/query/QueryBuilder.d.ts +5 -0
  109. package/dist/query/QueryBuilder.d.ts.map +1 -1
  110. package/dist/query/QueryBuilder.js +7 -0
  111. package/dist/query/QueryBuilder.js.map +1 -1
  112. package/dist/query/RawQueryBuilder.d.ts +47 -0
  113. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  114. package/dist/query/RawQueryBuilder.js +138 -0
  115. package/dist/query/RawQueryBuilder.js.map +1 -0
  116. package/dist/query/interpolate.d.ts +45 -0
  117. package/dist/query/interpolate.d.ts.map +1 -0
  118. package/dist/query/interpolate.js +51 -0
  119. package/dist/query/interpolate.js.map +1 -0
  120. package/dist/query/native.d.ts +8 -0
  121. package/dist/query/native.d.ts.map +1 -1
  122. package/dist/query/native.js +14 -0
  123. package/dist/query/native.js.map +1 -1
  124. package/dist/query/operators.d.ts +4 -0
  125. package/dist/query/operators.d.ts.map +1 -0
  126. package/dist/query/operators.js +27 -0
  127. package/dist/query/operators.js.map +1 -0
  128. package/dist/schema/Migration.d.ts +50 -3
  129. package/dist/schema/Migration.d.ts.map +1 -1
  130. package/dist/schema/Migration.js +60 -24
  131. package/dist/schema/Migration.js.map +1 -1
  132. package/dist/schema/MigrationRunner.d.ts +95 -34
  133. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  134. package/dist/schema/MigrationRunner.js +523 -68
  135. package/dist/schema/MigrationRunner.js.map +1 -1
  136. package/dist/schema/Schema.d.ts +114 -0
  137. package/dist/schema/Schema.d.ts.map +1 -1
  138. package/dist/schema/Schema.js +272 -3
  139. package/dist/schema/Schema.js.map +1 -1
  140. package/dist/schema/SchemaDumper.d.ts +121 -0
  141. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  142. package/dist/schema/SchemaDumper.js +365 -0
  143. package/dist/schema/SchemaDumper.js.map +1 -0
  144. package/dist/schema/Seeder.d.ts +13 -0
  145. package/dist/schema/Seeder.d.ts.map +1 -1
  146. package/dist/schema/Seeder.js +28 -5
  147. package/dist/schema/Seeder.js.map +1 -1
  148. package/dist/schema/TableBuilder.d.ts +274 -16
  149. package/dist/schema/TableBuilder.d.ts.map +1 -1
  150. package/dist/schema/TableBuilder.js +656 -52
  151. package/dist/schema/TableBuilder.js.map +1 -1
  152. package/dist/schema/catalog.d.ts +71 -0
  153. package/dist/schema/catalog.d.ts.map +1 -0
  154. package/dist/schema/catalog.js +158 -0
  155. package/dist/schema/catalog.js.map +1 -0
  156. package/dist/schema/introspect.d.ts +1 -1
  157. package/dist/schema/introspect.d.ts.map +1 -1
  158. package/dist/schema/introspect.js +26 -11
  159. package/dist/schema/introspect.js.map +1 -1
  160. package/dist/schema/types.d.ts +150 -1
  161. package/dist/schema/types.d.ts.map +1 -1
  162. package/dist/schema/types.js +11 -0
  163. package/dist/schema/types.js.map +1 -1
  164. package/dist/seeders.d.ts +7 -0
  165. package/dist/seeders.d.ts.map +1 -0
  166. package/dist/seeders.js +7 -0
  167. package/dist/seeders.js.map +1 -0
  168. package/dist/services/db.d.ts +122 -5
  169. package/dist/services/db.d.ts.map +1 -1
  170. package/dist/services/db.js +187 -14
  171. package/dist/services/db.js.map +1 -1
  172. package/dist/testing/DatabaseCleanup.d.ts +33 -7
  173. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  174. package/dist/testing/DatabaseCleanup.js +54 -24
  175. package/dist/testing/DatabaseCleanup.js.map +1 -1
  176. package/dist/testing/DbAssertions.d.ts +48 -0
  177. package/dist/testing/DbAssertions.d.ts.map +1 -0
  178. package/dist/testing/DbAssertions.js +80 -0
  179. package/dist/testing/DbAssertions.js.map +1 -0
  180. package/dist/testing/Factory.d.ts +166 -15
  181. package/dist/testing/Factory.d.ts.map +1 -1
  182. package/dist/testing/Factory.js +497 -23
  183. package/dist/testing/Factory.js.map +1 -1
  184. package/dist/testing/TestUtils.d.ts +49 -0
  185. package/dist/testing/TestUtils.d.ts.map +1 -0
  186. package/dist/testing/TestUtils.js +79 -0
  187. package/dist/testing/TestUtils.js.map +1 -0
  188. package/dist/testing/index.d.ts +3 -1
  189. package/dist/testing/index.d.ts.map +1 -1
  190. package/dist/testing/index.js +3 -1
  191. package/dist/testing/index.js.map +1 -1
  192. package/dist/types/relations.d.ts +31 -0
  193. package/dist/types/relations.d.ts.map +1 -0
  194. package/dist/types/relations.js +20 -0
  195. package/dist/types/relations.js.map +1 -0
  196. package/index.darwin-arm64.node +0 -0
  197. package/index.darwin-x64.node +0 -0
  198. package/index.linux-arm64-gnu.node +0 -0
  199. package/index.linux-x64-gnu.node +0 -0
  200. package/index.win32-x64-msvc.node +0 -0
  201. package/package.json +32 -2
  202. package/scripts/guard-publish.mjs +15 -0
  203. package/src/AtlasProvider.ts +305 -52
  204. package/src/BaseEntity.ts +460 -41
  205. package/src/BaseModel.ts +461 -0
  206. package/src/BaseRepository.ts +1710 -421
  207. package/src/ConnectionManager.ts +298 -0
  208. package/src/ModelQuery.ts +2998 -335
  209. package/src/Transaction.ts +227 -30
  210. package/src/adapters/NapiDbAdapter.ts +224 -17
  211. package/src/console/contract.ts +96 -0
  212. package/src/console/factoryCommands.ts +73 -0
  213. package/src/console/migrationCommands.ts +448 -0
  214. package/src/console/schemaCheckCommand.ts +26 -22
  215. package/src/console/schemaDumpCommand.ts +96 -0
  216. package/src/console/schemaGenerateCommand.ts +380 -0
  217. package/src/console/seederCommands.ts +174 -0
  218. package/src/decorators/entity.ts +71 -8
  219. package/src/decorators/hooks.ts +5 -3
  220. package/src/events.ts +117 -0
  221. package/src/factories.ts +10 -0
  222. package/src/index.ts +69 -3
  223. package/src/lucid-schema.ts +22 -0
  224. package/src/metadata-keys.ts +3 -2
  225. package/src/naming/NamingStrategy.ts +23 -0
  226. package/src/orm.ts +13 -0
  227. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  228. package/src/query/DmlBuilder.ts +131 -0
  229. package/src/query/QueryBuilder.ts +8 -0
  230. package/src/query/RawQueryBuilder.ts +173 -0
  231. package/src/query/interpolate.ts +73 -0
  232. package/src/query/native.ts +29 -0
  233. package/src/query/operators.ts +27 -0
  234. package/src/schema/Migration.ts +71 -3
  235. package/src/schema/MigrationRunner.ts +676 -69
  236. package/src/schema/Schema.ts +359 -3
  237. package/src/schema/SchemaDumper.ts +518 -0
  238. package/src/schema/Seeder.ts +47 -10
  239. package/src/schema/TableBuilder.ts +783 -52
  240. package/src/schema/catalog.ts +236 -0
  241. package/src/schema/introspect.ts +26 -9
  242. package/src/schema/types.ts +137 -2
  243. package/src/seeders.ts +16 -0
  244. package/src/services/db.ts +361 -21
  245. package/src/testing/DatabaseCleanup.ts +78 -25
  246. package/src/testing/DbAssertions.ts +127 -0
  247. package/src/testing/Factory.ts +837 -40
  248. package/src/testing/TestUtils.ts +123 -0
  249. package/src/testing/index.ts +12 -1
  250. package/src/types/relations.ts +43 -0
package/src/ModelQuery.ts CHANGED
@@ -7,22 +7,43 @@
7
7
  * Builds SQL fluently and executes against the database connection.
8
8
  */
9
9
 
10
- import type { BaseEntity } from "./BaseEntity.js";
11
- import type { DatabaseConnection } from "./BaseRepository.js";
10
+ import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
11
+ import type { QueryMeta } from "./adapters/NapiDbAdapter.js";
12
+ import { type BaseEntity, type DomainEvent, REPO_REF } from "./BaseEntity.js";
13
+ // Value import used only inside method bodies (preload hydration) — the
14
+ // BaseRepository ↔ ModelQuery cycle resolves at runtime, after both are defined.
12
15
  import {
16
+ assertNotPromise,
17
+ BaseRepository,
18
+ type DatabaseConnection,
19
+ wrapAdapterError,
20
+ } from "./BaseRepository.js";
21
+ import {
22
+ ensureEntityMetadata,
13
23
  getColumnMetadata,
14
- getEntityMetadata,
24
+ getDateColumnConfig,
15
25
  getPrimaryKey,
16
26
  getRelationMetadata,
17
27
  hasSoftDeletes,
18
28
  type RelationMetadata,
19
29
  } from "./decorators/entity.js";
20
30
  import { fireHooks } from "./decorators/hooks.js";
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";
21
38
  import {
22
39
  type AtlasDialect,
23
40
  compileStatementNative,
41
+ type DialectName,
24
42
  getAtlasDialect,
43
+ normalizeDialect,
25
44
  } from "./query/native.js";
45
+ import { negateOperator } from "./query/operators.js";
46
+ import { RawSql } from "./query/QueryBuilder.js";
26
47
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
27
48
 
28
49
  /**
@@ -43,6 +64,50 @@ const WHEREEXPR_OPERATORS = new Set<string>([
43
64
  "NOT LIKE",
44
65
  ]);
45
66
 
67
+ /**
68
+ * SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
69
+ * They are just letters (pass the charset guard) but would let the fragment alter
70
+ * the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
71
+ * alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
72
+ */
73
+ const WHEREEXPR_FORBIDDEN_WORDS = new Set<string>([
74
+ "OR",
75
+ "AND",
76
+ "NOT",
77
+ "IS",
78
+ "NULL",
79
+ "IN",
80
+ "LIKE",
81
+ "ILIKE",
82
+ "BETWEEN",
83
+ "EXISTS",
84
+ "ANY",
85
+ "ALL",
86
+ "SOME",
87
+ "CASE",
88
+ "WHEN",
89
+ "THEN",
90
+ "ELSE",
91
+ "END",
92
+ "SELECT",
93
+ "FROM",
94
+ "WHERE",
95
+ "JOIN",
96
+ "UNION",
97
+ "INTERSECT",
98
+ "EXCEPT",
99
+ "HAVING",
100
+ "GROUP",
101
+ "ORDER",
102
+ "BY",
103
+ "LIMIT",
104
+ "OFFSET",
105
+ "AS",
106
+ "DISTINCT",
107
+ "TRUE",
108
+ "FALSE",
109
+ ]);
110
+
46
111
  /** True when every `(` in `s` has a matching `)` and none closes early. */
47
112
  function hasBalancedParens(s: string): boolean {
48
113
  let depth = 0;
@@ -59,6 +124,112 @@ function hasBalancedParens(s: string): boolean {
59
124
  type PreloadCallback = (query: ModelQuery<BaseEntity>) => void;
60
125
 
61
126
  type ColumnResolver = (column: string) => string;
127
+ /**
128
+ * Lower a value bound for a given property to its DB form — mirrors
129
+ * `BaseRepository.#applyPrepare` (a `@column.dateTime` DateTime → ISO string, a
130
+ * `@Column({ prepare })` adapter runs). Threaded into ModelQuery so the fluent
131
+ * `update()` / WHERE paths don't bypass prepare the way direct repo writes don't.
132
+ */
133
+ type ValuePreparer = (column: string, value: unknown) => unknown;
134
+
135
+ /**
136
+ * Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
137
+ * and the snake_case convention. Used to build correlated/preload subqueries on a
138
+ * RELATED model so their WHERE/join columns resolve like a direct query would.
139
+ */
140
+ function buildColumnResolver(
141
+ entityClass: new () => BaseEntity,
142
+ ): ColumnResolver {
143
+ const map = new Map<string, string>();
144
+ for (const col of getColumnMetadata(entityClass)) {
145
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
146
+ map.set(col.propertyKey, db);
147
+ map.set(db, db);
148
+ }
149
+ return (col) => map.get(col) ?? camelToSnake(col);
150
+ }
151
+
152
+ /**
153
+ * Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
154
+ * (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
155
+ * preload/whereHas constraint on a RELATED model prepares its values like a direct query.
156
+ */
157
+ function buildValuePreparer(entityClass: new () => BaseEntity): ValuePreparer {
158
+ const prepares = new Map<
159
+ string,
160
+ (v: unknown, attribute?: string, model?: unknown) => unknown
161
+ >();
162
+ // Reverse map (db column → property) so a caller passing a DB name or an
163
+ // explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
164
+ // still routes through the property-keyed prepare/date maps — mirrors
165
+ // BaseRepository.#applyPrepare.
166
+ const byDbName = new Map<string, string>();
167
+ for (const col of getColumnMetadata(entityClass)) {
168
+ if (col.prepare) prepares.set(col.propertyKey, col.prepare);
169
+ byDbName.set(
170
+ col.columnName ?? camelToSnake(col.propertyKey),
171
+ col.propertyKey,
172
+ );
173
+ }
174
+ const dateCols = getDateColumnConfig(entityClass);
175
+ return (key, value) => {
176
+ const prop = byDbName.get(key) ?? key;
177
+ const p = prepares.get(prop);
178
+ // Query-builder value transform — no model instance, but the attribute is
179
+ // known (Adonis Lucid signature: value, attribute, model).
180
+ if (p) return p(value, prop, undefined);
181
+ if (dateCols[prop] && value != null) {
182
+ if (value instanceof Date) return value.toISOString();
183
+ return dateTimeAtlasAdapter.prepare(value);
184
+ }
185
+ return value;
186
+ };
187
+ }
188
+
189
+ /** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
190
+ function joinValueHasToISO(v: unknown): v is { toISO(): string } {
191
+ return (
192
+ typeof v === "object" &&
193
+ v !== null &&
194
+ "toISO" in v &&
195
+ typeof v.toISO === "function"
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
201
+ * `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
202
+ * column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
203
+ * borrow the root model's adapter for a same-named column on a different table
204
+ * (Knex binds join values model-agnostically; we add only safe universal
205
+ * serialization so a DateTime still lowers to ISO like `where()`).
206
+ */
207
+ function lowerJoinValue(value: unknown): unknown {
208
+ if (value instanceof Date) return value.toISOString();
209
+ if (joinValueHasToISO(value)) return value.toISO();
210
+ return value;
211
+ }
212
+
213
+ /**
214
+ * Does a join column's table reference (`ref`) denote the root model's own table
215
+ * (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
216
+ * model declares (default schema) — `orders` matches a `public.orders` model — but
217
+ * it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
218
+ * accepts `orders.col`, while an unqualified `orders` model rejects
219
+ * `archive.orders.col` (a different schema the model never named) — keeping it
220
+ * foreign so the root model's `@Column` adapters aren't misapplied to it.
221
+ */
222
+ function sameTableRef(ref: string, modelTable: string): boolean {
223
+ const rs = ref.split(".");
224
+ const ms = modelTable.split(".");
225
+ // The reference cannot be MORE qualified than the model (it can only drop the
226
+ // schema, never assert a new one) — otherwise treat it as a foreign table.
227
+ if (rs.length > ms.length) return false;
228
+ for (let i = 1; i <= rs.length; i++) {
229
+ if (rs[rs.length - i] !== ms[ms.length - i]) return false;
230
+ }
231
+ return true;
232
+ }
62
233
 
63
234
  /** Per-preload-relation locals shared by the resolver helpers. Built once per relation, then passed by ref. */
64
235
  interface PreloadContext {
@@ -148,6 +319,22 @@ interface RawWhere {
148
319
  bindings: unknown[];
149
320
  }
150
321
 
322
+ /**
323
+ * A JSON predicate — path comparison or containment. The column is a quoted
324
+ * identifier; the path and value cross the boundary as bound params. Mirrors
325
+ * the Rust `json` WHERE kind.
326
+ */
327
+ interface JsonWhere {
328
+ type: "and" | "or";
329
+ kind: "json";
330
+ jsonOp: "path" | "superset" | "subset" | "equals";
331
+ column: string;
332
+ negated: boolean;
333
+ path?: string;
334
+ operator?: string;
335
+ value: unknown;
336
+ }
337
+
151
338
  /** An EXISTS / NOT EXISTS correlated subquery — used by whereHas / doesntHave. */
152
339
  interface ExistsWhere {
153
340
  type: "and" | "or";
@@ -156,11 +343,13 @@ interface ExistsWhere {
156
343
  subquery: SelectSpec;
157
344
  }
158
345
 
159
- /** Parenthesised group of WHERE conditions — built via `where(cb)`. */
346
+ /** Parenthesised group of WHERE conditions — built via `where(cb)` / `whereNot(cb)`. */
160
347
  interface GroupWhere {
161
348
  type: "and" | "or";
162
349
  kind: "group";
163
350
  conditions: WhereClause[];
351
+ /** `whereNot(cb)` wraps the group in `NOT (…)` (honoured by the Rust compiler). */
352
+ negated?: boolean;
164
353
  }
165
354
 
166
355
  /** `col IN (SELECT ...)` / `col NOT IN (SELECT ...)` — built via `whereIn(col, subQ)`. */
@@ -195,13 +384,27 @@ interface CteSpec {
195
384
  name: string;
196
385
  sql: string;
197
386
  params: unknown[];
387
+ /** One recursive CTE makes the whole WITH clause recursive — see `withRecursive`. */
388
+ recursive?: boolean;
389
+ /**
390
+ * `true` → AS MATERIALIZED, `false` → AS NOT MATERIALIZED, `null` →
391
+ * planner's choice. Null rather than absent because this crosses the NAPI
392
+ * boundary, where serde reads a missing key and an explicit null alike.
393
+ */
394
+ materialized?: boolean | null;
198
395
  }
199
396
 
200
- /** A compiled UNION / UNION ALL branch — pre-compiled to SQL + params. */
397
+ /**
398
+ * A compiled set-operation branch — pre-compiled to SQL + params. Still named
399
+ * `UnionSpec` (and sent under `unions`) because that wire field predates
400
+ * INTERSECT/EXCEPT; renaming it would break the contract for no gain.
401
+ */
201
402
  interface UnionSpec {
202
403
  sql: string;
203
404
  params: unknown[];
204
405
  all: boolean;
406
+ /** Defaults to `union` when absent/null, keeping the pre-existing wire format. */
407
+ op?: "union" | "intersect" | "except" | null;
205
408
  }
206
409
 
207
410
  interface SubqueryProjection {
@@ -209,22 +412,52 @@ interface SubqueryProjection {
209
412
  subquery: SelectSpec;
210
413
  }
211
414
 
415
+ /**
416
+ * One ORDER BY term: a resolved column + direction, or a verbatim fragment
417
+ * (`orderByRaw`). Both share one list so a raw term keeps its position among
418
+ * the plain ones. Mirrors the Rust `OrderByClause`.
419
+ */
420
+ type OrderByEntry =
421
+ | { column: string; direction: "asc" | "desc" }
422
+ | { raw: string };
423
+
424
+ /**
425
+ * One GROUP BY term: a resolved column, or a verbatim fragment (`groupByRaw`).
426
+ * Mirrors the Rust `GroupByItem` — untagged, so a bare string stays a column
427
+ * and the pre-existing wire format is unchanged.
428
+ */
429
+ type GroupByEntry = string | { raw: string };
430
+
212
431
  interface SelectSpec {
213
432
  kind: "select";
214
433
  table: string;
215
434
  select: string[];
216
435
  selectSubqueries: SubqueryProjection[];
217
436
  wheres: WhereClause[];
218
- orderBy: Array<{ column: string; direction: "asc" | "desc" }>;
219
- groupBy: string[];
437
+ orderBy: OrderByEntry[];
438
+ groupBy: GroupByEntry[];
220
439
  having: HavingEntry[];
221
440
  limit: number | null;
222
441
  offset: number | null;
223
442
  distinct: boolean;
443
+ distinctOn: string[];
224
444
  ctes: CteSpec[];
225
445
  unions: UnionSpec[];
226
- joins: string[];
227
- lockMode: "FOR UPDATE" | "FOR SHARE" | null;
446
+ /** JOIN fragments; each carries its own `?`-style bound params (e.g. `onVal`). */
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[] }>;
450
+ /** Composite lock clause, e.g. `FOR UPDATE`, `FOR NO KEY UPDATE SKIP LOCKED`. */
451
+ lockMode: string | null;
452
+ }
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;
228
461
  }
229
462
 
230
463
  type WhereClause =
@@ -232,15 +465,22 @@ type WhereClause =
232
465
  | RawWhere
233
466
  | ExistsWhere
234
467
  | GroupWhere
235
- | InSubWhere;
468
+ | InSubWhere
469
+ | InTupleWhere
470
+ | JsonWhere;
236
471
 
237
472
  type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
238
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
+
239
477
  /**
240
- * Process-wide strict mode flag. When enabled, `whereRaw()` and `joinRaw()`
241
- * throw unconditionally — forcing every call site to use the typed
242
- * `whereExpr()` / `joinOn()` / structured builder paths. Intended for prod
243
- * hardening on apps that can't audit every call site manually.
478
+ * Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
479
+ * `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
480
+ * call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
481
+ * builder paths. The connection-level `db.query()` / `db.execute()` stay available
482
+ * as the explicit, parameterised break-glass. Intended for prod hardening on apps
483
+ * that can't audit every call site manually.
244
484
  *
245
485
  * Enable via:
246
486
  * - `setAtlasStrictMode(true)` at app bootstrap
@@ -252,7 +492,7 @@ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
252
492
  */
253
493
  let atlasStrictMode: boolean | undefined;
254
494
 
255
- /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
495
+ /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
256
496
  export function setAtlasStrictMode(enabled: boolean): void {
257
497
  atlasStrictMode = enabled;
258
498
  }
@@ -286,12 +526,54 @@ function isInternalBypass(): boolean {
286
526
  return atlasInternalBypass;
287
527
  }
288
528
 
289
- /** Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks. */
529
+ /**
530
+ * Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks.
531
+ * `on`/`andOn`/`orOn` join two COLUMNS; `onVal`/`andOnVal`/`orOnVal` join a column
532
+ * to a bound VALUE (AdonisJS/Knex parity) — the value flows through the join-params
533
+ * channel into the compiled parameter list.
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
+
290
549
  interface JoinBuilder {
291
- parts: Array<{ kind: "and" | "or"; left: string; right: string }>;
550
+ /** Accumulated `ON` parts column-to-column, column-to-value, IN, BETWEEN, NULL, EXISTS. */
551
+ parts: JoinPartMQ[];
292
552
  on(left: string, right: string): JoinBuilder;
553
+ on(left: string, operator: string, right: string): JoinBuilder;
293
554
  andOn(left: string, right: string): JoinBuilder;
555
+ andOn(left: string, operator: string, right: string): JoinBuilder;
556
+ orOn(left: string, right: string): JoinBuilder;
557
+ orOn(left: string, operator: string, right: string): JoinBuilder;
558
+ onVal(left: string, value: unknown): JoinBuilder;
294
559
  andOnVal(left: string, value: unknown): JoinBuilder;
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;
295
577
  }
296
578
 
297
579
  /** Offset-based paginator (Story 29.10). */
@@ -306,20 +588,46 @@ export class Paginator<T> {
306
588
  };
307
589
  #baseUrl?: string;
308
590
  #queryString: Record<string, unknown> = {};
591
+ #metaKeys?: Record<string, string>;
309
592
 
310
593
  constructor(
311
594
  items: T[],
312
595
  base: { total: number; perPage: number; currentPage: number },
596
+ metaKeys?: Record<string, string>,
313
597
  ) {
314
598
  this.items = items;
315
599
  const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
316
600
  this.meta = { ...base, lastPage, firstPage: 1 };
601
+ this.#metaKeys = metaKeys;
317
602
  }
318
603
 
319
604
  all(): T[] {
320
605
  return this.items;
321
606
  }
322
607
 
608
+ // Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
609
+ // carried in `.meta`, exposed directly on the instance for convenience.
610
+ /** Total row count across all pages. */
611
+ get total(): number {
612
+ return this.meta.total;
613
+ }
614
+ /** Rows per page. */
615
+ get perPage(): number {
616
+ return this.meta.perPage;
617
+ }
618
+ /** The current page number. */
619
+ get currentPage(): number {
620
+ return this.meta.currentPage;
621
+ }
622
+ /** The last page number. */
623
+ get lastPage(): number {
624
+ return this.meta.lastPage;
625
+ }
626
+ /** The first page number (always 1). */
627
+ get firstPage(): number {
628
+ return this.meta.firstPage;
629
+ }
630
+
323
631
  /** True when there is more than one page of results (AdonisJS `hasPages`). */
324
632
  get hasPages(): boolean {
325
633
  return this.meta.lastPage > 1;
@@ -332,7 +640,7 @@ export class Paginator<T> {
332
640
 
333
641
  serialize(opts?: { fields?: string[] }): {
334
642
  data: unknown[];
335
- meta: Paginator<T>["meta"];
643
+ meta: Record<string, unknown>;
336
644
  } {
337
645
  const data = this.items.map((item) => {
338
646
  if (!opts?.fields) return item;
@@ -341,7 +649,9 @@ export class Paginator<T> {
341
649
  picked[f] = (item as Record<string, unknown>)[f];
342
650
  return picked;
343
651
  });
344
- return { data, meta: this.meta };
652
+ // Same meta shape as toJSON — snake_case keys via the naming strategy's
653
+ // paginationMetaKeys, plus page URLs when a baseUrl is set.
654
+ return { data, meta: this.#buildMeta() };
345
655
  }
346
656
 
347
657
  baseUrl(url: string): this {
@@ -353,29 +663,76 @@ export class Paginator<T> {
353
663
  return this;
354
664
  }
355
665
 
666
+ /**
667
+ * Build the URL for a page number, honouring `baseUrl` + `queryString`.
668
+ * Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
669
+ */
670
+ getUrl(page: number): string {
671
+ if (!this.#baseUrl) return "";
672
+ const params = new URLSearchParams();
673
+ for (const [k, v] of Object.entries(this.#queryString))
674
+ params.set(k, String(v));
675
+ params.set("page", String(page));
676
+ return `${this.#baseUrl}?${params.toString()}`;
677
+ }
678
+
679
+ /** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
680
+ getNextPageUrl(): string | null {
681
+ return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
682
+ }
683
+
684
+ /** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
685
+ getPreviousPageUrl(): string | null {
686
+ return this.meta.currentPage > 1
687
+ ? this.getUrl(this.meta.currentPage - 1)
688
+ : null;
689
+ }
690
+
691
+ /** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
692
+ getUrlsForRange(
693
+ start: number,
694
+ end: number,
695
+ ): Array<{ page: number; url: string; isActive: boolean }> {
696
+ const lo = Math.max(1, start);
697
+ const hi = Math.min(this.meta.lastPage, end);
698
+ const range: Array<{ page: number; url: string; isActive: boolean }> = [];
699
+ for (let page = lo; page <= hi; page++)
700
+ range.push({
701
+ page,
702
+ url: this.getUrl(page),
703
+ isActive: page === this.meta.currentPage,
704
+ });
705
+ return range;
706
+ }
707
+
708
+ /**
709
+ * Build the serialized `meta` object: the raw camelCase fields plus page URLs
710
+ * (when a baseUrl is set), remapped through the naming strategy's
711
+ * `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
712
+ * Shared by {@link toJSON} and {@link serialize} so they never diverge.
713
+ */
714
+ #buildMeta(): Record<string, unknown> {
715
+ const raw: Record<string, unknown> = { ...this.meta };
716
+ if (this.#baseUrl) {
717
+ raw.firstPageUrl = this.getUrl(1);
718
+ raw.lastPageUrl = this.getUrl(this.meta.lastPage);
719
+ const next = this.getNextPageUrl();
720
+ const prev = this.getPreviousPageUrl();
721
+ if (next) raw.nextPageUrl = next;
722
+ if (prev) raw.previousPageUrl = prev;
723
+ }
724
+ const keys = this.#metaKeys;
725
+ if (!keys) return raw;
726
+ const meta: Record<string, unknown> = {};
727
+ for (const [k, v] of Object.entries(raw)) meta[keys[k] ?? k] = v;
728
+ return meta;
729
+ }
730
+
356
731
  toJSON(): {
357
732
  data: unknown[];
358
- meta: Paginator<T>["meta"] & Record<string, unknown>;
733
+ meta: Record<string, unknown>;
359
734
  } {
360
- const meta: Paginator<T>["meta"] & Record<string, unknown> = {
361
- ...this.meta,
362
- };
363
- if (this.#baseUrl) {
364
- const build = (page: number) => {
365
- const params = new URLSearchParams();
366
- for (const [k, v] of Object.entries(this.#queryString))
367
- params.set(k, String(v));
368
- params.set("page", String(page));
369
- return `${this.#baseUrl}?${params.toString()}`;
370
- };
371
- meta.firstPageUrl = build(1);
372
- meta.lastPageUrl = build(this.meta.lastPage);
373
- if (this.meta.currentPage < this.meta.lastPage)
374
- meta.nextPageUrl = build(this.meta.currentPage + 1);
375
- if (this.meta.currentPage > 1)
376
- meta.previousPageUrl = build(this.meta.currentPage - 1);
377
- }
378
- return { data: this.items as unknown[], meta };
735
+ return { data: this.items as unknown[], meta: this.#buildMeta() };
379
736
  }
380
737
  }
381
738
 
@@ -393,34 +750,96 @@ export class ModelQuery<T extends BaseEntity> {
393
750
  #softDeletes: boolean;
394
751
  #softScope: SoftDeleteScope = "default";
395
752
  #wheres: WhereClause[] = [];
396
- #orderBys: Array<{ column: string; direction: "asc" | "desc" }> = [];
753
+ #orderBys: OrderByEntry[] = [];
397
754
  #select: string[] = ["*"];
398
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" };
399
760
  #offset?: number;
400
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 }> = [];
401
767
  /** Correlated subquery projections (withCount / withAggregate). */
402
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[] = [];
403
777
  /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
404
778
  #subqueryAlias?: string;
405
779
  /** Raw JOIN fragments — Story 29.4. */
406
- #joins: string[] = [];
407
- /** Row lock mode — Story 30.8. */
408
- #lockMode: "FOR UPDATE" | "FOR SHARE" | null = null;
780
+ #joins: Array<{ sql: string; params: unknown[] }> = [];
781
+ /** Row lock base mode — Story 30.8. */
782
+ #lockMode:
783
+ | "FOR UPDATE"
784
+ | "FOR SHARE"
785
+ | "FOR NO KEY UPDATE"
786
+ | "FOR KEY SHARE"
787
+ | null = null;
788
+ /** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
789
+ #lockModifier: "SKIP LOCKED" | "NOWAIT" | null = null;
790
+ /** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
791
+ #sideloaded: Record<string, unknown> | null = null;
409
792
  /** Per-query debug flag — Story 29.11. */
410
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[] = [];
411
798
  /** Distinct flag — Story 29.5. */
412
799
  #distinct = false;
800
+ #distinctOn: string[] = [];
413
801
  /** GROUP BY columns (Lucid parity). */
414
- #groupBy: string[] = [];
802
+ #groupBy: GroupByEntry[] = [];
415
803
  /** HAVING clauses — structured + raw (Lucid parity). */
416
804
  #having: HavingEntry[] = [];
417
805
  /** CTEs registered via `.with()` (Lucid parity). */
418
- #ctes: Array<{ name: string; query: ModelQuery<BaseEntity> }> = [];
806
+ #ctes: Array<{
807
+ name: string;
808
+ query: ModelQuery<BaseEntity>;
809
+ recursive?: boolean;
810
+ materialized?: boolean;
811
+ columns?: string[];
812
+ }> = [];
419
813
  /** UNION / UNION ALL branches (Lucid parity). */
420
- #unions: Array<{ query: ModelQuery<BaseEntity>; all: boolean }> = [];
814
+ #unions: Array<{
815
+ query: ModelQuery<BaseEntity>;
816
+ all: boolean;
817
+ op?: "union" | "intersect" | "except";
818
+ }> = [];
421
819
  /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
422
- #pivotWheres: Array<{ column: string; operator: string; value: unknown }> =
423
- [];
820
+ #pivotWheres: Array<{
821
+ column: string;
822
+ operator: string;
823
+ value: unknown;
824
+ /** AND/OR within the parenthesised pivot-filter group — see `#runInQuery`. */
825
+ type: "and" | "or";
826
+ }> = [];
827
+ /** Extra pivot columns requested at query time (Lucid `pivotColumns([...])`). */
828
+ #extraPivotColumns: string[] = [];
829
+ /**
830
+ * Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
831
+ * the relation proxy's scoped query; invoked at `#buildSpec()` time with the
832
+ * CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
833
+ * back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
834
+ * time would freeze the predicate before those calls and silently drop them).
835
+ */
836
+ #pivotExists?: (
837
+ pivotWheres: ReadonlyArray<{
838
+ column: string;
839
+ operator: string;
840
+ value: unknown;
841
+ }>,
842
+ ) => { sql: string; bindings: unknown[] };
424
843
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
425
844
  #dialect: AtlasDialect;
426
845
 
@@ -432,6 +851,8 @@ export class ModelQuery<T extends BaseEntity> {
432
851
  resolveColumn: ColumnResolver = (c) => c,
433
852
  softDeletes = false,
434
853
  dialect: AtlasDialect = getAtlasDialect(),
854
+ prepareValue: ValuePreparer = (_c, v) => v,
855
+ onDomainEvents?: (events: DomainEvent[]) => Promise<void>,
435
856
  ) {
436
857
  this.#tableName = tableName;
437
858
  this.#db = db;
@@ -440,8 +861,15 @@ export class ModelQuery<T extends BaseEntity> {
440
861
  this.#resolveColumn = resolveColumn;
441
862
  this.#softDeletes = softDeletes;
442
863
  this.#dialect = dialect;
864
+ this.#prepareValue = prepareValue;
865
+ this.#onDomainEvents = onDomainEvents;
443
866
  }
444
867
 
868
+ /** @see ValuePreparer — identity unless the owning repository wires prepare in. */
869
+ #prepareValue: ValuePreparer;
870
+ /** Domain-event bus threaded from the owning repository — propagated to preload repos. */
871
+ #onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
872
+
445
873
  /** Include soft-deleted rows in the result (default behavior excludes them). */
446
874
  withTrashed(): this {
447
875
  this.#softScope = "with-trashed";
@@ -454,6 +882,27 @@ export class ModelQuery<T extends BaseEntity> {
454
882
  return this;
455
883
  }
456
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
+
457
906
  /**
458
907
  * Eager-load a relation (AdonisJS-style).
459
908
  * Relations are never loaded automatically — you must call .preload() explicitly.
@@ -467,14 +916,108 @@ export class ModelQuery<T extends BaseEntity> {
467
916
  return this;
468
917
  }
469
918
 
470
- /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
471
- select(columns: string | string[]): this {
472
- this.#select = Array.isArray(columns)
473
- ? columns
474
- : columns.split(",").map((c) => c.trim());
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
+ }
929
+ return this;
930
+ }
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
+ }
475
991
  return this;
476
992
  }
477
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
+
1003
+ /**
1004
+ * Resolve a bare model-property select/returning target to its DB column
1005
+ * (honouring `@Column({ columnName })`), leaving expressions / aliases /
1006
+ * qualified names / `*` untouched. A bare identifier IS validated through the
1007
+ * column resolver — so a typo like `select('lable')` raises the same Atlas
1008
+ * error as `where`/`orderBy`, rather than reaching the DB.
1009
+ */
1010
+ #resolveSelect(col: string): string {
1011
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col)) return this.#resolveColumn(col);
1012
+ // `col as alias` — resolve the (bare) column part to its DB name, keep the
1013
+ // alias verbatim, so `select('label as name')` honours a columnName override.
1014
+ const aliased = col.match(
1015
+ /^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i,
1016
+ );
1017
+ if (aliased) return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
1018
+ return col;
1019
+ }
1020
+
478
1021
  where(callback: WhereCallback): this;
479
1022
  where(column: string, value: unknown): this;
480
1023
  where(column: string, operator: string, value: unknown): this;
@@ -525,37 +1068,175 @@ export class ModelQuery<T extends BaseEntity> {
525
1068
  return this;
526
1069
  }
527
1070
 
528
- /** `WHERE col != ?` — negation of `where`. */
529
- whereNot(column: string, value: unknown): this {
1071
+ // ─── AND aliases ──────────────────────────────────────────
1072
+ //
1073
+ // Lucid documents an `and*` spelling alongside every `where*`. They are
1074
+ // exact synonyms — the base methods already default to AND — and exist so a
1075
+ // chain can say so out loud: `.where(a).andWhere(b)`. Kept as thin
1076
+ // delegations rather than duplicated bodies, so they cannot drift.
1077
+
1078
+ andWhere(callback: WhereCallback): this;
1079
+ andWhere(column: string, value: unknown): this;
1080
+ andWhere(column: string, operator: string, value: unknown): this;
1081
+ andWhere(
1082
+ columnOrCb: string | WhereCallback,
1083
+ operatorOrValue?: unknown,
1084
+ value?: unknown,
1085
+ ): this {
1086
+ // The 2-arg overload must not forward a phantom third argument: `where`
1087
+ // switches on `value === undefined` to tell `(col, value)` from
1088
+ // `(col, operator, value)`.
1089
+ return typeof columnOrCb === "function"
1090
+ ? this.where(columnOrCb)
1091
+ : value === undefined
1092
+ ? this.where(columnOrCb, operatorOrValue)
1093
+ : this.where(columnOrCb, operatorOrValue as string, value);
1094
+ }
1095
+
1096
+ /** Alias of {@link whereNot} (Lucid parity). */
1097
+ andWhereNot(column: string, value: unknown): this {
1098
+ return this.whereNot(column, value);
1099
+ }
1100
+
1101
+ /** Alias of {@link whereIn} (Lucid parity). */
1102
+ andWhereIn(column: string, values: readonly unknown[]): this {
1103
+ return this.whereIn(column, values);
1104
+ }
1105
+
1106
+ /** Alias of {@link whereNotIn} (Lucid parity). */
1107
+ andWhereNotIn(column: string, values: readonly unknown[]): this {
1108
+ return this.whereNotIn(column, values);
1109
+ }
1110
+
1111
+ /** Alias of {@link whereNull} (Lucid parity). */
1112
+ andWhereNull(column: string): this {
1113
+ return this.whereNull(column);
1114
+ }
1115
+
1116
+ /** Alias of {@link whereNotNull} (Lucid parity). */
1117
+ andWhereNotNull(column: string): this {
1118
+ return this.whereNotNull(column);
1119
+ }
1120
+
1121
+ /** Alias of {@link whereBetween} (Lucid parity). */
1122
+ andWhereBetween(column: string, range: readonly [unknown, unknown]): this {
1123
+ return this.whereBetween(column, range);
1124
+ }
1125
+
1126
+ /** Alias of {@link whereNotBetween} (Lucid parity). */
1127
+ andWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
1128
+ return this.whereNotBetween(column, range);
1129
+ }
1130
+
1131
+ /** Alias of {@link whereLike} (Lucid parity). */
1132
+ andWhereLike(column: string, pattern: string): this {
1133
+ return this.whereLike(column, pattern);
1134
+ }
1135
+
1136
+ /** Alias of {@link whereILike} (Lucid parity). */
1137
+ andWhereILike(column: string, pattern: string): this {
1138
+ return this.whereILike(column, pattern);
1139
+ }
1140
+
1141
+ /** Alias of {@link whereColumn} (Lucid parity). */
1142
+ andWhereColumn(left: string, operator: string, right: string): this {
1143
+ return this.whereColumn(left, operator, right);
1144
+ }
1145
+
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;
530
1191
  this.#wheres.push({
531
- type: "and",
532
- column: this.#resolveColumn(column),
533
- operator: "!=",
534
- value,
1192
+ ...this.#buildGroup("and", (q) => q.where(col, op as string, value)),
1193
+ negated: true,
535
1194
  });
536
1195
  return this;
537
1196
  }
538
1197
 
539
1198
  /** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
1199
+ whereIn(columns: string[], rows: unknown[][]): this;
540
1200
  whereIn(
541
1201
  column: string,
542
1202
  source: readonly unknown[] | ModelQuery<BaseEntity>,
1203
+ ): this;
1204
+ whereIn(
1205
+ columnOrColumns: string | string[],
1206
+ source: readonly unknown[] | unknown[][] | ModelQuery<BaseEntity>,
543
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
+ }
544
1225
  if (source instanceof ModelQuery) {
545
1226
  this.#wheres.push({
546
1227
  type: "and",
547
1228
  kind: "inSub",
548
1229
  negated: false,
549
- column: this.#resolveColumn(column),
1230
+ column: this.#resolveColumn(columnOrColumns),
550
1231
  subquery: source.#buildSpec(),
551
1232
  });
552
1233
  return this;
553
1234
  }
554
1235
  this.#wheres.push({
555
1236
  type: "and",
556
- column: this.#resolveColumn(column),
1237
+ column: this.#resolveColumn(columnOrColumns),
557
1238
  operator: "IN",
558
- value: [...source],
1239
+ value: this.#prep(columnOrColumns, [...source]),
559
1240
  });
560
1241
  return this;
561
1242
  }
@@ -579,7 +1260,7 @@ export class ModelQuery<T extends BaseEntity> {
579
1260
  type: "and",
580
1261
  column: this.#resolveColumn(column),
581
1262
  operator: "NOT IN",
582
- value: [...source],
1263
+ value: this.#prep(column, [...source]),
583
1264
  });
584
1265
  return this;
585
1266
  }
@@ -590,7 +1271,7 @@ export class ModelQuery<T extends BaseEntity> {
590
1271
  type: "and",
591
1272
  column: this.#resolveColumn(column),
592
1273
  operator: "BETWEEN",
593
- value: [...range],
1274
+ value: this.#prep(column, [...range]),
594
1275
  });
595
1276
  return this;
596
1277
  }
@@ -601,7 +1282,7 @@ export class ModelQuery<T extends BaseEntity> {
601
1282
  type: "and",
602
1283
  column: this.#resolveColumn(column),
603
1284
  operator: "NOT BETWEEN",
604
- value: [...range],
1285
+ value: this.#prep(column, [...range]),
605
1286
  });
606
1287
  return this;
607
1288
  }
@@ -664,7 +1345,7 @@ export class ModelQuery<T extends BaseEntity> {
664
1345
  type: "or",
665
1346
  column: this.#resolveColumn(column),
666
1347
  operator: "!=",
667
- value,
1348
+ value: this.#prep(column, value),
668
1349
  });
669
1350
  return this;
670
1351
  }
@@ -688,7 +1369,7 @@ export class ModelQuery<T extends BaseEntity> {
688
1369
  type: "or",
689
1370
  column: this.#resolveColumn(column),
690
1371
  operator: "IN",
691
- value: [...source],
1372
+ value: this.#prep(column, [...source]),
692
1373
  });
693
1374
  return this;
694
1375
  }
@@ -712,7 +1393,7 @@ export class ModelQuery<T extends BaseEntity> {
712
1393
  type: "or",
713
1394
  column: this.#resolveColumn(column),
714
1395
  operator: "NOT IN",
715
- value: [...source],
1396
+ value: this.#prep(column, [...source]),
716
1397
  });
717
1398
  return this;
718
1399
  }
@@ -723,7 +1404,7 @@ export class ModelQuery<T extends BaseEntity> {
723
1404
  type: "or",
724
1405
  column: this.#resolveColumn(column),
725
1406
  operator: "BETWEEN",
726
- value: [...range],
1407
+ value: this.#prep(column, [...range]),
727
1408
  });
728
1409
  return this;
729
1410
  }
@@ -734,7 +1415,7 @@ export class ModelQuery<T extends BaseEntity> {
734
1415
  type: "or",
735
1416
  column: this.#resolveColumn(column),
736
1417
  operator: "NOT BETWEEN",
737
- value: [...range],
1418
+ value: this.#prep(column, [...range]),
738
1419
  });
739
1420
  return this;
740
1421
  }
@@ -803,9 +1484,13 @@ export class ModelQuery<T extends BaseEntity> {
803
1484
  * Not exported from the package barrel — only accessible inside the Atlas
804
1485
  * codebase via direct ModelQuery instance access.
805
1486
  */
806
- #pushWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1487
+ #pushWhereRaw(
1488
+ sql: string,
1489
+ bindings: readonly unknown[] = [],
1490
+ type: "and" | "or" = "and",
1491
+ ): this {
807
1492
  this.#wheres.push({
808
- type: "and",
1493
+ type,
809
1494
  kind: "raw",
810
1495
  sql,
811
1496
  bindings: [...bindings],
@@ -813,6 +1498,52 @@ export class ModelQuery<T extends BaseEntity> {
813
1498
  return this;
814
1499
  }
815
1500
 
1501
+ /** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
1502
+ andWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1503
+ return this.whereRaw(sql, bindings);
1504
+ }
1505
+
1506
+ /**
1507
+ * `OR <raw fragment>` (Lucid parity).
1508
+ *
1509
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1510
+ * Subject to the same strict-mode gate as {@link whereRaw}.
1511
+ */
1512
+ orWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1513
+ this.#assertRawAllowed("orWhereRaw");
1514
+ return this.#pushWhereRaw(sql, bindings, "or");
1515
+ }
1516
+
1517
+ /** Shared strict-mode gate for the raw WHERE entry points. */
1518
+ #assertRawAllowed(method: string): void {
1519
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1520
+ throw new Error(
1521
+ `${method}() is disabled in Atlas strict mode. ` +
1522
+ "Use whereExpr() or a structured builder method instead. " +
1523
+ "Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.",
1524
+ );
1525
+ }
1526
+ }
1527
+
1528
+ /**
1529
+ * Framework-internal: register the deferred m2m EXISTS predicate for a lazy
1530
+ * `related().query()`. The builder is re-invoked on every `#buildSpec()` with
1531
+ * the pivot constraints known at that moment, so `.wherePivot()` added after
1532
+ * the proxy returned still applies. Not exported from the barrel.
1533
+ */
1534
+ setPivotExistsBuilder(
1535
+ builder: (
1536
+ pivotWheres: ReadonlyArray<{
1537
+ column: string;
1538
+ operator: string;
1539
+ value: unknown;
1540
+ }>,
1541
+ ) => { sql: string; bindings: unknown[] },
1542
+ ): this {
1543
+ this.#pivotExists = builder;
1544
+ return this;
1545
+ }
1546
+
816
1547
  /**
817
1548
  * **SAFE** alternative to `whereRaw` for the common case of a single
818
1549
  * SQL expression built from a validated column + operator + bound value.
@@ -863,6 +1594,20 @@ export class ModelQuery<T extends BaseEntity> {
863
1594
  `whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
864
1595
  );
865
1596
  }
1597
+ // The charset blocks comparison/quote symbols, but bare SQL keywords
1598
+ // (OR / AND / IS / NOT / SELECT …) are just letters and would slip
1599
+ // through, letting `extra` alter the predicate's logical structure
1600
+ // (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
1601
+ // SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
1602
+ // token — arithmetic on columns/numbers/functions only.
1603
+ for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
1604
+ if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
1605
+ throw new Error(
1606
+ `whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
1607
+ "whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.",
1608
+ );
1609
+ }
1610
+ }
866
1611
  // `op` is interpolated raw into the fragment below, so it MUST be
867
1612
  // allow-listed — the 3-arg path gets this from the Rust operator
868
1613
  // validation, but the raw 4-arg path bypasses Rust and would
@@ -881,9 +1626,16 @@ export class ModelQuery<T extends BaseEntity> {
881
1626
  // the operator against the allow-list above.
882
1627
  if (hasExtra) {
883
1628
  const q = this.#quote(resolved);
884
- return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
1629
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
1630
+ this.#prep(column, value),
1631
+ ]);
885
1632
  }
886
- this.#wheres.push({ type: "and", column: resolved, operator: op, value });
1633
+ this.#wheres.push({
1634
+ type: "and",
1635
+ column: resolved,
1636
+ operator: op,
1637
+ value: this.#prep(column, value),
1638
+ });
887
1639
  return this;
888
1640
  }
889
1641
 
@@ -893,22 +1645,352 @@ export class ModelQuery<T extends BaseEntity> {
893
1645
  * operator is allow-listed; nothing is bound (it's a column reference, not a
894
1646
  * value), which the standard `where`/`whereExpr` value-binding path can't do.
895
1647
  */
896
- whereColumn(left: string, operator: string, right: string): this {
897
- return this.#whereColumn("and", left, operator, right);
1648
+ // ─── EXISTS ───────────────────────────────────────────────
1649
+ //
1650
+ // `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
1651
+ // builder `repo.query()` actually hands back, so it was unreachable from
1652
+ // normal use. The subquery is another `ModelQuery`; correlate it to the
1653
+ // outer table with `whereColumn`:
1654
+ //
1655
+ // userRepo.query().whereExists(
1656
+ // postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
1657
+ // )
1658
+ //
1659
+ // For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
1660
+ // join predicate from the relation metadata.
1661
+
1662
+ /** `WHERE EXISTS (subquery)` (Lucid parity). */
1663
+ whereExists(subquery: ModelQuery<BaseEntity>): this {
1664
+ return this.#pushExists("and", false, subquery);
898
1665
  }
899
1666
 
900
- /** `OR`-combined {@link whereColumn}. */
901
- orWhereColumn(left: string, operator: string, right: string): this {
902
- return this.#whereColumn("or", left, operator, right);
1667
+ /** Alias of {@link whereExists} (Lucid parity). */
1668
+ andWhereExists(subquery: ModelQuery<BaseEntity>): this {
1669
+ return this.#pushExists("and", false, subquery);
903
1670
  }
904
1671
 
905
- #whereColumn(
906
- type: "and" | "or",
907
- left: string,
1672
+ /** `OR EXISTS (subquery)` (Lucid parity). */
1673
+ orWhereExists(subquery: ModelQuery<BaseEntity>): this {
1674
+ return this.#pushExists("or", false, subquery);
1675
+ }
1676
+
1677
+ /** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
1678
+ whereNotExists(subquery: ModelQuery<BaseEntity>): this {
1679
+ return this.#pushExists("and", true, subquery);
1680
+ }
1681
+
1682
+ /** Alias of {@link whereNotExists} (Lucid parity). */
1683
+ andWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
1684
+ return this.#pushExists("and", true, subquery);
1685
+ }
1686
+
1687
+ /** `OR NOT EXISTS (subquery)` (Lucid parity). */
1688
+ orWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
1689
+ return this.#pushExists("or", true, subquery);
1690
+ }
1691
+
1692
+ // ─── JSON ─────────────────────────────────────────────────
1693
+ //
1694
+ // Every value crosses the boundary as a bound param — the path and the
1695
+ // compared value both. Only the column is a quoted identifier. Path access
1696
+ // and containment are each spelled per dialect, and SQLite has no
1697
+ // containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
1698
+
1699
+ /**
1700
+ * `WHERE <col at path> <op> ?` — compare a value inside a JSON column
1701
+ * (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
1702
+ *
1703
+ * query.whereJsonPath('data', '$.address.city', '=', 'Paris')
1704
+ */
1705
+ whereJsonPath(column: string, path: string, value: unknown): this;
1706
+ whereJsonPath(
1707
+ column: string,
1708
+ path: string,
908
1709
  operator: string,
909
- right: string,
1710
+ value: unknown,
1711
+ ): this;
1712
+ whereJsonPath(
1713
+ column: string,
1714
+ path: string,
1715
+ operatorOrValue: unknown,
1716
+ value?: unknown,
910
1717
  ): this {
911
- if (!WHEREEXPR_OPERATORS.has(operator)) {
1718
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1719
+ }
1720
+
1721
+ /** Alias of {@link whereJsonPath} (Lucid parity). Operator defaults to `=`. */
1722
+ andWhereJsonPath(column: string, path: string, value: unknown): this;
1723
+ andWhereJsonPath(
1724
+ column: string,
1725
+ path: string,
1726
+ operator: string,
1727
+ value: unknown,
1728
+ ): this;
1729
+ andWhereJsonPath(
1730
+ column: string,
1731
+ path: string,
1732
+ operatorOrValue: unknown,
1733
+ value?: unknown,
1734
+ ): this {
1735
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1736
+ }
1737
+
1738
+ /** `OR <col at path> <op> ?` (Lucid parity). Operator defaults to `=`. */
1739
+ orWhereJsonPath(column: string, path: string, value: unknown): this;
1740
+ orWhereJsonPath(
1741
+ column: string,
1742
+ path: string,
1743
+ operator: string,
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,
1762
+ ): this {
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);
1804
+ }
1805
+
1806
+ /**
1807
+ * `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
1808
+ * `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
1809
+ *
1810
+ * Postgres and MySQL only — SQLite has no JSON containment operator and the
1811
+ * compiler raises `E_UNSUPPORTED` there.
1812
+ */
1813
+ whereJsonSupersetOf(column: string, value: unknown): this {
1814
+ return this.#pushJson("and", false, "superset", column, value);
1815
+ }
1816
+
1817
+ /** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
1818
+ andWhereJsonSupersetOf(column: string, value: unknown): this {
1819
+ return this.#pushJson("and", false, "superset", column, value);
1820
+ }
1821
+
1822
+ /** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
1823
+ orWhereJsonSupersetOf(column: string, value: unknown): this {
1824
+ return this.#pushJson("or", false, "superset", column, value);
1825
+ }
1826
+
1827
+ /** `WHERE NOT (<col> @> ?)` (Lucid parity). */
1828
+ whereNotJsonSupersetOf(column: string, value: unknown): this {
1829
+ return this.#pushJson("and", true, "superset", column, value);
1830
+ }
1831
+
1832
+ /** `OR NOT (<col> @> ?)` (Lucid parity). */
1833
+ orWhereNotJsonSupersetOf(column: string, value: unknown): this {
1834
+ return this.#pushJson("or", true, "superset", column, value);
1835
+ }
1836
+
1837
+ /**
1838
+ * `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
1839
+ * `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
1840
+ */
1841
+ whereJsonSubsetOf(column: string, value: unknown): this {
1842
+ return this.#pushJson("and", false, "subset", column, value);
1843
+ }
1844
+
1845
+ /** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
1846
+ andWhereJsonSubsetOf(column: string, value: unknown): this {
1847
+ return this.#pushJson("and", false, "subset", column, value);
1848
+ }
1849
+
1850
+ /** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
1851
+ orWhereJsonSubsetOf(column: string, value: unknown): this {
1852
+ return this.#pushJson("or", false, "subset", column, value);
1853
+ }
1854
+
1855
+ /** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
1856
+ whereNotJsonSubsetOf(column: string, value: unknown): this {
1857
+ return this.#pushJson("and", true, "subset", column, value);
1858
+ }
1859
+
1860
+ /** `OR NOT (<col> <@ ?)` (Lucid parity). */
1861
+ orWhereNotJsonSubsetOf(column: string, value: unknown): this {
1862
+ return this.#pushJson("or", true, "subset", column, value);
1863
+ }
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
+
1907
+ #pushJson(
1908
+ type: "and" | "or",
1909
+ negated: boolean,
1910
+ jsonOp: "path" | "superset" | "subset" | "equals",
1911
+ column: string,
1912
+ value: unknown,
1913
+ path?: string,
1914
+ operator?: string,
1915
+ ): this {
1916
+ // A JSONPath is bound, not interpolated, so injection is not the concern
1917
+ // here — a clear early error for a malformed path is. Lucid/Knex paths
1918
+ // start at the document root.
1919
+ if (path !== undefined && !path.startsWith("$")) {
1920
+ throw new Error(
1921
+ `whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`,
1922
+ );
1923
+ }
1924
+ // Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
1925
+ // and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
1926
+ // as-is would not cast. A path comparison keeps its scalar value.
1927
+ const bound =
1928
+ jsonOp === "path"
1929
+ ? value
1930
+ : typeof value === "string"
1931
+ ? value
1932
+ : JSON.stringify(value);
1933
+ this.#wheres.push({
1934
+ type,
1935
+ kind: "json",
1936
+ jsonOp,
1937
+ column: this.#resolveColumn(column),
1938
+ negated,
1939
+ path,
1940
+ operator,
1941
+ value: bound,
1942
+ });
1943
+ return this;
1944
+ }
1945
+
1946
+ #pushExists(
1947
+ type: "and" | "or",
1948
+ negated: boolean,
1949
+ subquery: ModelQuery<BaseEntity>,
1950
+ ): this {
1951
+ // `#buildSpec` is private, but private access is per-class, not per
1952
+ // instance: another ModelQuery's spec is reachable from here.
1953
+ this.#wheres.push({
1954
+ type,
1955
+ kind: "exists",
1956
+ negated,
1957
+ subquery: subquery.#buildSpec(),
1958
+ });
1959
+ return this;
1960
+ }
1961
+
1962
+ whereColumn(left: string, operator: string, right: string): this {
1963
+ return this.#whereColumn("and", left, operator, right);
1964
+ }
1965
+
1966
+ /** `OR`-combined {@link whereColumn}. */
1967
+ orWhereColumn(left: string, operator: string, right: string): this {
1968
+ return this.#whereColumn("or", left, operator, right);
1969
+ }
1970
+
1971
+ /** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
1972
+ whereNotColumn(left: string, operator: string, right: string): this {
1973
+ return this.#whereColumn("and", left, operator, right, true);
1974
+ }
1975
+
1976
+ /** Alias of {@link whereNotColumn} (Lucid parity). */
1977
+ andWhereNotColumn(left: string, operator: string, right: string): this {
1978
+ return this.#whereColumn("and", left, operator, right, true);
1979
+ }
1980
+
1981
+ /** `OR NOT (left <op> right)` (Lucid parity). */
1982
+ orWhereNotColumn(left: string, operator: string, right: string): this {
1983
+ return this.#whereColumn("or", left, operator, right, true);
1984
+ }
1985
+
1986
+ #whereColumn(
1987
+ type: "and" | "or",
1988
+ left: string,
1989
+ operator: string,
1990
+ right: string,
1991
+ negated = false,
1992
+ ): this {
1993
+ if (!WHEREEXPR_OPERATORS.has(operator)) {
912
1994
  throw new Error(
913
1995
  `whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`,
914
1996
  );
@@ -919,7 +2001,7 @@ export class ModelQuery<T extends BaseEntity> {
919
2001
  // `[table.]column` charset. This closes the injection surface regardless of
920
2002
  // what #resolveColumn returns (it can be an identity resolver on sub-queries).
921
2003
  const safe = (name: string): string => {
922
- const resolved = this.#resolveColumn(name);
2004
+ const resolved = this.#resolveColumnReference(name);
923
2005
  if (
924
2006
  !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
925
2007
  ) {
@@ -934,11 +2016,48 @@ export class ModelQuery<T extends BaseEntity> {
934
2016
  .map((part) => this.#quote(part))
935
2017
  .join(".");
936
2018
  };
937
- const sql = `${safe(left)} ${operator} ${safe(right)}`;
2019
+ const predicate = `${safe(left)} ${operator} ${safe(right)}`;
2020
+ // Both operands are already validated identifiers and the operator is
2021
+ // allow-listed, so wrapping in NOT(...) adds no new surface.
2022
+ const sql = negated ? `NOT (${predicate})` : predicate;
938
2023
  this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
939
2024
  return this;
940
2025
  }
941
2026
 
2027
+ /**
2028
+ * Resolve a column reference that may legitimately point at a table other
2029
+ * than this query's own.
2030
+ *
2031
+ * `#resolveColumn` only knows the entity's own columns, so it rejects
2032
+ * anything qualified. That is right for a value predicate, but wrong for a
2033
+ * column-to-column one: a correlated subquery
2034
+ * (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
2035
+ * and a joined query both have to name another table, and atlas cannot know
2036
+ * that table's columns. So: an unqualified name resolves as usual (typos
2037
+ * still get the helpful error), and a `table.column` naming a different
2038
+ * table passes through — validated against the identifier charset here and
2039
+ * quoted segment by segment by the caller, never interpolated loose. A typo
2040
+ * in that case surfaces as a database error rather than an atlas one, which
2041
+ * is the unavoidable cost of referencing a table we have no metadata for.
2042
+ */
2043
+ #resolveColumnReference(name: string): string {
2044
+ const qualified =
2045
+ /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
2046
+ if (!qualified) return this.#resolveColumn(name);
2047
+
2048
+ const [, table, column] = qualified;
2049
+ // Our own table: resolve the column half so `@Column({ columnName })` and
2050
+ // the camel→snake convention still apply.
2051
+ if (table === this.#tableName) {
2052
+ return `${table}.${this.#resolveColumn(column as string)}`;
2053
+ }
2054
+ // Another table in scope (outer query or JOIN). Charset-checked by the
2055
+ // regex above and quoted segment by segment by the caller — strict mode
2056
+ // does not apply, since its concern is unvalidated SQL reaching the
2057
+ // compiler and this identifier is validated.
2058
+ return `${table}.${column}`;
2059
+ }
2060
+
942
2061
  /**
943
2062
  * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
944
2063
  * by the existence of related rows, optionally constrained by a callback.
@@ -987,6 +2106,22 @@ export class ModelQuery<T extends BaseEntity> {
987
2106
  return this;
988
2107
  }
989
2108
 
2109
+ /** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
2110
+ andWhereHas(
2111
+ relationName: string,
2112
+ callback?: (query: ModelQuery<BaseEntity>) => void,
2113
+ ): this {
2114
+ return this.whereHas(relationName, callback);
2115
+ }
2116
+
2117
+ /** Alias of {@link whereDoesntHave} (Lucid parity). */
2118
+ andWhereDoesntHave(
2119
+ relationName: string,
2120
+ callback?: (query: ModelQuery<BaseEntity>) => void,
2121
+ ): this {
2122
+ return this.whereDoesntHave(relationName, callback);
2123
+ }
2124
+
990
2125
  /**
991
2126
  * Short form of `whereHas`. With an operator + count, emits a count threshold:
992
2127
  * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
@@ -1026,6 +2161,26 @@ export class ModelQuery<T extends BaseEntity> {
1026
2161
  return this;
1027
2162
  }
1028
2163
 
2164
+ /** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
2165
+ orDoesntHave(relationName: string): this {
2166
+ this.#wheres.push(this.#buildExistsClause("or", true, relationName));
2167
+ return this;
2168
+ }
2169
+
2170
+ /** Alias of {@link has} (Lucid parity) — `has` is already AND. */
2171
+ andHas(
2172
+ relationName: string,
2173
+ countOp?: string,
2174
+ countThreshold?: number,
2175
+ ): this {
2176
+ return this.has(relationName, countOp, countThreshold);
2177
+ }
2178
+
2179
+ /** Alias of {@link doesntHave} (Lucid parity). */
2180
+ andDoesntHave(relationName: string): this {
2181
+ return this.doesntHave(relationName);
2182
+ }
2183
+
1029
2184
  /**
1030
2185
  * Set this query's projection alias — only meaningful when this ModelQuery
1031
2186
  * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
@@ -1070,27 +2225,37 @@ export class ModelQuery<T extends BaseEntity> {
1070
2225
  /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
1071
2226
  async count(column: string = "*"): Promise<number> {
1072
2227
  const expr =
1073
- column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
2228
+ column === "*"
2229
+ ? "COUNT(*)"
2230
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
1074
2231
  return Number((await this.#runScalar(expr)) ?? 0);
1075
2232
  }
1076
2233
 
1077
2234
  async sum(column: string): Promise<number | null> {
1078
- const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
2235
+ const v = await this.#runScalar(
2236
+ `SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
2237
+ );
1079
2238
  return v === null || v === undefined ? null : Number(v);
1080
2239
  }
1081
2240
 
1082
2241
  async avg(column: string): Promise<number | null> {
1083
- const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
2242
+ const v = await this.#runScalar(
2243
+ `AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
2244
+ );
1084
2245
  return v === null || v === undefined ? null : Number(v);
1085
2246
  }
1086
2247
 
1087
2248
  async min(column: string): Promise<number | null> {
1088
- const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
2249
+ const v = await this.#runScalar(
2250
+ `MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
2251
+ );
1089
2252
  return v === null || v === undefined ? null : Number(v);
1090
2253
  }
1091
2254
 
1092
2255
  async max(column: string): Promise<number | null> {
1093
- const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
2256
+ const v = await this.#runScalar(
2257
+ `MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
2258
+ );
1094
2259
  return v === null || v === undefined ? null : Number(v);
1095
2260
  }
1096
2261
 
@@ -1140,37 +2305,172 @@ export class ModelQuery<T extends BaseEntity> {
1140
2305
  return this;
1141
2306
  }
1142
2307
 
1143
- orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
1144
- 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
+ });
2343
+ return this;
2344
+ }
2345
+
2346
+ /**
2347
+ * `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
2348
+ * no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
2349
+ * alias.
2350
+ *
2351
+ * query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
2352
+ *
2353
+ * The fragment keeps its position among the plain `orderBy` terms.
2354
+ *
2355
+ * **Strict mode**: like {@link whereRaw}, this throws when
2356
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
2357
+ *
2358
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
2359
+ */
2360
+ orderByRaw(sql: string): this {
2361
+ this.#assertRawAllowed("orderByRaw");
2362
+ this.#orderBys.push({ raw: sql });
1145
2363
  return this;
1146
2364
  }
1147
2365
 
1148
2366
  /**
1149
2367
  * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
1150
2368
  * through the entity's column map (camelCase → snake_case) like `orderBy`.
1151
- * For a raw grouping expression, use a `whereRaw`-style construct via the
1152
- * fluent {@link QueryBuilder}.
2369
+ * For a grouping expression with no typed form, see {@link groupByRaw}.
1153
2370
  */
1154
2371
  groupBy(...columns: string[]): this {
1155
2372
  for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
1156
2373
  return this;
1157
2374
  }
1158
2375
 
2376
+ /**
2377
+ * `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
2378
+ * no typed form, e.g. `DATE_TRUNC('day', created_at)`.
2379
+ *
2380
+ * The fragment keeps its position among the plain `groupBy` terms.
2381
+ *
2382
+ * **Strict mode**: like {@link whereRaw}, this throws when
2383
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
2384
+ *
2385
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
2386
+ */
2387
+ groupByRaw(sql: string): this {
2388
+ this.#assertRawAllowed("groupByRaw");
2389
+ this.#groupBy.push({ raw: sql });
2390
+ return this;
2391
+ }
2392
+
1159
2393
  /**
1160
2394
  * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
1161
- * The column is passed verbatim to the Rust HAVING compiler, which quotes a
1162
- * plain identifier or accepts an allow-listed aggregate expression
1163
- * (`COUNT(*)`, `SUM(col)`, …) it is NOT run through the entity column map,
1164
- * so aggregate expressions and result aliases both work.
2395
+ * A bare model property is resolved through the entity column map (honouring
2396
+ * `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
2397
+ * expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
2398
+ * `having` can still reference `withCount`/`withAggregate` aliases.
1165
2399
  */
1166
2400
  having(column: string, operator: string, value: unknown): this {
1167
- this.#having.push({ column, operator, value, type: "and" });
2401
+ this.#having.push({
2402
+ column: this.#resolveHavingCol(column),
2403
+ operator,
2404
+ value: this.#prep(column, value),
2405
+ type: "and",
2406
+ });
1168
2407
  return this;
1169
2408
  }
1170
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
+
2451
+ /**
2452
+ * Resolve a HAVING column: a bare model property maps to its DB column
2453
+ * (honouring `@Column({ columnName })`), but an aggregate expression
2454
+ * (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
2455
+ * so `having` can still reference `withCount`/`withAggregate` aliases.
2456
+ */
2457
+ #resolveHavingCol(column: string): string {
2458
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) return column;
2459
+ try {
2460
+ return this.#resolveColumn(column);
2461
+ } catch {
2462
+ return column;
2463
+ }
2464
+ }
2465
+
1171
2466
  /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
1172
2467
  orHaving(column: string, operator: string, value: unknown): this {
1173
- this.#having.push({ column, operator, value, type: "or" });
2468
+ this.#having.push({
2469
+ column: this.#resolveHavingCol(column),
2470
+ operator,
2471
+ value: this.#prep(column, value),
2472
+ type: "or",
2473
+ });
1174
2474
  return this;
1175
2475
  }
1176
2476
 
@@ -1183,6 +2483,14 @@ export class ModelQuery<T extends BaseEntity> {
1183
2483
  * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1184
2484
  */
1185
2485
  havingRaw(sql: string, bindings: readonly unknown[] = []): this {
2486
+ // Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
2487
+ // surface, so prod hardening must be able to neutralise it too.
2488
+ if (isAtlasStrictMode() && !isInternalBypass()) {
2489
+ throw new Error(
2490
+ "havingRaw() is disabled in Atlas strict mode. " +
2491
+ "Use having(column, operator, value) instead.",
2492
+ );
2493
+ }
1186
2494
  this.#having.push({
1187
2495
  kind: "raw",
1188
2496
  sql,
@@ -1197,27 +2505,161 @@ export class ModelQuery<T extends BaseEntity> {
1197
2505
  * appended as a parenthesised UNION branch; its bindings are re-indexed into
1198
2506
  * the outer parameter list.
1199
2507
  */
1200
- union(query: ModelQuery<BaseEntity>): this {
1201
- this.#unions.push({ query, all: false });
2508
+ union(query: UnionArg): this {
2509
+ this.#unions.push({ query: this.#resolveUnion(query), all: false });
1202
2510
  return this;
1203
2511
  }
1204
2512
 
1205
2513
  /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
1206
- unionAll(query: ModelQuery<BaseEntity>): this {
1207
- this.#unions.push({ query, all: true });
2514
+ unionAll(query: UnionArg): this {
2515
+ this.#unions.push({ query: this.#resolveUnion(query), all: true });
2516
+ return this;
2517
+ }
2518
+
2519
+ /** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
2520
+ intersect(query: UnionArg): this {
2521
+ this.#unions.push({
2522
+ query: this.#resolveUnion(query),
2523
+ all: false,
2524
+ op: "intersect",
2525
+ });
2526
+ return this;
2527
+ }
2528
+
2529
+ /**
2530
+ * `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
2531
+ *
2532
+ * Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
2533
+ * INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
2534
+ * `E_UNSUPPORTED` there rather than emitting a syntax error.
2535
+ */
2536
+ intersectAll(query: UnionArg): this {
2537
+ this.#unions.push({
2538
+ query: this.#resolveUnion(query),
2539
+ all: true,
2540
+ op: "intersect",
2541
+ });
2542
+ return this;
2543
+ }
2544
+
2545
+ /** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
2546
+ except(query: UnionArg): this {
2547
+ this.#unions.push({
2548
+ query: this.#resolveUnion(query),
2549
+ all: false,
2550
+ op: "except",
2551
+ });
2552
+ return this;
2553
+ }
2554
+
2555
+ /** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
2556
+ exceptAll(query: UnionArg): this {
2557
+ this.#unions.push({
2558
+ query: this.#resolveUnion(query),
2559
+ all: true,
2560
+ op: "except",
2561
+ });
1208
2562
  return this;
1209
2563
  }
1210
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
+
1211
2588
  /**
1212
2589
  * `WITH <name> AS (<query>)` — register a Common Table Expression
1213
2590
  * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
1214
2591
  * sub-query is compiled and its bindings are re-indexed into the outer list.
1215
2592
  */
1216
- with(name: string, query: ModelQuery<BaseEntity>): this {
2593
+ with(name: string, query: UnionArg): this {
2594
+ return this.#pushCte("with", name, query, {});
2595
+ }
2596
+
2597
+ /**
2598
+ * `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
2599
+ * (Lucid/Knex `withRecursive`), for trees and graph walks.
2600
+ *
2601
+ * RECURSIVE is a property of the WITH clause rather than of one CTE, so a
2602
+ * single recursive entry makes the whole clause recursive — which is what
2603
+ * all three dialects require. Mixing `with()` and `withRecursive()` is fine.
2604
+ *
2605
+ * The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
2606
+ * an anchor `SELECT` unioned with a select that references `<name>`.
2607
+ */
2608
+ withRecursive(name: string, query: UnionArg, columns?: string[]): this {
2609
+ return this.#pushCte("withRecursive", name, query, {
2610
+ recursive: true,
2611
+ columns,
2612
+ });
2613
+ }
2614
+
2615
+ /**
2616
+ * `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
2617
+ * once and stashed (Lucid/Knex `withMaterialized`).
2618
+ *
2619
+ * Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
2620
+ * compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
2621
+ */
2622
+ withMaterialized(name: string, query: UnionArg): this {
2623
+ return this.#pushCte("withMaterialized", name, query, {
2624
+ materialized: true,
2625
+ });
2626
+ }
2627
+
2628
+ /** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
2629
+ withNotMaterialized(name: string, query: UnionArg): this {
2630
+ return this.#pushCte("withNotMaterialized", name, query, {
2631
+ materialized: false,
2632
+ });
2633
+ }
2634
+
2635
+ #pushCte(
2636
+ method: string,
2637
+ name: string,
2638
+ query: UnionArg,
2639
+ options: {
2640
+ recursive?: boolean;
2641
+ materialized?: boolean;
2642
+ columns?: string[];
2643
+ },
2644
+ ): this {
1217
2645
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1218
- throw new Error(`with(): CTE name '${name}' is not a valid identifier`);
2646
+ throw new Error(
2647
+ `${method}(): CTE name '${name}' is not a valid identifier`,
2648
+ );
1219
2649
  }
1220
- this.#ctes.push({ name, query });
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);
1221
2663
  return this;
1222
2664
  }
1223
2665
 
@@ -1232,21 +2674,180 @@ export class ModelQuery<T extends BaseEntity> {
1232
2674
  wherePivot(column: string, value: unknown): this;
1233
2675
  wherePivot(column: string, operator: string, value: unknown): this;
1234
2676
  wherePivot(column: string, operatorOrValue: unknown, value?: unknown): this {
2677
+ return this.#pushPivot("and", column, operatorOrValue, value);
2678
+ }
2679
+
2680
+ /** Alias of {@link wherePivot} (Lucid parity) — pivot filters already AND together. */
2681
+ andWherePivot(column: string, value: unknown): this;
2682
+ andWherePivot(column: string, operator: string, value: unknown): this;
2683
+ andWherePivot(
2684
+ column: string,
2685
+ operatorOrValue: unknown,
2686
+ value?: unknown,
2687
+ ): this {
2688
+ return this.#pushPivot("and", column, operatorOrValue, value);
2689
+ }
2690
+
2691
+ /**
2692
+ * `@ManyToMany` only — OR form of {@link wherePivot} (Lucid parity).
2693
+ *
2694
+ * The pivot filters are compiled as a parenthesised group, so an OR joins
2695
+ * the other pivot filters and cannot escape the `pivot_fk IN (parents)`
2696
+ * scoping that makes the preload correct.
2697
+ */
2698
+ orWherePivot(column: string, value: unknown): this;
2699
+ orWherePivot(column: string, operator: string, value: unknown): this;
2700
+ orWherePivot(
2701
+ column: string,
2702
+ operatorOrValue: unknown,
2703
+ value?: unknown,
2704
+ ): this {
2705
+ return this.#pushPivot("or", column, operatorOrValue, value);
2706
+ }
2707
+
2708
+ /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
2709
+ whereInPivot(column: string, values: readonly unknown[]): this {
2710
+ return this.#pushPivotOp("and", column, "IN", [...values]);
2711
+ }
2712
+
2713
+ /** Alias of {@link whereInPivot} (Lucid parity). */
2714
+ andWhereInPivot(column: string, values: readonly unknown[]): this {
2715
+ return this.#pushPivotOp("and", column, "IN", [...values]);
2716
+ }
2717
+
2718
+ /** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
2719
+ orWhereInPivot(column: string, values: readonly unknown[]): this {
2720
+ return this.#pushPivotOp("or", column, "IN", [...values]);
2721
+ }
2722
+
2723
+ /** Alias of {@link whereInPivot} kept for the earlier atlas name. */
2724
+ wherePivotIn(column: string, values: readonly unknown[]): this {
2725
+ return this.whereInPivot(column, values);
2726
+ }
2727
+
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 {
1235
2740
  if (value === undefined) {
1236
- this.#pivotWheres.push({ column, operator: "=", value: operatorOrValue });
1237
- } else {
1238
- this.#pivotWheres.push({
1239
- column,
1240
- operator: operatorOrValue as string,
1241
- value,
1242
- });
2741
+ return this.#pushPivotOp("and", column, "!=", operatorOrValue);
1243
2742
  }
1244
- return this;
2743
+ return this.#pushPivotOp(
2744
+ "and",
2745
+ column,
2746
+ negateOperator(String(operatorOrValue)),
2747
+ value,
2748
+ );
1245
2749
  }
1246
2750
 
1247
- /** `@ManyToMany` only `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `wherePivotIn`). */
1248
- wherePivotIn(column: string, values: readonly unknown[]): this {
1249
- this.#pivotWheres.push({ column, operator: "IN", value: [...values] });
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
+ );
2767
+ }
2768
+
2769
+ /** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
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
+ );
2785
+ }
2786
+
2787
+ /** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
2788
+ whereNotInPivot(column: string, values: readonly unknown[]): this {
2789
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
2790
+ }
2791
+
2792
+ /** Alias of {@link whereNotInPivot} (Lucid parity). */
2793
+ andWhereNotInPivot(column: string, values: readonly unknown[]): this {
2794
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
2795
+ }
2796
+
2797
+ /** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
2798
+ orWhereNotInPivot(column: string, values: readonly unknown[]): this {
2799
+ return this.#pushPivotOp("or", column, "NOT IN", [...values]);
2800
+ }
2801
+
2802
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
2803
+ whereNullPivot(column: string): this {
2804
+ return this.#pushPivotOp("and", column, "IS NULL", null);
2805
+ }
2806
+
2807
+ /** Alias of {@link whereNullPivot} (Lucid parity). */
2808
+ andWhereNullPivot(column: string): this {
2809
+ return this.#pushPivotOp("and", column, "IS NULL", null);
2810
+ }
2811
+
2812
+ /** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
2813
+ orWhereNullPivot(column: string): this {
2814
+ return this.#pushPivotOp("or", column, "IS NULL", null);
2815
+ }
2816
+
2817
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
2818
+ whereNotNullPivot(column: string): this {
2819
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
2820
+ }
2821
+
2822
+ /** Alias of {@link whereNotNullPivot} (Lucid parity). */
2823
+ andWhereNotNullPivot(column: string): this {
2824
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
2825
+ }
2826
+
2827
+ /** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
2828
+ orWhereNotNullPivot(column: string): this {
2829
+ return this.#pushPivotOp("or", column, "IS NOT NULL", null);
2830
+ }
2831
+
2832
+ /** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
2833
+ #pushPivot(
2834
+ type: "and" | "or",
2835
+ column: string,
2836
+ operatorOrValue: unknown,
2837
+ value?: unknown,
2838
+ ): this {
2839
+ return value === undefined
2840
+ ? this.#pushPivotOp(type, column, "=", operatorOrValue)
2841
+ : this.#pushPivotOp(type, column, operatorOrValue as string, value);
2842
+ }
2843
+
2844
+ #pushPivotOp(
2845
+ type: "and" | "or",
2846
+ column: string,
2847
+ operator: string,
2848
+ value: unknown,
2849
+ ): this {
2850
+ this.#pivotWheres.push({ column, operator, value, type });
1250
2851
  return this;
1251
2852
  }
1252
2853
 
@@ -1255,10 +2856,16 @@ export class ModelQuery<T extends BaseEntity> {
1255
2856
  column: string;
1256
2857
  operator: string;
1257
2858
  value: unknown;
2859
+ type: "and" | "or";
1258
2860
  }> {
1259
2861
  return this.#pivotWheres;
1260
2862
  }
1261
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
+
1262
2869
  limit(n: number): this {
1263
2870
  // Guard here with a clear message — the Rust spec types limit as
1264
2871
  // u64, so a negative/non-integer otherwise surfaces as a cryptic
@@ -1271,6 +2878,26 @@ export class ModelQuery<T extends BaseEntity> {
1271
2878
  return this;
1272
2879
  }
1273
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
+
1274
2901
  offset(n: number): this {
1275
2902
  if (!Number.isInteger(n) || n < 0) {
1276
2903
  throw new Error(`offset must be a non-negative integer, got ${n}`);
@@ -1341,21 +2968,99 @@ export class ModelQuery<T extends BaseEntity> {
1341
2968
  }
1342
2969
 
1343
2970
  /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
2971
+ /**
2972
+ * DB column backing the soft-delete `deletedAt` property — honours a
2973
+ * `@Column({ columnName })` override, read straight from the entity metadata
2974
+ * (not the resolver callback, which is identity for subqueries/preloads).
2975
+ */
2976
+ #deletedAtColumn(): string {
2977
+ const col = this.#entityClass
2978
+ ? getColumnMetadata(this.#entityClass).find(
2979
+ (c) => c.propertyKey === "deletedAt",
2980
+ )
2981
+ : undefined;
2982
+ return col?.columnName ?? "deleted_at";
2983
+ }
2984
+
1344
2985
  #buildSpec(): SelectSpec {
2986
+ // `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
2987
+ // compiler emits the lock clause only when a base mode is set, so a lone
2988
+ // modifier would be a SILENT no-op (dangerous for job-queue polling that
2989
+ // believes it skips locked rows). Fail loud instead. Order-independent: this
2990
+ // fires whether the modifier was chained before or after the base lock.
2991
+ if (this.#lockModifier && !this.#lockMode) {
2992
+ throw new Error(
2993
+ `${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`,
2994
+ );
2995
+ }
2996
+ // With a JOIN and the default `SELECT *`, scope the projection to the base
2997
+ // table's declared columns so joined columns can't clobber the model's fields
2998
+ // (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
2999
+ // AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
3000
+ let selectCols = this.#select;
3001
+ if (
3002
+ this.#joins.length > 0 &&
3003
+ this.#select.length === 1 &&
3004
+ this.#select[0] === "*"
3005
+ ) {
3006
+ const cols = getColumnMetadata(this.#entityClass).map(
3007
+ (c) =>
3008
+ `${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`,
3009
+ );
3010
+ if (cols.length > 0) selectCols = cols;
3011
+ } else if (
3012
+ !(selectCols.length === 1 && selectCols[0] === "*") &&
3013
+ selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))
3014
+ ) {
3015
+ // A partial `select()` of PLAIN columns that omits the primary key would
3016
+ // hydrate a persisted entity with no PK — a later save() would then INSERT
3017
+ // instead of UPDATE (double-write / unique violation / spurious
3018
+ // beforeCreate). Auto-include the (base-table-qualified) PK so model
3019
+ // entities stay saveable. Aggregate/alias/expression selects are left
3020
+ // untouched — use `.pojo()` for those.
3021
+ const pkProp = getPrimaryKey(this.#entityClass);
3022
+ if (pkProp) {
3023
+ const pkCol =
3024
+ getColumnMetadata(this.#entityClass).find(
3025
+ (c) => c.propertyKey === pkProp,
3026
+ )?.columnName ?? camelToSnake(pkProp);
3027
+ // The PK counts as present ONLY as the bare column or the BASE-table-
3028
+ // qualified column. A joined `other.id` must NOT satisfy it (its leaf
3029
+ // collides with the PK name but it's a different table's row) — otherwise
3030
+ // we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
3031
+ // save(). Appended last, `base.id` also wins the duplicate result key
3032
+ // (rows collect in column order, last-wins) so the base row's PK hydrates.
3033
+ const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
3034
+ if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
3035
+ selectCols = [...selectCols, baseQualifiedPk];
3036
+ }
3037
+ }
3038
+ }
1345
3039
  const wheres: WhereClause[] = [...this.#wheres];
1346
- // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes
3040
+ // Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
3041
+ // `.wherePivot()` recorded since the proxy handed back this query (pushed to
3042
+ // the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
3043
+ if (this.#pivotExists) {
3044
+ const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
3045
+ wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
3046
+ }
3047
+ // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
3048
+ // Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
3049
+ // override on the soft-delete column is honoured on the read side too — matching
3050
+ // the write side (delete/restore go through #dbColumn).
1347
3051
  if (this.#softDeletes) {
3052
+ const deletedAtCol = this.#deletedAtColumn();
1348
3053
  if (this.#softScope === "default") {
1349
3054
  wheres.push({
1350
3055
  type: "and",
1351
- column: "deleted_at",
3056
+ column: deletedAtCol,
1352
3057
  operator: "IS NULL",
1353
3058
  value: null,
1354
3059
  });
1355
3060
  } else if (this.#softScope === "only-trashed") {
1356
3061
  wheres.push({
1357
3062
  type: "and",
1358
- column: "deleted_at",
3063
+ column: deletedAtCol,
1359
3064
  operator: "IS NOT NULL",
1360
3065
  value: null,
1361
3066
  });
@@ -1366,8 +3071,9 @@ export class ModelQuery<T extends BaseEntity> {
1366
3071
  return {
1367
3072
  kind: "select",
1368
3073
  table: this.#tableName,
1369
- select: this.#select,
3074
+ select: selectCols,
1370
3075
  selectSubqueries: this.#selectSubqueries,
3076
+ selectRaw: this.#selectRaw,
1371
3077
  wheres,
1372
3078
  orderBy: this.#orderBys,
1373
3079
  groupBy: this.#groupBy,
@@ -1375,23 +3081,148 @@ export class ModelQuery<T extends BaseEntity> {
1375
3081
  limit: this.#limit ?? null,
1376
3082
  offset: this.#offset ?? null,
1377
3083
  distinct: this.#distinct,
1378
- ctes: this.#ctes.map((c) => {
1379
- const { sql, params } = c.query.toSQL();
1380
- return { name: c.name, sql, params };
1381
- }),
3084
+ distinctOn: this.#distinctOn,
3085
+ ctes: this.#compiledCtes(),
1382
3086
  unions: this.#unions.map((u) => {
1383
- const { sql, params } = u.query.toSQL();
1384
- return { sql, params, all: u.all };
3087
+ const { sql, params } = u.query.#compiledNative();
3088
+ return { sql, params, all: u.all, op: u.op ?? null };
1385
3089
  }),
1386
3090
  joins: this.#joins,
1387
- lockMode: this.#lockMode,
3091
+ lockMode: this.#lockMode
3092
+ ? this.#lockModifier
3093
+ ? `${this.#lockMode} ${this.#lockModifier}`
3094
+ : this.#lockMode
3095
+ : null,
1388
3096
  };
1389
3097
  }
1390
3098
 
1391
- /** Build SQL + params via the Rust query compiler. */
1392
- 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[] } {
1393
3104
  const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
1394
- 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
+ : "";
1395
3226
  }
1396
3227
 
1397
3228
  /**
@@ -1419,8 +3250,10 @@ export class ModelQuery<T extends BaseEntity> {
1419
3250
  }
1420
3251
 
1421
3252
  async #doExec(): Promise<T[]> {
1422
- const { sql, params } = this.toSQL();
1423
- const rawRows = await this.#db.query<Record<string, unknown>>(sql, params);
3253
+ const { sql, params } = this.#compiledNative();
3254
+ const rawRows = await this.#raceTimeout(
3255
+ this.#db.query<Record<string, unknown>>(sql, params, this.#meta("exec")),
3256
+ );
1424
3257
  // Peel withCount / withAggregate alias columns off the raw row into $extras
1425
3258
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
1426
3259
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -1434,6 +3267,8 @@ export class ModelQuery<T extends BaseEntity> {
1434
3267
  }
1435
3268
  const entity = this.#hydrateFn(row);
1436
3269
  for (const [k, v] of Object.entries(picked)) entity.setExtra(k, v);
3270
+ // Thread query-level sideloaded context onto each hydrated instance.
3271
+ if (this.#sideloaded) entity.$sideloaded = { ...this.#sideloaded };
1437
3272
  return entity;
1438
3273
  });
1439
3274
 
@@ -1442,9 +3277,42 @@ export class ModelQuery<T extends BaseEntity> {
1442
3277
  await this.#resolvePreloads(entities);
1443
3278
  }
1444
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
+
1445
3288
  return entities;
1446
3289
  }
1447
3290
 
3291
+ /**
3292
+ * Execute and return PLAIN row objects (raw snake_case DB columns), skipping
3293
+ * model hydration, `@column({ consume })`, dirty-tracking and preloads —
3294
+ * AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
3295
+ * instances aren't needed.
3296
+ */
3297
+ async pojo<R = Record<string, unknown>>(): Promise<R[]> {
3298
+ const { sql, params } = this.#compiledNative();
3299
+ return this.#db.query<R>(sql, params);
3300
+ }
3301
+
3302
+ /**
3303
+ * Thread arbitrary context onto every instance this query hydrates, exposed as
3304
+ * `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
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.
3308
+ */
3309
+ sideload(values: Record<string, unknown>, merge = false): this {
3310
+ this.#sideloaded = merge
3311
+ ? { ...this.#sideloaded, ...values }
3312
+ : { ...values };
3313
+ return this;
3314
+ }
3315
+
1448
3316
  /** Resolve preloaded relations via batched subqueries (no N+1). */
1449
3317
  async #resolvePreloads(entities: T[]): Promise<void> {
1450
3318
  if (!this.#entityClass) return;
@@ -1463,6 +3331,14 @@ export class ModelQuery<T extends BaseEntity> {
1463
3331
  relation.type,
1464
3332
  ctx,
1465
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
+ }
1466
3342
  await this.#applyNestedPreloads(allRelated, ctx);
1467
3343
  }
1468
3344
  }
@@ -1473,33 +3349,98 @@ export class ModelQuery<T extends BaseEntity> {
1473
3349
  relationName: string,
1474
3350
  ): PreloadContext | null {
1475
3351
  const relatedClass = relation.target() as new () => BaseEntity;
1476
- const relatedMeta = getEntityMetadata(relatedClass);
1477
- if (!relatedMeta) return null;
3352
+ // Boot the related model's metadata on demand (Lucid parity): a preload
3353
+ // must not silently no-op just because the related class hasn't been
3354
+ // touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
3355
+ // static table / naming strategy when the decorator hasn't run.
3356
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1478
3357
 
1479
3358
  // Resolve row keys against declared column metadata, NOT `in entity` —
1480
3359
  // entities using Adonis' `declare field: T` pattern have no own-properties
1481
3360
  // on a freshly constructed instance, so `key in entity` is always false and
1482
3361
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
1483
- 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";
1484
3370
  const validColumns = new Set<string>();
3371
+ // Reverse map (db column → property) so an explicit `@Column({ columnName })`
3372
+ // on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
3373
+ const byDbName = new Map<string, string>();
3374
+ // Capture the related model's `@Column({ consume })` adapters + its date
3375
+ // columns so preloaded rows hydrate identically to a direct query — dates
3376
+ // become Chronos DateTime, decimal/etc adapters run. Without this, a
3377
+ // preloaded relation left column values raw (Lucid parity bug + a runtime
3378
+ // footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
3379
+ const consumes = new Map<
3380
+ string,
3381
+ (v: unknown, attribute?: string, model?: unknown) => unknown
3382
+ >();
3383
+ let relatedPkDb = camelToSnake(relatedPkName);
1485
3384
  for (const col of getColumnMetadata(relatedClass)) {
3385
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
1486
3386
  validColumns.add(col.propertyKey);
1487
- validColumns.add(camelToSnake(col.propertyKey));
3387
+ validColumns.add(db);
3388
+ byDbName.set(db, col.propertyKey);
3389
+ if (col.consume) consumes.set(col.propertyKey, col.consume);
3390
+ // The related PK may be multi-word (postId→post_id) or columnName-mapped;
3391
+ // its DB column name is what the WHERE + row indexing must use.
3392
+ if (col.propertyKey === relatedPkName) relatedPkDb = db;
1488
3393
  }
1489
3394
  validColumns.add(relatedPkName);
1490
3395
  validColumns.add(camelToSnake(relatedPkName));
3396
+ const dateCols = getDateColumnConfig(relatedClass);
3397
+ const consumeValue = (
3398
+ prop: string,
3399
+ value: unknown,
3400
+ model?: unknown,
3401
+ ): unknown => {
3402
+ const c = consumes.get(prop);
3403
+ // Adonis Lucid signature: (value, attribute, model).
3404
+ if (c) return c(value, prop, model);
3405
+ if (dateCols[prop] && value != null)
3406
+ return dateTimeAtlasAdapter.consume(value);
3407
+ return value;
3408
+ };
1491
3409
 
3410
+ // A repository for the related model so preloaded instances are hydrated with
3411
+ // the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
3412
+ // not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
3413
+ // refresh()/fresh()/load()/related(). Without this a preloaded relation
3414
+ // looked $isNew/$isLocal/$dirty and a later save() over-updated it.
3415
+ const relatedRepo = new BaseRepository(relatedClass, this.#db, {
3416
+ dialect: this.#dialect,
3417
+ });
3418
+ // Propagate the domain-event bus so save()/create() from a preloaded relation
3419
+ // still dispatch events (a fresh repo has none by default).
3420
+ relatedRepo.onDomainEvents = this.#onDomainEvents;
1492
3421
  const hydrate = (row: Record<string, unknown>): BaseEntity => {
1493
3422
  const entity = new relatedClass();
1494
3423
  for (const [key, value] of Object.entries(row)) {
1495
3424
  const camelKey = snakeToCamel(key);
1496
- const targetKey = validColumns.has(camelKey)
1497
- ? camelKey
1498
- : validColumns.has(key)
1499
- ? key
1500
- : null;
1501
- if (targetKey !== null) entity.setProp(targetKey, value);
3425
+ const targetKey =
3426
+ byDbName.get(key) ??
3427
+ (validColumns.has(camelKey)
3428
+ ? camelKey
3429
+ : validColumns.has(key)
3430
+ ? key
3431
+ : null);
3432
+ if (targetKey !== null)
3433
+ entity.setProp(targetKey, consumeValue(targetKey, value, entity));
1502
3434
  }
3435
+ // Freeze the clean snapshot + mark persisted/from-DB, and back-reference
3436
+ // the related repo (mirrors BaseRepository.#hydrate).
3437
+ entity.markAsPersisted();
3438
+ entity.markAsFromDatabase();
3439
+ Object.defineProperty(entity, REPO_REF, {
3440
+ value: relatedRepo,
3441
+ enumerable: false,
3442
+ configurable: true,
3443
+ });
1503
3444
  return entity;
1504
3445
  };
1505
3446
 
@@ -1508,7 +3449,9 @@ export class ModelQuery<T extends BaseEntity> {
1508
3449
  relationName,
1509
3450
  relatedClass,
1510
3451
  relatedTable: relatedMeta.tableName,
1511
- relatedPk: getPrimaryKey(relatedClass) ?? "id",
3452
+ // DB column name (not property) used as the WHERE column in the related
3453
+ // query AND to index the returned DB rows by their PK value.
3454
+ relatedPk: relatedPkDb,
1512
3455
  hydrate,
1513
3456
  runInQuery: (table, column, values) =>
1514
3457
  this.#runInQuery(table, column, values),
@@ -1570,11 +3513,7 @@ export class ModelQuery<T extends BaseEntity> {
1570
3513
  );
1571
3514
  }
1572
3515
  const throughClass = relation.through() as new () => BaseEntity;
1573
- const throughMeta = getEntityMetadata(throughClass);
1574
- if (!throughMeta)
1575
- throw new Error(
1576
- `Entity metadata missing on through class ${throughClass.name}`,
1577
- );
3516
+ const throughMeta = ensureEntityMetadata(throughClass);
1578
3517
  const throughTable = throughMeta.tableName;
1579
3518
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1580
3519
  const parentLocal =
@@ -1583,7 +3522,13 @@ export class ModelQuery<T extends BaseEntity> {
1583
3522
  relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1584
3523
  const secondKey =
1585
3524
  relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1586
- const secondLocal = relation.secondLocalKey ?? throughPk;
3525
+ // secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
3526
+ // DB column — resolve the through model's key (default: its PK), honouring a
3527
+ // multi-word / columnName PK. (parentLocal stays a property: it's read off
3528
+ // the parent ENTITY, not a row.)
3529
+ const secondLocal = buildColumnResolver(throughClass)(
3530
+ relation.secondLocalKey ?? throughPk,
3531
+ );
1587
3532
 
1588
3533
  const parentIds = entities
1589
3534
  .map((e) => e[parentLocal])
@@ -1752,7 +3697,11 @@ export class ModelQuery<T extends BaseEntity> {
1752
3697
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1753
3698
  const otherKey =
1754
3699
  pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
1755
- const pk = getPrimaryKey(this.#entityClass) ?? "id";
3700
+ // The pivot FK stores `parent[localKey]` (default PK) attach() writes it,
3701
+ // so preload MUST read back with the SAME key, else a custom-localKey m2m
3702
+ // writes `user_code = code` but reads `user_code IN (id)` and never matches.
3703
+ const pk =
3704
+ ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1756
3705
 
1757
3706
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1758
3707
  if (ids.length === 0) return [];
@@ -1766,18 +3715,49 @@ export class ModelQuery<T extends BaseEntity> {
1766
3715
  operator: string;
1767
3716
  value: unknown;
1768
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];
1769
3721
  if (ctx.nestedCallback) {
1770
3722
  const scratch = new ModelQuery<BaseEntity>(
1771
3723
  ctx.relatedTable,
1772
3724
  this.#db,
1773
3725
  (r) => r as BaseEntity,
1774
3726
  ctx.relatedClass,
1775
- (c) => c,
3727
+ buildColumnResolver(ctx.relatedClass),
1776
3728
  false,
1777
3729
  this.#dialect,
3730
+ buildValuePreparer(ctx.relatedClass),
1778
3731
  );
1779
3732
  ctx.nestedCallback(scratch);
1780
- for (const c of scratch.pivotConstraints) pivotWheres.push({ ...c });
3733
+ extraPivotCols.push(...scratch.pivotColumnRequests);
3734
+ // Apply the pivot column adapters' `prepare` to wherePivot values, so a
3735
+ // filter like wherePivot('amount', new Money(1)) matches what attach()/
3736
+ // sync() stored (they prepare the same extras on write).
3737
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
3738
+ for (const c of scratch.pivotConstraints) {
3739
+ const prep = pivotAdapters[c.column]?.prepare;
3740
+ // Same guards as the attach()/sync() write path: wrap a throwing
3741
+ // adapter with a column-annotated error and reject async adapters,
3742
+ // so filter and write agree on the adapter contract.
3743
+ const apply = (v: unknown): unknown => {
3744
+ if (!prep) return v;
3745
+ let out: unknown;
3746
+ try {
3747
+ // Adonis Lucid signature: (value, attribute, model). wherePivot is
3748
+ // a query filter — attribute known, no model instance.
3749
+ out = prep(v, c.column, undefined);
3750
+ } catch (err) {
3751
+ throw wrapAdapterError("prepare", c.column, err);
3752
+ }
3753
+ assertNotPromise("prepare", c.column, out);
3754
+ return out;
3755
+ };
3756
+ const value = Array.isArray(c.value)
3757
+ ? c.value.map(apply)
3758
+ : apply(c.value);
3759
+ pivotWheres.push({ ...c, value });
3760
+ }
1781
3761
  }
1782
3762
 
1783
3763
  // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
@@ -1797,7 +3777,11 @@ export class ModelQuery<T extends BaseEntity> {
1797
3777
 
1798
3778
  // Step 2 — load all related entities in one query
1799
3779
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
1800
- 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
+ ];
1801
3785
  const pivotAdapters = pivot.pivotColumnAdapters ?? {};
1802
3786
  // When pivot extras are projected, each (parent, related) edge gets its OWN
1803
3787
  // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
@@ -1830,7 +3814,8 @@ export class ModelQuery<T extends BaseEntity> {
1830
3814
  const adapter = pivotAdapters[col];
1831
3815
  related.setExtra(
1832
3816
  `pivot_${col}`,
1833
- adapter?.consume ? adapter.consume(rawVal) : rawVal,
3817
+ // Adonis Lucid signature: (value, attribute, model).
3818
+ adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal,
1834
3819
  );
1835
3820
  }
1836
3821
  allRelated.push(related);
@@ -1860,6 +3845,10 @@ export class ModelQuery<T extends BaseEntity> {
1860
3845
  this.#db,
1861
3846
  (r) => ctx.hydrate(r),
1862
3847
  ctx.relatedClass,
3848
+ buildColumnResolver(ctx.relatedClass),
3849
+ hasSoftDeletes(ctx.relatedClass),
3850
+ this.#dialect,
3851
+ buildValuePreparer(ctx.relatedClass),
1863
3852
  );
1864
3853
  ctx.nestedCallback(sub);
1865
3854
  if (sub.#preloads.size > 0) {
@@ -1876,17 +3865,28 @@ export class ModelQuery<T extends BaseEntity> {
1876
3865
  column: string;
1877
3866
  operator: string;
1878
3867
  value: unknown;
3868
+ type?: "and" | "or";
1879
3869
  }> = [],
1880
3870
  ): Promise<Record<string, unknown>[]> {
1881
3871
  const wheres: Array<Record<string, unknown>> = [
1882
3872
  { column, operator: "IN", value: values, type: "and" },
1883
3873
  ];
1884
- for (const w of extraWheres) {
3874
+ // The caller's filters go in a parenthesised group, never flat beside the
3875
+ // `IN`. Flat, an `orWherePivot` would read as
3876
+ // `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
3877
+ // other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
3878
+ // With every filter ANDed the two forms are equivalent, so this changes
3879
+ // no existing query.
3880
+ if (extraWheres.length > 0) {
1885
3881
  wheres.push({
1886
- column: w.column,
1887
- operator: w.operator,
1888
- value: w.value,
3882
+ kind: "group",
1889
3883
  type: "and",
3884
+ conditions: extraWheres.map((w) => ({
3885
+ column: w.column,
3886
+ operator: w.operator,
3887
+ value: w.value,
3888
+ type: w.type ?? "and",
3889
+ })),
1890
3890
  });
1891
3891
  }
1892
3892
  const spec = {
@@ -1937,7 +3937,10 @@ export class ModelQuery<T extends BaseEntity> {
1937
3937
  this.#db,
1938
3938
  (row) => row as BaseEntity,
1939
3939
  relatedClass,
1940
- (c) => c,
3940
+ // Resolve columns + prepare values against the RELATED model so a preload
3941
+ // constraint (onQuery / callback) targeting a columnName-mapped or date
3942
+ // column compiles/binds like a direct query on that model.
3943
+ buildColumnResolver(relatedClass),
1941
3944
  // Propagate the RELATED entity's soft-delete flag — hardcoding
1942
3945
  // false here meant `preload('posts')` returned soft-deleted
1943
3946
  // posts even when Post is @SoftDeletes (a data leak). The
@@ -1947,14 +3950,63 @@ export class ModelQuery<T extends BaseEntity> {
1947
3950
  // preload callback.)
1948
3951
  hasSoftDeletes(relatedClass),
1949
3952
  this.#dialect,
3953
+ buildValuePreparer(relatedClass),
1950
3954
  );
1951
3955
  sub.whereIn(column, values);
1952
3956
  if (relation.onQuery) relation.onQuery(sub as unknown);
1953
3957
  if (userCallback) userCallback(sub);
1954
- 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();
1955
3963
  return this.#db.query<Record<string, unknown>>(sql, params);
1956
3964
  }
1957
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
+
1958
4010
  /**
1959
4011
  * Build a correlated subquery over a relation. Returns `SubqueryProjection`
1960
4012
  * used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
@@ -1995,30 +4047,46 @@ export class ModelQuery<T extends BaseEntity> {
1995
4047
  );
1996
4048
  }
1997
4049
  const relatedClass = relation.target() as new () => BaseEntity;
1998
- const relatedMeta = getEntityMetadata(relatedClass);
1999
- if (!relatedMeta) {
2000
- throw new Error(
2001
- `Entity metadata missing on related class ${relatedClass.name}`,
2002
- );
2003
- }
4050
+ const relatedMeta = ensureEntityMetadata(relatedClass);
2004
4051
  const relatedTable = relatedMeta.tableName;
2005
4052
  const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
2006
4053
  const parentTable = this.#tableName;
2007
- const q =
2008
- this.#dialect === "mysql"
2009
- ? (name: string) => `\`${name}\``
2010
- : (name: string) => `"${name}"`;
4054
+ // Strict single-segment identifier quote. This builds a RAW correlated
4055
+ // subquery fragment (no bind params for identifiers), so every segment must
4056
+ // be validated a table/key from relation metadata carrying a quote/backtick
4057
+ // would otherwise emit invalid or injectable SQL. Same policy as
4058
+ // BaseRepository's lazy m2m path.
4059
+ const q = (name: string): string => {
4060
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
4061
+ throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
4062
+ }
4063
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
4064
+ };
4065
+ // Table identifiers may be schema-qualified (`schema.table`) — quote each
4066
+ // dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
4067
+ // `public.users_roles` gets wrapped as ONE identifier and silently targets a
4068
+ // table literally named with a dot. Each segment still passes the strict
4069
+ // guard above. Columns stay single-segment via `q`.
4070
+ const qTable = (name: string): string => name.split(".").map(q).join(".");
2011
4071
 
2012
4072
  const sub = new ModelQuery<BaseEntity>(
2013
4073
  relatedTable,
2014
4074
  this.#db,
2015
4075
  (row) => row as BaseEntity,
2016
4076
  relatedClass,
2017
- (c) => c,
4077
+ // whereHas/withCount constraints run against the RELATED model — resolve
4078
+ // its columns (columnName/multi-word) and prepare its values like a direct query.
4079
+ buildColumnResolver(relatedClass),
2018
4080
  false,
2019
4081
  this.#dialect,
4082
+ buildValuePreparer(relatedClass),
2020
4083
  );
2021
4084
 
4085
+ // `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
4086
+ // PK); resolve each to its DB column via the owning model so a multi-word or
4087
+ // `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
4088
+ // `firstKey`/`secondKey` are DB column names already — left as-is.
4089
+ const resolveParent = buildColumnResolver(this.#entityClass);
2022
4090
  switch (relation.type) {
2023
4091
  case "hasOne":
2024
4092
  case "hasMany": {
@@ -2026,19 +4094,20 @@ export class ModelQuery<T extends BaseEntity> {
2026
4094
  // hard-coding them here produced silently-wrong whereHas/withCount SQL.
2027
4095
  const fk =
2028
4096
  relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2029
- const localKey = relation.localKey ?? parentPk;
4097
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2030
4098
  sub.#pushWhereRaw(
2031
- `${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`,
4099
+ `${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
2032
4100
  );
2033
4101
  break;
2034
4102
  }
2035
4103
  case "belongsTo": {
2036
4104
  const fk =
2037
4105
  relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
2038
- const ownerKey =
2039
- relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
4106
+ const ownerKey = buildColumnResolver(relatedClass)(
4107
+ relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
4108
+ );
2040
4109
  sub.#pushWhereRaw(
2041
- `${q(relatedTable)}.${q(ownerKey)} = ${q(parentTable)}.${q(fk)}`,
4110
+ `${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`,
2042
4111
  );
2043
4112
  break;
2044
4113
  }
@@ -2055,12 +4124,16 @@ export class ModelQuery<T extends BaseEntity> {
2055
4124
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2056
4125
  const otherKey =
2057
4126
  pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
2058
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
2059
- const localKey = relation.localKey ?? parentPk;
4127
+ const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
4128
+ const relatedPk =
4129
+ getColumnMetadata(relatedClass).find(
4130
+ (c) => c.propertyKey === relatedPkProp,
4131
+ )?.columnName ?? camelToSnake(relatedPkProp);
4132
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2060
4133
  sub.#pushWhereRaw(
2061
- `${q(relatedTable)}.${q(relatedPk)} IN ` +
2062
- `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
2063
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`,
4134
+ `${qTable(relatedTable)}.${q(relatedPk)} IN ` +
4135
+ `(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
4136
+ `WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`,
2064
4137
  );
2065
4138
  break;
2066
4139
  }
@@ -2075,24 +4148,21 @@ export class ModelQuery<T extends BaseEntity> {
2075
4148
  );
2076
4149
  }
2077
4150
  const throughClass = relation.through() as new () => BaseEntity;
2078
- const throughMeta = getEntityMetadata(throughClass);
2079
- if (!throughMeta) {
2080
- throw new Error(
2081
- `Entity metadata missing on through class ${throughClass.name}`,
2082
- );
2083
- }
4151
+ const throughMeta = ensureEntityMetadata(throughClass);
2084
4152
  const throughTable = throughMeta.tableName;
2085
4153
  const throughPk = getPrimaryKey(throughClass) ?? "id";
2086
- const parentLocal = relation.localKey ?? parentPk;
4154
+ const parentLocal = resolveParent(relation.localKey ?? parentPk);
2087
4155
  const firstKey =
2088
4156
  relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2089
4157
  const secondKey =
2090
4158
  relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
2091
- const secondLocal = relation.secondLocalKey ?? throughPk;
4159
+ const secondLocal = buildColumnResolver(throughClass)(
4160
+ relation.secondLocalKey ?? throughPk,
4161
+ );
2092
4162
  sub.#pushWhereRaw(
2093
- `${q(relatedTable)}.${q(secondKey)} IN ` +
2094
- `(SELECT ${q(secondLocal)} FROM ${q(throughTable)} ` +
2095
- `WHERE ${q(throughTable)}.${q(firstKey)} = ${q(parentTable)}.${q(parentLocal)})`,
4163
+ `${qTable(relatedTable)}.${q(secondKey)} IN ` +
4164
+ `(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
4165
+ `WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`,
2096
4166
  );
2097
4167
  break;
2098
4168
  }
@@ -2114,40 +4184,119 @@ export class ModelQuery<T extends BaseEntity> {
2114
4184
  */
2115
4185
  // === Story 29.4 — joins ===========================================================================
2116
4186
 
2117
- /** `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. */
2118
4201
  innerJoin(table: string, left: string, right: string): this;
4202
+ innerJoin(table: string, left: string, operator: string, right: string): this;
2119
4203
  innerJoin(table: string, build: (j: JoinBuilder) => void): this;
2120
4204
  innerJoin(
2121
4205
  table: string,
2122
4206
  leftOrBuild: string | ((j: JoinBuilder) => void),
4207
+ operatorOrRight?: string,
2123
4208
  right?: string,
2124
4209
  ): this {
2125
- return this.#pushJoin("INNER", table, leftOrBuild, right);
4210
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
2126
4211
  }
2127
4212
 
2128
4213
  leftJoin(table: string, left: string, right: string): this;
4214
+ leftJoin(table: string, left: string, operator: string, right: string): this;
2129
4215
  leftJoin(table: string, build: (j: JoinBuilder) => void): this;
2130
4216
  leftJoin(
2131
4217
  table: string,
2132
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,
2133
4238
  right?: string,
2134
4239
  ): this {
2135
- return this.#pushJoin("LEFT", table, leftOrBuild, right);
4240
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
2136
4241
  }
2137
4242
 
2138
4243
  rightJoin(table: string, left: string, right: string): this;
4244
+ rightJoin(table: string, left: string, operator: string, right: string): this;
2139
4245
  rightJoin(table: string, build: (j: JoinBuilder) => void): this;
2140
4246
  rightJoin(
2141
4247
  table: string,
2142
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,
4268
+ right?: string,
4269
+ ): this {
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,
2143
4286
  right?: string,
2144
4287
  ): this {
2145
- return this.#pushJoin("RIGHT", table, leftOrBuild, right);
4288
+ return this.#pushJoin(
4289
+ "FULL OUTER",
4290
+ table,
4291
+ leftOrBuild,
4292
+ operatorOrRight,
4293
+ right,
4294
+ );
2146
4295
  }
2147
4296
 
2148
4297
  crossJoin(table: string): this {
2149
- const tq = this.#quote(table);
2150
- this.#joins.push(`CROSS JOIN ${tq}`);
4298
+ const tq = this.#quoteCol(table);
4299
+ this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
2151
4300
  return this;
2152
4301
  }
2153
4302
 
@@ -2167,14 +4316,14 @@ export class ModelQuery<T extends BaseEntity> {
2167
4316
  *
2168
4317
  * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
2169
4318
  */
2170
- joinRaw(fragment: string): this {
4319
+ joinRaw(fragment: string, bindings: readonly unknown[] = []): this {
2171
4320
  if (isAtlasStrictMode() && !isInternalBypass()) {
2172
4321
  throw new Error(
2173
4322
  "joinRaw() is disabled in Atlas strict mode. " +
2174
4323
  "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
2175
4324
  );
2176
4325
  }
2177
- this.#joins.push(fragment);
4326
+ this.#joins.push({ sql: fragment, params: [...bindings] });
2178
4327
  return this;
2179
4328
  }
2180
4329
 
@@ -2195,25 +4344,72 @@ export class ModelQuery<T extends BaseEntity> {
2195
4344
 
2196
4345
  // === Story 29.5 — aggregates / exists / pluck =====================================================
2197
4346
 
2198
- 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 {
2199
4352
  this.#distinct = true;
4353
+ if (columns.length > 0) {
4354
+ this.#select = columns.map((c) => this.#resolveSelect(c));
4355
+ }
4356
+ return this;
4357
+ }
4358
+
4359
+ /**
4360
+ * `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
4361
+ * `columns` (Lucid/Knex `distinctOn`). Takes precedence over
4362
+ * {@link distinct}.
4363
+ *
4364
+ * Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
4365
+ * would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
4366
+ * return a *different* result set rather than fail — a silent wrong answer
4367
+ * is worse than an error.
4368
+ *
4369
+ * Postgres also requires the leading `ORDER BY` terms to match `columns`;
4370
+ * that is left to the database to enforce.
4371
+ */
4372
+ distinctOn(...columns: string[]): this {
4373
+ for (const c of columns) this.#distinctOn.push(this.#resolveColumn(c));
2200
4374
  return this;
2201
4375
  }
2202
4376
 
2203
4377
  /** `SELECT COUNT(DISTINCT col)`. */
2204
4378
  async countDistinct(column: string): Promise<number> {
2205
4379
  return Number(
2206
- (await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0,
4380
+ (await this.#runScalar(
4381
+ `COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
4382
+ )) ?? 0,
2207
4383
  );
2208
4384
  }
2209
4385
 
4386
+ /** `SUM(DISTINCT col)` (Lucid parity). */
4387
+ async sumDistinct(column: string): Promise<number | null> {
4388
+ const v = await this.#runScalar(
4389
+ `SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
4390
+ );
4391
+ return v === null || v === undefined ? null : Number(v);
4392
+ }
4393
+
4394
+ /** `AVG(DISTINCT col)` (Lucid parity). */
4395
+ async avgDistinct(column: string): Promise<number | null> {
4396
+ const v = await this.#runScalar(
4397
+ `AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
4398
+ );
4399
+ return v === null || v === undefined ? null : Number(v);
4400
+ }
4401
+
2210
4402
  /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
2211
4403
  async exists(): Promise<boolean> {
2212
4404
  const clone = this.clone();
2213
4405
  clone.#select = ["1"];
2214
4406
  clone.#limit = 1;
2215
- const { sql, params } = clone.toSQL();
2216
- const rows = await this.#db.query<Record<string, unknown>>(sql, params);
4407
+ const { sql, params } = clone.#compiledNative();
4408
+ const rows = await this.#db.query<Record<string, unknown>>(
4409
+ sql,
4410
+ params,
4411
+ this.#meta("exists"),
4412
+ );
2217
4413
  return rows.length > 0;
2218
4414
  }
2219
4415
 
@@ -2226,7 +4422,7 @@ export class ModelQuery<T extends BaseEntity> {
2226
4422
  const col = this.#resolveColumn(column);
2227
4423
  const clone = this.clone();
2228
4424
  clone.#select = [col];
2229
- const { sql, params } = clone.toSQL();
4425
+ const { sql, params } = clone.#compiledNative();
2230
4426
  const rows = await this.#db.query<Record<string, unknown>>(sql, params);
2231
4427
  return rows.map((row) => {
2232
4428
  const v = row[col];
@@ -2297,8 +4493,10 @@ export class ModelQuery<T extends BaseEntity> {
2297
4493
  unless<V>(
2298
4494
  condition: V | undefined | null | false,
2299
4495
  fn: (q: this) => void,
4496
+ elseFn?: (q: this) => void,
2300
4497
  ): this {
2301
4498
  if (!condition) fn(this);
4499
+ else if (elseFn) elseFn(this);
2302
4500
  return this;
2303
4501
  }
2304
4502
 
@@ -2308,27 +4506,61 @@ export class ModelQuery<T extends BaseEntity> {
2308
4506
  async paginate(page: number, perPage: number): Promise<Paginator<T>> {
2309
4507
  const p = Math.max(1, Math.floor(page));
2310
4508
  const pp = Math.max(1, Math.floor(perPage));
2311
- // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
2312
- // tenant scope) propagates into both the COUNT and the data fetch.
2313
- await fireHooks(this.#entityClass, "beforePaginate", this);
2314
- // Parallel 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.
2315
4514
  const countQ = this.clone();
2316
- countQ.#select = ["COUNT(*) AS count"];
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.
2317
4520
  countQ.#limit = undefined;
2318
4521
  countQ.#offset = undefined;
2319
4522
  countQ.#orderBys = [];
2320
- const { sql: cSql, params: cParams } = countQ.toSQL();
2321
- const cRows = await this.#db.query<Record<string, unknown>>(cSql, cParams);
4523
+ let cSql: string;
4524
+ let cParams: unknown[];
4525
+ if (countQ.#groupBy.length > 0) {
4526
+ // A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
4527
+ // group's own size), so `rows[0].count` would be the first group's size, not
4528
+ // the number of pages. Lucid counts via a subquery: wrap the grouped query
4529
+ // (select + groupBy + having preserved) and count its rows = group count.
4530
+ const inner = countQ.#compiledNative();
4531
+ cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
4532
+ cParams = inner.params;
4533
+ } else {
4534
+ countQ.#select = ["COUNT(*) AS count"];
4535
+ const flat = countQ.#compiledNative();
4536
+ cSql = flat.sql;
4537
+ cParams = flat.params;
4538
+ }
4539
+ const cRows = await this.#db.query<Record<string, unknown>>(
4540
+ cSql,
4541
+ cParams,
4542
+ this.#meta("paginate"),
4543
+ );
2322
4544
  const total = Number(cRows[0]?.count ?? 0);
2323
4545
 
2324
- const dataQ = this.clone();
2325
4546
  dataQ.#limit = pp;
2326
4547
  dataQ.#offset = (p - 1) * pp;
2327
- // `#doExec` (not `exec`) so the generic beforeFetch/afterFetch don't fire on
2328
- // 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().
2329
4551
  const items = await dataQ.#doExec();
2330
- await fireHooks(this.#entityClass, "afterPaginate", items);
2331
- return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
4552
+ const metaKeys = this.#entityClass
4553
+ ? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
4554
+ : undefined;
4555
+ const paginator = new Paginator<T>(
4556
+ items,
4557
+ { total, perPage: pp, currentPage: p },
4558
+ metaKeys,
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;
2332
4564
  }
2333
4565
 
2334
4566
  /**
@@ -2349,9 +4581,13 @@ export class ModelQuery<T extends BaseEntity> {
2349
4581
  limit: number;
2350
4582
  orderBy: string | string[];
2351
4583
  }): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
2352
- const cols = (
2353
- Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]
2354
- ).map((c) => this.#resolveColumn(c));
4584
+ // Keep BOTH forms: `props` (model property names) to read the cursor value
4585
+ // off the hydrated entity, and `cols` (resolved DB columns) for the SQL
4586
+ // ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
4587
+ // encode `undefined` into the cursor (entity exposes the property, not the
4588
+ // DB column) — an unstable / stuck cursor.
4589
+ const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
4590
+ const cols = props.map((c) => this.#resolveColumn(c));
2355
4591
  if (cols.length === 0)
2356
4592
  throw new Error("cursorPaginate requires at least one orderBy column");
2357
4593
  const lim = Math.max(1, Math.floor(opts.limit));
@@ -2400,9 +4636,9 @@ export class ModelQuery<T extends BaseEntity> {
2400
4636
  const last = items[items.length - 1] as Record<string, unknown> | undefined;
2401
4637
  const nextCursor =
2402
4638
  hasMore && last
2403
- ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString(
2404
- "base64",
2405
- )
4639
+ ? Buffer.from(
4640
+ JSON.stringify({ v: props.map((p) => last[p]) }),
4641
+ ).toString("base64")
2406
4642
  : null;
2407
4643
  return { items, nextCursor, hasMore };
2408
4644
  }
@@ -2423,14 +4659,43 @@ export class ModelQuery<T extends BaseEntity> {
2423
4659
  return this;
2424
4660
  }
2425
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
+
4674
+ /**
4675
+ * Context attached to each statement this query runs, so a `db:query`
4676
+ * listener can say which model and which call produced it — and so
4677
+ * {@link debug} can force emission for this query alone.
4678
+ *
4679
+ * Note the connection's own `debug: true` emits every statement regardless;
4680
+ * `meta` only enriches the event and opens the per-query override.
4681
+ */
4682
+ #meta(method: string): QueryMeta {
4683
+ return {
4684
+ model: this.#entityClass.name,
4685
+ method,
4686
+ debug: this.#debugFlag,
4687
+ reporterData: this.#reporterData,
4688
+ serverTimeoutMs:
4689
+ this.#cancelTimeout && this.#timeoutMs != null
4690
+ ? this.#timeoutMs
4691
+ : undefined,
4692
+ };
4693
+ }
4694
+
2426
4695
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
2427
4696
  toQuery(): string {
2428
- const { sql, params } = this.toSQL();
2429
- let i = 0;
2430
- return sql.replace(/\?|\$\d+/g, () => {
2431
- const v = params[i++];
2432
- return this.#literalEscape(v);
2433
- });
4697
+ const { sql, params } = this.#compiledNative();
4698
+ return interpolateQuery(sql, params);
2434
4699
  }
2435
4700
 
2436
4701
  /** Deep clone of this query — mutations on the clone never affect the original. */
@@ -2443,85 +4708,191 @@ export class ModelQuery<T extends BaseEntity> {
2443
4708
  this.#resolveColumn,
2444
4709
  this.#softDeletes,
2445
4710
  this.#dialect,
4711
+ this.#prepareValue,
4712
+ this.#onDomainEvents,
2446
4713
  );
2447
4714
  c.#softScope = this.#softScope;
2448
4715
  c.#wheres = structuredCloneSafe(this.#wheres);
2449
4716
  c.#orderBys = [...this.#orderBys];
2450
4717
  c.#select = [...this.#select];
2451
4718
  c.#limit = this.#limit;
4719
+ c.#groupLimit = this.#groupLimit;
4720
+ c.#groupOrderBy = this.#groupOrderBy
4721
+ ? { ...this.#groupOrderBy }
4722
+ : undefined;
2452
4723
  c.#offset = this.#offset;
2453
4724
  c.#preloads = new Map(this.#preloads);
4725
+ c.#rowTransformers = [...this.#rowTransformers];
2454
4726
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
2455
- c.#joins = [...this.#joins];
4727
+ c.#selectRaw = this.#selectRaw.map((s) => ({
4728
+ ...s,
4729
+ params: [...s.params],
4730
+ }));
4731
+ c.#timeoutMs = this.#timeoutMs;
4732
+ c.#cancelTimeout = this.#cancelTimeout;
4733
+ c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
2456
4734
  c.#lockMode = this.#lockMode;
4735
+ c.#lockModifier = this.#lockModifier;
4736
+ c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
2457
4737
  c.#distinct = this.#distinct;
4738
+ c.#distinctOn = [...this.#distinctOn];
2458
4739
  c.#groupBy = [...this.#groupBy];
2459
4740
  c.#having = structuredCloneSafe(this.#having);
2460
- c.#ctes = this.#ctes.map((e) => ({ name: e.name, query: e.query.clone() }));
4741
+ c.#ctes = this.#ctes.map((e) => ({
4742
+ name: e.name,
4743
+ query: e.query.clone(),
4744
+ recursive: e.recursive,
4745
+ materialized: e.materialized,
4746
+ columns: e.columns ? [...e.columns] : undefined,
4747
+ }));
2461
4748
  c.#unions = this.#unions.map((u) => ({
2462
4749
  query: u.query.clone(),
2463
4750
  all: u.all,
4751
+ op: u.op,
2464
4752
  }));
2465
4753
  c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
4754
+ c.#extraPivotColumns = [...this.#extraPivotColumns];
4755
+ // Pure closure over pivot metadata — safe to share by reference; it reads the
4756
+ // clone's own #pivotWheres at build time (passed in), holding no query state.
4757
+ c.#pivotExists = this.#pivotExists;
2466
4758
  c.#debugFlag = this.#debugFlag;
4759
+ c.#reporterData = this.#reporterData
4760
+ ? { ...this.#reporterData }
4761
+ : undefined;
4762
+ c.#comments = [...this.#comments];
2467
4763
  return c;
2468
4764
  }
2469
4765
 
2470
4766
  // === Story 30.2 — update / delete fluent ===========================================================
2471
4767
 
2472
- /** Execute a fluent UPDATE. Returns affected rows (or rows when `returning` is set). */
2473
- 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(
2474
4778
  patch: Record<string, unknown>,
2475
4779
  returning?: string[],
2476
- ): 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);
2477
4793
  if (!patch || Object.keys(patch).length === 0) {
2478
4794
  throw new Error("update() requires a non-empty payload");
2479
4795
  }
2480
- const setPairs = Object.entries(patch).map(
2481
- ([k, v]) => [this.#resolveColumn(k), v] as [string, unknown],
4796
+ // Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
4797
+ // like BaseRepository's write paths — the fluent update() must not bypass it.
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)],
2482
4803
  );
2483
- const spec = {
2484
- kind: "update",
2485
- table: this.#tableName,
2486
- set: setPairs,
2487
- wheres: this.#wheresForDml(),
2488
- returning: returning ?? [],
2489
- };
2490
- const compiled = compileStatementNative(spec, this.#dialect);
2491
- if (returning && returning.length > 0) {
2492
- return this.#db.query<Record<string, unknown>>(
2493
- compiled.statements[0],
2494
- compiled.params,
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,
4814
+ );
4815
+ }
4816
+
4817
+ /**
4818
+ * Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
4819
+ * scoped rows (stamps `deleted_at`) — consistent with the entity-level
4820
+ * `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
4821
+ * model it issues a hard `DELETE`. Returns affected rows (or rows when
4822
+ * `returning` is set).
4823
+ */
4824
+ delete(returning?: string[]): DmlBuilder<number | Record<string, unknown>[]> {
4825
+ if (this.#softDeletes) {
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,
2495
4837
  );
2496
4838
  }
2497
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
2498
- return r.rowsAffected ?? 0;
4839
+ return this.forceDelete(returning);
2499
4840
  }
2500
4841
 
2501
- /** Execute a fluent DELETE. Returns affected rows (or rows when `returning` is set). */
2502
- async delete(
4842
+ /** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
4843
+ forceDelete(
2503
4844
  returning?: string[],
2504
- ): Promise<number | Record<string, unknown>[]> {
2505
- const spec = {
2506
- kind: "delete",
2507
- table: this.#tableName,
2508
- wheres: this.#wheresForDml(),
2509
- returning: returning ?? [],
2510
- };
2511
- const compiled = compileStatementNative(spec, this.#dialect);
2512
- if (returning && returning.length > 0) {
2513
- return this.#db.query<Record<string, unknown>>(
2514
- compiled.statements[0],
2515
- compiled.params,
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
+ );
4856
+ }
4857
+
4858
+ /**
4859
+ * Bulk restore: clear `deleted_at` on the trashed rows matching the user's
4860
+ * predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
4861
+ * on a non-soft-delete model. Independent of the current soft-scope — it always
4862
+ * targets trashed rows (`deleted_at IS NOT NULL`).
4863
+ */
4864
+ restore(
4865
+ returning?: string[],
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(),
2516
4872
  );
2517
4873
  }
2518
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
2519
- return r.rowsAffected ?? 0;
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);
2520
4891
  }
2521
4892
 
2522
4893
  // === Story 30.3 — increment / decrement already implemented? check ================================
2523
4894
 
2524
- increment(column: string, amount: number): Promise<number>;
4895
+ increment(column: string, amount?: number): Promise<number>;
2525
4896
  increment(patch: Record<string, number>): Promise<number>;
2526
4897
  increment(
2527
4898
  colOrPatch: string | Record<string, number>,
@@ -2530,7 +4901,7 @@ export class ModelQuery<T extends BaseEntity> {
2530
4901
  return this.#runIncDec("increment", colOrPatch, amount);
2531
4902
  }
2532
4903
 
2533
- decrement(column: string, amount: number): Promise<number>;
4904
+ decrement(column: string, amount?: number): Promise<number>;
2534
4905
  decrement(patch: Record<string, number>): Promise<number>;
2535
4906
  decrement(
2536
4907
  colOrPatch: string | Record<string, number>,
@@ -2563,6 +4934,56 @@ export class ModelQuery<T extends BaseEntity> {
2563
4934
  return this;
2564
4935
  }
2565
4936
 
4937
+ /** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
4938
+ forNoKeyUpdate(): this {
4939
+ if (this.#dialect === "postgres") {
4940
+ this.#lockMode = "FOR NO KEY UPDATE";
4941
+ } else {
4942
+ console.warn(
4943
+ `[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`,
4944
+ );
4945
+ }
4946
+ return this;
4947
+ }
4948
+
4949
+ /** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
4950
+ forKeyShare(): this {
4951
+ if (this.#dialect === "postgres") {
4952
+ this.#lockMode = "FOR KEY SHARE";
4953
+ } else {
4954
+ console.warn(
4955
+ `[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`,
4956
+ );
4957
+ }
4958
+ return this;
4959
+ }
4960
+
4961
+ /**
4962
+ * Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
4963
+ * waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
4964
+ */
4965
+ skipLocked(): this {
4966
+ if (this.#dialect === "sqlite") {
4967
+ console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
4968
+ } else {
4969
+ this.#lockModifier = "SKIP LOCKED";
4970
+ }
4971
+ return this;
4972
+ }
4973
+
4974
+ /**
4975
+ * Append `NOWAIT` to the lock clause — error immediately instead of waiting on
4976
+ * a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
4977
+ */
4978
+ noWait(): this {
4979
+ if (this.#dialect === "sqlite") {
4980
+ console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
4981
+ } else {
4982
+ this.#lockModifier = "NOWAIT";
4983
+ }
4984
+ return this;
4985
+ }
4986
+
2566
4987
  // === Private helpers ==============================================================================
2567
4988
 
2568
4989
  #quote(name: string): string {
@@ -2571,62 +4992,207 @@ export class ModelQuery<T extends BaseEntity> {
2571
4992
 
2572
4993
  /** Quote a `table.column` reference on both sides of the dot. */
2573
4994
  #quoteCol(ref: string): string {
2574
- if (ref.includes(".")) {
2575
- const [t, c] = ref.split(".", 2);
2576
- return `${this.#quote(t)}.${this.#quote(c)}`;
4995
+ // Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
4996
+ // identifier smuggling a `"`/backtick would break out of the quoting on the
4997
+ // join path (which the Rust screen doesn't re-validate). Strict
4998
+ // `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
4999
+ // helpers injection-safe. Use joinRaw() for anything more complex.
5000
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
5001
+ throw new Error(
5002
+ `Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`,
5003
+ );
2577
5004
  }
2578
- return this.#quote(ref);
5005
+ return ref
5006
+ .split(".")
5007
+ .map((seg) => this.#quote(seg))
5008
+ .join(".");
2579
5009
  }
2580
5010
 
2581
5011
  #pushJoin(
2582
- kind: "INNER" | "LEFT" | "RIGHT",
5012
+ kind: "INNER" | "LEFT" | "RIGHT" | "FULL OUTER",
2583
5013
  table: string,
2584
5014
  leftOrBuild: string | ((j: JoinBuilder) => void),
5015
+ operatorOrRight?: string,
2585
5016
  right?: string,
2586
5017
  ): this {
2587
- const tq = this.#quote(table);
5018
+ const tq = this.#quoteCol(table);
2588
5019
  if (typeof leftOrBuild === "function") {
5020
+ const parts: JoinPartMQ[] = [];
2589
5021
  const jb: JoinBuilder = {
2590
- parts: [],
2591
- on(l: string, r: string) {
2592
- this.parts.push({ kind: "and", left: l, right: r });
2593
- 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;
5030
+ },
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;
5050
+ },
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;
5071
+ },
5072
+ onNull: (l) => {
5073
+ parts.push({ kind: "and", left: l, nullOp: "IS NULL" });
5074
+ return jb;
5075
+ },
5076
+ onNotNull: (l) => {
5077
+ parts.push({ kind: "and", left: l, nullOp: "IS NOT NULL" });
5078
+ return jb;
5079
+ },
5080
+ onBetween: (l, range) => {
5081
+ parts.push({ kind: "and", left: l, between: [range[0], range[1]] });
5082
+ return jb;
2594
5083
  },
2595
- andOn(l: string, r: string) {
2596
- this.parts.push({ kind: "and", left: l, right: r });
2597
- return this;
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;
2598
5097
  },
2599
- andOnVal(l: string, _v: unknown) {
2600
- this.parts.push({ kind: "and", left: l, right: "?" });
2601
- return this;
5098
+ onNotExists: (sub) => {
5099
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
5100
+ parts.push({ kind: "and", exists: { sql, params, not: true } });
5101
+ return jb;
2602
5102
  },
2603
5103
  };
2604
5104
  leftOrBuild(jb);
2605
- const on = jb.parts
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
+ };
5118
+ const params: unknown[] = [];
5119
+ const on = parts
2606
5120
  .map((p, i) => {
2607
5121
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
2608
- return `${prefix} ${this.#quoteCol(p.left)} = ${p.right === "?" ? "?" : this.#quoteCol(p.right)}`;
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) {
5131
+ params.push(
5132
+ prep(p.left ?? "", p.between[0]),
5133
+ prep(p.left ?? "", p.between[1]),
5134
+ );
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 ?? "=")} ?`;
5145
+ }
5146
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ${this.#quoteCol(p.right ?? "")}`;
2609
5147
  })
2610
5148
  .join(" ");
2611
- this.#joins.push(`${kind} JOIN ${tq} ${on}`);
5149
+ this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
2612
5150
  return this;
2613
5151
  }
2614
- 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)
2615
5157
  throw new Error(
2616
5158
  "join() with string form requires both left and right operands",
2617
5159
  );
2618
- this.#joins.push(
2619
- `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
2620
- );
5160
+ this.#joins.push({
5161
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(left)} ${this.#validateJoinOp(operator)} ${this.#quoteCol(rightCol)}`,
5162
+ params: [],
5163
+ });
2621
5164
  return this;
2622
5165
  }
2623
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
+
2624
5188
  async #runScalar(expr: string): Promise<unknown> {
2625
5189
  const clone = this.clone();
2626
5190
  clone.#select = [`${expr} AS __scalar__`];
2627
5191
  clone.#orderBys = [];
2628
- const { sql, params } = clone.toSQL();
2629
- 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
+ );
2630
5196
  const row = rows[0];
2631
5197
  return row ? row.__scalar__ : null;
2632
5198
  }
@@ -2651,46 +5217,140 @@ export class ModelQuery<T extends BaseEntity> {
2651
5217
  set: setPairs,
2652
5218
  wheres: this.#wheresForDml(),
2653
5219
  returning: [],
5220
+ ctes: this.#compiledCtes(),
2654
5221
  };
2655
5222
  const compiled = compileStatementNative(spec, this.#dialect);
2656
- 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
+ );
2657
5230
  return r.rowsAffected ?? 0;
2658
5231
  }
2659
5232
 
2660
5233
  /**
2661
- * Flatten the SELECT wheres to DML-compatible wheres. Standard predicates
2662
- * and `whereRaw` fragments pass through; `group` / `exists` / `inSub` are
2663
- * still rejected because the DML compiler's WHERE lowering does not yet
2664
- * 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).
2665
5239
  */
2666
- #wheresForDml(): Array<Record<string, unknown>> {
2667
- const out: Array<Record<string, unknown>> = [];
2668
- for (const w of this.#wheres) {
2669
- if ("kind" in w) {
2670
- if (w.kind === "raw") {
2671
- out.push({
2672
- kind: "raw",
2673
- sql: w.sql,
2674
- bindings: w.bindings,
2675
- type: w.type,
2676
- });
2677
- continue;
2678
- }
2679
- throw new Error(
2680
- `update/delete do not support '${w.kind}' WHERE clauses. ` +
2681
- `Supported: plain predicates and whereRaw. Use a raw UPDATE/DELETE for complex criteria.`,
2682
- );
5240
+ #userWheresForDml(): WhereClause[] {
5241
+ return [...this.#wheres];
5242
+ }
5243
+
5244
+ #wheresForDml(): WhereClause[] {
5245
+ const out = this.#userWheresForDml();
5246
+ // Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
5247
+ // update/delete/increment/decrement must NOT touch trashed rows under the
5248
+ // default scope — otherwise `query().where(x)` would denote a different row
5249
+ // set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
5250
+ // `.onlyTrashed()` restricts to trashed (mirrors reads).
5251
+ if (this.#softDeletes) {
5252
+ const deletedAtCol = this.#deletedAtColumn();
5253
+ if (this.#softScope === "default") {
5254
+ out.push({
5255
+ column: deletedAtCol,
5256
+ operator: "IS NULL",
5257
+ value: null,
5258
+ type: "and",
5259
+ });
5260
+ } else if (this.#softScope === "only-trashed") {
5261
+ out.push({
5262
+ column: deletedAtCol,
5263
+ operator: "IS NOT NULL",
5264
+ value: null,
5265
+ type: "and",
5266
+ });
2683
5267
  }
2684
- out.push({
2685
- column: w.column,
2686
- operator: w.operator,
2687
- value: w.value,
2688
- type: w.type,
2689
- });
2690
5268
  }
2691
5269
  return out;
2692
5270
  }
2693
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
+
5329
+ /** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
5330
+ async #runDml(
5331
+ spec: Record<string, unknown>,
5332
+ returning?: string[],
5333
+ ): Promise<number | Record<string, unknown>[]> {
5334
+ const compiled = compileStatementNative(spec, this.#dialect);
5335
+ if (returning && returning.length > 0) {
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
+ ),
5342
+ );
5343
+ }
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
+ );
5351
+ return r.rowsAffected ?? 0;
5352
+ }
5353
+
2694
5354
  /**
2695
5355
  * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
2696
5356
  *
@@ -2704,15 +5364,6 @@ export class ModelQuery<T extends BaseEntity> {
2704
5364
  * parameters via the Rust compiler — this escaper is never on the hot path.
2705
5365
  * If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
2706
5366
  */
2707
- #literalEscape(v: unknown): string {
2708
- if (v === null || v === undefined) return "NULL";
2709
- if (typeof v === "number") return String(v);
2710
- if (typeof v === "boolean") return v ? "1" : "0";
2711
- if (v instanceof Date) return `'${v.toISOString()}'`;
2712
- // Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
2713
- return `'${String(v).replace(/'/g, "''")}'`;
2714
- }
2715
-
2716
5367
  /**
2717
5368
  * Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
2718
5369
  * on the SAME table is used as the scratch builder so the callback can call
@@ -2728,6 +5379,7 @@ export class ModelQuery<T extends BaseEntity> {
2728
5379
  this.#resolveColumn,
2729
5380
  false,
2730
5381
  this.#dialect,
5382
+ this.#prepareValue,
2731
5383
  );
2732
5384
  callback(scratch);
2733
5385
  return { type, kind: "group", conditions: scratch.#wheres };
@@ -2781,19 +5433,30 @@ export class ModelQuery<T extends BaseEntity> {
2781
5433
  type,
2782
5434
  column: resolved,
2783
5435
  operator: "=",
2784
- value: operatorOrValue,
5436
+ value: this.#prep(column, operatorOrValue),
2785
5437
  });
2786
5438
  } else {
2787
5439
  this.#wheres.push({
2788
5440
  type,
2789
5441
  column: resolved,
2790
5442
  operator: operatorOrValue as string,
2791
- value,
5443
+ value: this.#prep(column, value),
2792
5444
  });
2793
5445
  }
2794
5446
  return this;
2795
5447
  }
2796
5448
 
5449
+ /**
5450
+ * Lower a WHERE/search value (or each element of an array) to its DB form via
5451
+ * the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
5452
+ * adapter column used as a predicate binds the same shape the write path stores.
5453
+ */
5454
+ #prep(column: string, value: unknown): unknown {
5455
+ return Array.isArray(value)
5456
+ ? value.map((v) => this.#prepareValue(column, v))
5457
+ : this.#prepareValue(column, value);
5458
+ }
5459
+
2797
5460
  /**
2798
5461
  * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
2799
5462
  * Used by the nested-preload machinery to recurse without re-running the root select.