@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
@@ -7,9 +7,19 @@
7
7
  * Builds SQL fluently and executes against the database connection.
8
8
  */
9
9
  var _a;
10
- import { getColumnMetadata, getEntityMetadata, getPrimaryKey, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
10
+ import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
11
+ import { REPO_REF } from "./BaseEntity.js";
12
+ // Value import used only inside method bodies (preload hydration) — the
13
+ // BaseRepository ↔ ModelQuery cycle resolves at runtime, after both are defined.
14
+ import { assertNotPromise, BaseRepository, wrapAdapterError, } from "./BaseRepository.js";
15
+ import { ensureEntityMetadata, getColumnMetadata, getDateColumnConfig, getPrimaryKey, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
11
16
  import { fireHooks } from "./decorators/hooks.js";
12
- import { compileStatementNative, getAtlasDialect, } from "./query/native.js";
17
+ import { getNamingStrategy } from "./naming/NamingStrategy.js";
18
+ import { DmlBuilder } from "./query/DmlBuilder.js";
19
+ import { compiledStatement, interpolateQuery, } from "./query/interpolate.js";
20
+ import { compileStatementNative, getAtlasDialect, normalizeDialect, } from "./query/native.js";
21
+ import { negateOperator } from "./query/operators.js";
22
+ import { RawSql } from "./query/QueryBuilder.js";
13
23
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
14
24
  /**
15
25
  * Comparison operators allowed in `whereExpr`'s raw 4-arg form (where
@@ -28,6 +38,49 @@ const WHEREEXPR_OPERATORS = new Set([
28
38
  "LIKE",
29
39
  "NOT LIKE",
30
40
  ]);
41
+ /**
42
+ * SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
43
+ * They are just letters (pass the charset guard) but would let the fragment alter
44
+ * the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
45
+ * alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
46
+ */
47
+ const WHEREEXPR_FORBIDDEN_WORDS = new Set([
48
+ "OR",
49
+ "AND",
50
+ "NOT",
51
+ "IS",
52
+ "NULL",
53
+ "IN",
54
+ "LIKE",
55
+ "ILIKE",
56
+ "BETWEEN",
57
+ "EXISTS",
58
+ "ANY",
59
+ "ALL",
60
+ "SOME",
61
+ "CASE",
62
+ "WHEN",
63
+ "THEN",
64
+ "ELSE",
65
+ "END",
66
+ "SELECT",
67
+ "FROM",
68
+ "WHERE",
69
+ "JOIN",
70
+ "UNION",
71
+ "INTERSECT",
72
+ "EXCEPT",
73
+ "HAVING",
74
+ "GROUP",
75
+ "ORDER",
76
+ "BY",
77
+ "LIMIT",
78
+ "OFFSET",
79
+ "AS",
80
+ "DISTINCT",
81
+ "TRUE",
82
+ "FALSE",
83
+ ]);
31
84
  /** True when every `(` in `s` has a matching `)` and none closes early. */
32
85
  function hasBalancedParens(s) {
33
86
  let depth = 0;
@@ -42,6 +95,97 @@ function hasBalancedParens(s) {
42
95
  }
43
96
  return depth === 0;
44
97
  }
98
+ /**
99
+ * Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
100
+ * and the snake_case convention. Used to build correlated/preload subqueries on a
101
+ * RELATED model so their WHERE/join columns resolve like a direct query would.
102
+ */
103
+ function buildColumnResolver(entityClass) {
104
+ const map = new Map();
105
+ for (const col of getColumnMetadata(entityClass)) {
106
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
107
+ map.set(col.propertyKey, db);
108
+ map.set(db, db);
109
+ }
110
+ return (col) => map.get(col) ?? camelToSnake(col);
111
+ }
112
+ /**
113
+ * Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
114
+ * (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
115
+ * preload/whereHas constraint on a RELATED model prepares its values like a direct query.
116
+ */
117
+ function buildValuePreparer(entityClass) {
118
+ const prepares = new Map();
119
+ // Reverse map (db column → property) so a caller passing a DB name or an
120
+ // explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
121
+ // still routes through the property-keyed prepare/date maps — mirrors
122
+ // BaseRepository.#applyPrepare.
123
+ const byDbName = new Map();
124
+ for (const col of getColumnMetadata(entityClass)) {
125
+ if (col.prepare)
126
+ prepares.set(col.propertyKey, col.prepare);
127
+ byDbName.set(col.columnName ?? camelToSnake(col.propertyKey), col.propertyKey);
128
+ }
129
+ const dateCols = getDateColumnConfig(entityClass);
130
+ return (key, value) => {
131
+ const prop = byDbName.get(key) ?? key;
132
+ const p = prepares.get(prop);
133
+ // Query-builder value transform — no model instance, but the attribute is
134
+ // known (Adonis Lucid signature: value, attribute, model).
135
+ if (p)
136
+ return p(value, prop, undefined);
137
+ if (dateCols[prop] && value != null) {
138
+ if (value instanceof Date)
139
+ return value.toISOString();
140
+ return dateTimeAtlasAdapter.prepare(value);
141
+ }
142
+ return value;
143
+ };
144
+ }
145
+ /** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
146
+ function joinValueHasToISO(v) {
147
+ return (typeof v === "object" &&
148
+ v !== null &&
149
+ "toISO" in v &&
150
+ typeof v.toISO === "function");
151
+ }
152
+ /**
153
+ * Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
154
+ * `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
155
+ * column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
156
+ * borrow the root model's adapter for a same-named column on a different table
157
+ * (Knex binds join values model-agnostically; we add only safe universal
158
+ * serialization so a DateTime still lowers to ISO like `where()`).
159
+ */
160
+ function lowerJoinValue(value) {
161
+ if (value instanceof Date)
162
+ return value.toISOString();
163
+ if (joinValueHasToISO(value))
164
+ return value.toISO();
165
+ return value;
166
+ }
167
+ /**
168
+ * Does a join column's table reference (`ref`) denote the root model's own table
169
+ * (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
170
+ * model declares (default schema) — `orders` matches a `public.orders` model — but
171
+ * it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
172
+ * accepts `orders.col`, while an unqualified `orders` model rejects
173
+ * `archive.orders.col` (a different schema the model never named) — keeping it
174
+ * foreign so the root model's `@Column` adapters aren't misapplied to it.
175
+ */
176
+ function sameTableRef(ref, modelTable) {
177
+ const rs = ref.split(".");
178
+ const ms = modelTable.split(".");
179
+ // The reference cannot be MORE qualified than the model (it can only drop the
180
+ // schema, never assert a new one) — otherwise treat it as a foreign table.
181
+ if (rs.length > ms.length)
182
+ return false;
183
+ for (let i = 1; i <= rs.length; i++) {
184
+ if (rs[rs.length - i] !== ms[ms.length - i])
185
+ return false;
186
+ }
187
+ return true;
188
+ }
45
189
  /** Set an empty relation value on every parent and return no related rows. */
46
190
  function assignEmptyRelation(entities, relationName, single) {
47
191
  for (const e of entities)
@@ -68,10 +212,12 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
68
212
  return throughToParent;
69
213
  }
70
214
  /**
71
- * Process-wide strict mode flag. When enabled, `whereRaw()` and `joinRaw()`
72
- * throw unconditionally — forcing every call site to use the typed
73
- * `whereExpr()` / `joinOn()` / structured builder paths. Intended for prod
74
- * hardening on apps that can't audit every call site manually.
215
+ * Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
216
+ * `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
217
+ * call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
218
+ * builder paths. The connection-level `db.query()` / `db.execute()` stay available
219
+ * as the explicit, parameterised break-glass. Intended for prod hardening on apps
220
+ * that can't audit every call site manually.
75
221
  *
76
222
  * Enable via:
77
223
  * - `setAtlasStrictMode(true)` at app bootstrap
@@ -82,7 +228,7 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
82
228
  * `__internal: true` flag on the call — not exposed in the public types.
83
229
  */
84
230
  let atlasStrictMode;
85
- /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
231
+ /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
86
232
  export function setAtlasStrictMode(enabled) {
87
233
  atlasStrictMode = enabled;
88
234
  }
@@ -120,14 +266,38 @@ export class Paginator {
120
266
  meta;
121
267
  #baseUrl;
122
268
  #queryString = {};
123
- constructor(items, base) {
269
+ #metaKeys;
270
+ constructor(items, base, metaKeys) {
124
271
  this.items = items;
125
272
  const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
126
273
  this.meta = { ...base, lastPage, firstPage: 1 };
274
+ this.#metaKeys = metaKeys;
127
275
  }
128
276
  all() {
129
277
  return this.items;
130
278
  }
279
+ // Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
280
+ // carried in `.meta`, exposed directly on the instance for convenience.
281
+ /** Total row count across all pages. */
282
+ get total() {
283
+ return this.meta.total;
284
+ }
285
+ /** Rows per page. */
286
+ get perPage() {
287
+ return this.meta.perPage;
288
+ }
289
+ /** The current page number. */
290
+ get currentPage() {
291
+ return this.meta.currentPage;
292
+ }
293
+ /** The last page number. */
294
+ get lastPage() {
295
+ return this.meta.lastPage;
296
+ }
297
+ /** The first page number (always 1). */
298
+ get firstPage() {
299
+ return this.meta.firstPage;
300
+ }
131
301
  /** True when there is more than one page of results (AdonisJS `hasPages`). */
132
302
  get hasPages() {
133
303
  return this.meta.lastPage > 1;
@@ -145,7 +315,9 @@ export class Paginator {
145
315
  picked[f] = item[f];
146
316
  return picked;
147
317
  });
148
- return { data, meta: this.meta };
318
+ // Same meta shape as toJSON — snake_case keys via the naming strategy's
319
+ // paginationMetaKeys, plus page URLs when a baseUrl is set.
320
+ return { data, meta: this.#buildMeta() };
149
321
  }
150
322
  baseUrl(url) {
151
323
  this.#baseUrl = url;
@@ -155,26 +327,70 @@ export class Paginator {
155
327
  this.#queryString = qs;
156
328
  return this;
157
329
  }
158
- toJSON() {
159
- const meta = {
160
- ...this.meta,
161
- };
330
+ /**
331
+ * Build the URL for a page number, honouring `baseUrl` + `queryString`.
332
+ * Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
333
+ */
334
+ getUrl(page) {
335
+ if (!this.#baseUrl)
336
+ return "";
337
+ const params = new URLSearchParams();
338
+ for (const [k, v] of Object.entries(this.#queryString))
339
+ params.set(k, String(v));
340
+ params.set("page", String(page));
341
+ return `${this.#baseUrl}?${params.toString()}`;
342
+ }
343
+ /** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
344
+ getNextPageUrl() {
345
+ return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
346
+ }
347
+ /** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
348
+ getPreviousPageUrl() {
349
+ return this.meta.currentPage > 1
350
+ ? this.getUrl(this.meta.currentPage - 1)
351
+ : null;
352
+ }
353
+ /** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
354
+ getUrlsForRange(start, end) {
355
+ const lo = Math.max(1, start);
356
+ const hi = Math.min(this.meta.lastPage, end);
357
+ const range = [];
358
+ for (let page = lo; page <= hi; page++)
359
+ range.push({
360
+ page,
361
+ url: this.getUrl(page),
362
+ isActive: page === this.meta.currentPage,
363
+ });
364
+ return range;
365
+ }
366
+ /**
367
+ * Build the serialized `meta` object: the raw camelCase fields plus page URLs
368
+ * (when a baseUrl is set), remapped through the naming strategy's
369
+ * `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
370
+ * Shared by {@link toJSON} and {@link serialize} so they never diverge.
371
+ */
372
+ #buildMeta() {
373
+ const raw = { ...this.meta };
162
374
  if (this.#baseUrl) {
163
- const build = (page) => {
164
- const params = new URLSearchParams();
165
- for (const [k, v] of Object.entries(this.#queryString))
166
- params.set(k, String(v));
167
- params.set("page", String(page));
168
- return `${this.#baseUrl}?${params.toString()}`;
169
- };
170
- meta.firstPageUrl = build(1);
171
- meta.lastPageUrl = build(this.meta.lastPage);
172
- if (this.meta.currentPage < this.meta.lastPage)
173
- meta.nextPageUrl = build(this.meta.currentPage + 1);
174
- if (this.meta.currentPage > 1)
175
- meta.previousPageUrl = build(this.meta.currentPage - 1);
176
- }
177
- return { data: this.items, meta };
375
+ raw.firstPageUrl = this.getUrl(1);
376
+ raw.lastPageUrl = this.getUrl(this.meta.lastPage);
377
+ const next = this.getNextPageUrl();
378
+ const prev = this.getPreviousPageUrl();
379
+ if (next)
380
+ raw.nextPageUrl = next;
381
+ if (prev)
382
+ raw.previousPageUrl = prev;
383
+ }
384
+ const keys = this.#metaKeys;
385
+ if (!keys)
386
+ return raw;
387
+ const meta = {};
388
+ for (const [k, v] of Object.entries(raw))
389
+ meta[keys[k] ?? k] = v;
390
+ return meta;
391
+ }
392
+ toJSON() {
393
+ return { data: this.items, meta: this.#buildMeta() };
178
394
  }
179
395
  }
180
396
  /** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
@@ -193,20 +409,46 @@ export class ModelQuery {
193
409
  #orderBys = [];
194
410
  #select = ["*"];
195
411
  #limit;
412
+ /** Top-N-per-parent limit for a has-many preload (Lucid `groupLimit`). */
413
+ #groupLimit;
414
+ /** ORDER BY for the per-parent window (Lucid `groupOrderBy`). */
415
+ #groupOrderBy;
196
416
  #offset;
197
417
  #preloads = new Map();
418
+ // Per-query row transformers (Adonis Lucid `rowTransformer`). Stored via a
419
+ // method-signature wrapper (not a bare `(row: T) => void` property) so T stays
420
+ // in a bivariant position — a function-typed property would force ModelQuery<T>
421
+ // invariant and break the `this: ModelClass<T>` bound on every static finder.
422
+ #rowTransformers = [];
198
423
  /** Correlated subquery projections (withCount / withAggregate). */
199
424
  #selectSubqueries = [];
425
+ /** Raw / subquery SELECT fragments carrying their own params (Lucid `select(raw)`, `select(subquery.as())`). */
426
+ #selectRaw = [];
427
+ /** Caller-facing statement timeout in ms (Lucid `timeout(ms)`), applied via a race in exec. */
428
+ #timeoutMs;
429
+ /** `timeout(ms, { cancel: true })` — also apply a SERVER-side statement timeout. */
430
+ #cancelTimeout = false;
431
+ /** Columns from a chainable `.returning(...)` on a lazy DML builder. */
432
+ #dmlReturning = [];
200
433
  /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
201
434
  #subqueryAlias;
202
435
  /** Raw JOIN fragments — Story 29.4. */
203
436
  #joins = [];
204
- /** Row lock mode — Story 30.8. */
437
+ /** Row lock base mode — Story 30.8. */
205
438
  #lockMode = null;
439
+ /** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
440
+ #lockModifier = null;
441
+ /** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
442
+ #sideloaded = null;
206
443
  /** Per-query debug flag — Story 29.11. */
207
444
  #debugFlag = false;
445
+ /** Metadata attached to the db:query event (Adonis Lucid `reporterData`). */
446
+ #reporterData;
447
+ /** SQL comments prepended to the compiled query (Lucid/Knex `comment`). */
448
+ #comments = [];
208
449
  /** Distinct flag — Story 29.5. */
209
450
  #distinct = false;
451
+ #distinctOn = [];
210
452
  /** GROUP BY columns (Lucid parity). */
211
453
  #groupBy = [];
212
454
  /** HAVING clauses — structured + raw (Lucid parity). */
@@ -217,9 +459,19 @@ export class ModelQuery {
217
459
  #unions = [];
218
460
  /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
219
461
  #pivotWheres = [];
462
+ /** Extra pivot columns requested at query time (Lucid `pivotColumns([...])`). */
463
+ #extraPivotColumns = [];
464
+ /**
465
+ * Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
466
+ * the relation proxy's scoped query; invoked at `#buildSpec()` time with the
467
+ * CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
468
+ * back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
469
+ * time would freeze the predicate before those calls and silently drop them).
470
+ */
471
+ #pivotExists;
220
472
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
221
473
  #dialect;
222
- constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect()) {
474
+ constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect(), prepareValue = (_c, v) => v, onDomainEvents) {
223
475
  this.#tableName = tableName;
224
476
  this.#db = db;
225
477
  this.#hydrateFn = hydrateFn;
@@ -227,7 +479,13 @@ export class ModelQuery {
227
479
  this.#resolveColumn = resolveColumn;
228
480
  this.#softDeletes = softDeletes;
229
481
  this.#dialect = dialect;
482
+ this.#prepareValue = prepareValue;
483
+ this.#onDomainEvents = onDomainEvents;
230
484
  }
485
+ /** @see ValuePreparer — identity unless the owning repository wires prepare in. */
486
+ #prepareValue;
487
+ /** Domain-event bus threaded from the owning repository — propagated to preload repos. */
488
+ #onDomainEvents;
231
489
  /** Include soft-deleted rows in the result (default behavior excludes them). */
232
490
  withTrashed() {
233
491
  this.#softScope = "with-trashed";
@@ -238,6 +496,24 @@ export class ModelQuery {
238
496
  this.#softScope = "only-trashed";
239
497
  return this;
240
498
  }
499
+ /**
500
+ * Apply the first `[guard, callback]` whose guard is truthy (Adonis Lucid
501
+ * `match`); a trailing bare callback is the default, run when none matched.
502
+ */
503
+ match(...blocks) {
504
+ for (const block of blocks) {
505
+ if (typeof block === "function") {
506
+ block(this);
507
+ return this;
508
+ }
509
+ const [guard, callback] = block;
510
+ if (guard) {
511
+ callback(this);
512
+ return this;
513
+ }
514
+ }
515
+ return this;
516
+ }
241
517
  /**
242
518
  * Eager-load a relation (AdonisJS-style).
243
519
  * Relations are never loaded automatically — you must call .preload() explicitly.
@@ -250,13 +526,97 @@ export class ModelQuery {
250
526
  this.#preloads.set(relationName, callback);
251
527
  return this;
252
528
  }
253
- /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
254
- select(columns) {
255
- this.#select = Array.isArray(columns)
256
- ? columns
257
- : columns.split(",").map((c) => c.trim());
529
+ /**
530
+ * Eager-load a relation only if it hasn't been registered yet (Adonis Lucid
531
+ * `preloadOnce`) — a later `preload`/`preloadOnce` for the same relation is
532
+ * ignored, so a shared scope can safely add a preload without clobbering one
533
+ * the caller already set.
534
+ */
535
+ preloadOnce(relationName, callback) {
536
+ if (!this.#preloads.has(relationName)) {
537
+ this.#preloads.set(relationName, callback);
538
+ }
539
+ return this;
540
+ }
541
+ /**
542
+ * Register a callback run for every hydrated instance after loading (preloads
543
+ * included) but before the query resolves (Adonis Lucid `rowTransformer`). The
544
+ * callback mutates the instance in place — decorate rows with per-query
545
+ * computed values without a model hook/accessor. Multiple transformers run in
546
+ * registration order.
547
+ */
548
+ rowTransformer(callback) {
549
+ this.#rowTransformers.push({ run: callback });
258
550
  return this;
259
551
  }
552
+ /**
553
+ * Select columns (default: `*`). The model query builder extends the database
554
+ * query builder's `select` surface (Lucid parity): bare names, multiple args,
555
+ * a comma-separated string, an array, an `{ alias: 'column' }` object map, a
556
+ * `db.raw(...)` fragment, and a named subquery — `select(subquery.as('x'))`.
557
+ * Plain columns are resolved to their DB column (honouring `@Column`).
558
+ */
559
+ select(...columns) {
560
+ const plain = [];
561
+ for (const col of columns) {
562
+ if (typeof col === "string") {
563
+ plain.push(...col.split(",").map((c) => c.trim()));
564
+ }
565
+ else if (Array.isArray(col)) {
566
+ plain.push(...col);
567
+ }
568
+ else if (col instanceof RawSql) {
569
+ // Lucid `select(db.raw(sql, bindings))` — verbatim fragment + params.
570
+ this.#selectRaw.push({ sql: col.sql, params: [...col.params] });
571
+ }
572
+ else if (col instanceof _a) {
573
+ // Lucid `select(subquery.as('alias'))` — a correlated subquery column.
574
+ const alias = col.#subqueryAlias;
575
+ if (!alias) {
576
+ throw new Error("select(subquery) requires the subquery to be named with .as('alias')");
577
+ }
578
+ const { sql, params } = col.#compiledNative();
579
+ this.#selectRaw.push({
580
+ sql: `(${sql}) AS ${this.#quoteAliasName(alias)}`,
581
+ params,
582
+ });
583
+ }
584
+ else {
585
+ for (const [alias, expr] of Object.entries(col)) {
586
+ plain.push(`${expr} AS ${alias}`);
587
+ }
588
+ }
589
+ }
590
+ if (plain.length > 0) {
591
+ this.#select = plain.map((c) => this.#resolveSelect(c));
592
+ }
593
+ return this;
594
+ }
595
+ /** Validate + dialect-quote a bare alias identifier. */
596
+ #quoteAliasName(alias) {
597
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
598
+ throw new Error(`Invalid alias '${alias}' — expected a bare identifier.`);
599
+ }
600
+ const q = this.#dialect === "mysql" ? "`" : '"';
601
+ return `${q}${alias}${q}`;
602
+ }
603
+ /**
604
+ * Resolve a bare model-property select/returning target to its DB column
605
+ * (honouring `@Column({ columnName })`), leaving expressions / aliases /
606
+ * qualified names / `*` untouched. A bare identifier IS validated through the
607
+ * column resolver — so a typo like `select('lable')` raises the same Atlas
608
+ * error as `where`/`orderBy`, rather than reaching the DB.
609
+ */
610
+ #resolveSelect(col) {
611
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col))
612
+ return this.#resolveColumn(col);
613
+ // `col as alias` — resolve the (bare) column part to its DB name, keep the
614
+ // alias verbatim, so `select('label as name')` honours a columnName override.
615
+ const aliased = col.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
616
+ if (aliased)
617
+ return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
618
+ return col;
619
+ }
260
620
  where(columnOrCb, operatorOrValue, value) {
261
621
  if (typeof columnOrCb === "function") {
262
622
  this.#wheres.push(this.#buildGroup("and", columnOrCb));
@@ -289,33 +649,123 @@ export class ModelQuery {
289
649
  });
290
650
  return this;
291
651
  }
292
- /** `WHERE col != ?` — negation of `where`. */
293
- whereNot(column, value) {
652
+ andWhere(columnOrCb, operatorOrValue, value) {
653
+ // The 2-arg overload must not forward a phantom third argument: `where`
654
+ // switches on `value === undefined` to tell `(col, value)` from
655
+ // `(col, operator, value)`.
656
+ return typeof columnOrCb === "function"
657
+ ? this.where(columnOrCb)
658
+ : value === undefined
659
+ ? this.where(columnOrCb, operatorOrValue)
660
+ : this.where(columnOrCb, operatorOrValue, value);
661
+ }
662
+ /** Alias of {@link whereNot} (Lucid parity). */
663
+ andWhereNot(column, value) {
664
+ return this.whereNot(column, value);
665
+ }
666
+ /** Alias of {@link whereIn} (Lucid parity). */
667
+ andWhereIn(column, values) {
668
+ return this.whereIn(column, values);
669
+ }
670
+ /** Alias of {@link whereNotIn} (Lucid parity). */
671
+ andWhereNotIn(column, values) {
672
+ return this.whereNotIn(column, values);
673
+ }
674
+ /** Alias of {@link whereNull} (Lucid parity). */
675
+ andWhereNull(column) {
676
+ return this.whereNull(column);
677
+ }
678
+ /** Alias of {@link whereNotNull} (Lucid parity). */
679
+ andWhereNotNull(column) {
680
+ return this.whereNotNull(column);
681
+ }
682
+ /** Alias of {@link whereBetween} (Lucid parity). */
683
+ andWhereBetween(column, range) {
684
+ return this.whereBetween(column, range);
685
+ }
686
+ /** Alias of {@link whereNotBetween} (Lucid parity). */
687
+ andWhereNotBetween(column, range) {
688
+ return this.whereNotBetween(column, range);
689
+ }
690
+ /** Alias of {@link whereLike} (Lucid parity). */
691
+ andWhereLike(column, pattern) {
692
+ return this.whereLike(column, pattern);
693
+ }
694
+ /** Alias of {@link whereILike} (Lucid parity). */
695
+ andWhereILike(column, pattern) {
696
+ return this.whereILike(column, pattern);
697
+ }
698
+ /** Alias of {@link whereColumn} (Lucid parity). */
699
+ andWhereColumn(left, operator, right) {
700
+ return this.whereColumn(left, operator, right);
701
+ }
702
+ whereNot(columnOrCbOrObj, operatorOrValue, value) {
703
+ if (typeof columnOrCbOrObj === "function") {
704
+ this.#wheres.push({
705
+ ...this.#buildGroup("and", columnOrCbOrObj),
706
+ negated: true,
707
+ });
708
+ return this;
709
+ }
710
+ if (typeof columnOrCbOrObj === "object") {
711
+ for (const [col, val] of Object.entries(columnOrCbOrObj)) {
712
+ this.#wheres.push({
713
+ type: "and",
714
+ column: this.#resolveColumn(col),
715
+ operator: "!=",
716
+ value: this.#prep(col, val),
717
+ });
718
+ }
719
+ return this;
720
+ }
721
+ if (value === undefined) {
722
+ this.#wheres.push({
723
+ type: "and",
724
+ column: this.#resolveColumn(columnOrCbOrObj),
725
+ operator: "!=",
726
+ value: this.#prep(columnOrCbOrObj, operatorOrValue),
727
+ });
728
+ return this;
729
+ }
730
+ // (column, operator, value): NOT (col <op> value) via a negated group so any
731
+ // operator negates correctly without an operator-inversion table.
732
+ const col = columnOrCbOrObj;
733
+ const op = operatorOrValue;
294
734
  this.#wheres.push({
295
- type: "and",
296
- column: this.#resolveColumn(column),
297
- operator: "!=",
298
- value,
735
+ ...this.#buildGroup("and", (q) => q.where(col, op, value)),
736
+ negated: true,
299
737
  });
300
738
  return this;
301
739
  }
302
- /** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
303
- whereIn(column, source) {
740
+ whereIn(columnOrColumns, source) {
741
+ // Tuple form (Lucid `whereIn(['id','email'], [[1,'a@b.com']])`).
742
+ if (Array.isArray(columnOrColumns)) {
743
+ const cols = columnOrColumns.map((c) => this.#resolveColumn(c));
744
+ const rows = (Array.isArray(source) ? source : []).map((r) => (Array.isArray(r) ? r : [r]).map((v, j) => this.#prep(cols[j] ?? "", v)));
745
+ this.#wheres.push({
746
+ type: "and",
747
+ kind: "inTuple",
748
+ columns: cols,
749
+ rows,
750
+ negated: false,
751
+ });
752
+ return this;
753
+ }
304
754
  if (source instanceof _a) {
305
755
  this.#wheres.push({
306
756
  type: "and",
307
757
  kind: "inSub",
308
758
  negated: false,
309
- column: this.#resolveColumn(column),
759
+ column: this.#resolveColumn(columnOrColumns),
310
760
  subquery: source.#buildSpec(),
311
761
  });
312
762
  return this;
313
763
  }
314
764
  this.#wheres.push({
315
765
  type: "and",
316
- column: this.#resolveColumn(column),
766
+ column: this.#resolveColumn(columnOrColumns),
317
767
  operator: "IN",
318
- value: [...source],
768
+ value: this.#prep(columnOrColumns, [...source]),
319
769
  });
320
770
  return this;
321
771
  }
@@ -335,7 +785,7 @@ export class ModelQuery {
335
785
  type: "and",
336
786
  column: this.#resolveColumn(column),
337
787
  operator: "NOT IN",
338
- value: [...source],
788
+ value: this.#prep(column, [...source]),
339
789
  });
340
790
  return this;
341
791
  }
@@ -345,7 +795,7 @@ export class ModelQuery {
345
795
  type: "and",
346
796
  column: this.#resolveColumn(column),
347
797
  operator: "BETWEEN",
348
- value: [...range],
798
+ value: this.#prep(column, [...range]),
349
799
  });
350
800
  return this;
351
801
  }
@@ -355,7 +805,7 @@ export class ModelQuery {
355
805
  type: "and",
356
806
  column: this.#resolveColumn(column),
357
807
  operator: "NOT BETWEEN",
358
- value: [...range],
808
+ value: this.#prep(column, [...range]),
359
809
  });
360
810
  return this;
361
811
  }
@@ -412,7 +862,7 @@ export class ModelQuery {
412
862
  type: "or",
413
863
  column: this.#resolveColumn(column),
414
864
  operator: "!=",
415
- value,
865
+ value: this.#prep(column, value),
416
866
  });
417
867
  return this;
418
868
  }
@@ -432,7 +882,7 @@ export class ModelQuery {
432
882
  type: "or",
433
883
  column: this.#resolveColumn(column),
434
884
  operator: "IN",
435
- value: [...source],
885
+ value: this.#prep(column, [...source]),
436
886
  });
437
887
  return this;
438
888
  }
@@ -452,7 +902,7 @@ export class ModelQuery {
452
902
  type: "or",
453
903
  column: this.#resolveColumn(column),
454
904
  operator: "NOT IN",
455
- value: [...source],
905
+ value: this.#prep(column, [...source]),
456
906
  });
457
907
  return this;
458
908
  }
@@ -462,7 +912,7 @@ export class ModelQuery {
462
912
  type: "or",
463
913
  column: this.#resolveColumn(column),
464
914
  operator: "BETWEEN",
465
- value: [...range],
915
+ value: this.#prep(column, [...range]),
466
916
  });
467
917
  return this;
468
918
  }
@@ -472,7 +922,7 @@ export class ModelQuery {
472
922
  type: "or",
473
923
  column: this.#resolveColumn(column),
474
924
  operator: "NOT BETWEEN",
475
- value: [...range],
925
+ value: this.#prep(column, [...range]),
476
926
  });
477
927
  return this;
478
928
  }
@@ -535,15 +985,47 @@ export class ModelQuery {
535
985
  * Not exported from the package barrel — only accessible inside the Atlas
536
986
  * codebase via direct ModelQuery instance access.
537
987
  */
538
- #pushWhereRaw(sql, bindings = []) {
988
+ #pushWhereRaw(sql, bindings = [], type = "and") {
539
989
  this.#wheres.push({
540
- type: "and",
990
+ type,
541
991
  kind: "raw",
542
992
  sql,
543
993
  bindings: [...bindings],
544
994
  });
545
995
  return this;
546
996
  }
997
+ /** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
998
+ andWhereRaw(sql, bindings = []) {
999
+ return this.whereRaw(sql, bindings);
1000
+ }
1001
+ /**
1002
+ * `OR <raw fragment>` (Lucid parity).
1003
+ *
1004
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1005
+ * Subject to the same strict-mode gate as {@link whereRaw}.
1006
+ */
1007
+ orWhereRaw(sql, bindings = []) {
1008
+ this.#assertRawAllowed("orWhereRaw");
1009
+ return this.#pushWhereRaw(sql, bindings, "or");
1010
+ }
1011
+ /** Shared strict-mode gate for the raw WHERE entry points. */
1012
+ #assertRawAllowed(method) {
1013
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1014
+ throw new Error(`${method}() is disabled in Atlas strict mode. ` +
1015
+ "Use whereExpr() or a structured builder method instead. " +
1016
+ "Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.");
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Framework-internal: register the deferred m2m EXISTS predicate for a lazy
1021
+ * `related().query()`. The builder is re-invoked on every `#buildSpec()` with
1022
+ * the pivot constraints known at that moment, so `.wherePivot()` added after
1023
+ * the proxy returned still applies. Not exported from the barrel.
1024
+ */
1025
+ setPivotExistsBuilder(builder) {
1026
+ this.#pivotExists = builder;
1027
+ return this;
1028
+ }
547
1029
  whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
548
1030
  // 3-arg form: whereExpr(col, op, value)
549
1031
  // 4-arg form: whereExpr(col, extraExpr, op, value)
@@ -562,6 +1044,18 @@ export class ModelQuery {
562
1044
  if (!hasBalancedParens(extra)) {
563
1045
  throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
564
1046
  }
1047
+ // The charset blocks comparison/quote symbols, but bare SQL keywords
1048
+ // (OR / AND / IS / NOT / SELECT …) are just letters and would slip
1049
+ // through, letting `extra` alter the predicate's logical structure
1050
+ // (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
1051
+ // SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
1052
+ // token — arithmetic on columns/numbers/functions only.
1053
+ for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
1054
+ if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
1055
+ throw new Error(`whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
1056
+ "whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.");
1057
+ }
1058
+ }
565
1059
  // `op` is interpolated raw into the fragment below, so it MUST be
566
1060
  // allow-listed — the 3-arg path gets this from the Rust operator
567
1061
  // validation, but the raw 4-arg path bypasses Rust and would
@@ -578,9 +1072,16 @@ export class ModelQuery {
578
1072
  // the operator against the allow-list above.
579
1073
  if (hasExtra) {
580
1074
  const q = this.#quote(resolved);
581
- return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
1075
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
1076
+ this.#prep(column, value),
1077
+ ]);
582
1078
  }
583
- this.#wheres.push({ type: "and", column: resolved, operator: op, value });
1079
+ this.#wheres.push({
1080
+ type: "and",
1081
+ column: resolved,
1082
+ operator: op,
1083
+ value: this.#prep(column, value),
1084
+ });
584
1085
  return this;
585
1086
  }
586
1087
  /**
@@ -589,6 +1090,214 @@ export class ModelQuery {
589
1090
  * operator is allow-listed; nothing is bound (it's a column reference, not a
590
1091
  * value), which the standard `where`/`whereExpr` value-binding path can't do.
591
1092
  */
1093
+ // ─── EXISTS ───────────────────────────────────────────────
1094
+ //
1095
+ // `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
1096
+ // builder `repo.query()` actually hands back, so it was unreachable from
1097
+ // normal use. The subquery is another `ModelQuery`; correlate it to the
1098
+ // outer table with `whereColumn`:
1099
+ //
1100
+ // userRepo.query().whereExists(
1101
+ // postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
1102
+ // )
1103
+ //
1104
+ // For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
1105
+ // join predicate from the relation metadata.
1106
+ /** `WHERE EXISTS (subquery)` (Lucid parity). */
1107
+ whereExists(subquery) {
1108
+ return this.#pushExists("and", false, subquery);
1109
+ }
1110
+ /** Alias of {@link whereExists} (Lucid parity). */
1111
+ andWhereExists(subquery) {
1112
+ return this.#pushExists("and", false, subquery);
1113
+ }
1114
+ /** `OR EXISTS (subquery)` (Lucid parity). */
1115
+ orWhereExists(subquery) {
1116
+ return this.#pushExists("or", false, subquery);
1117
+ }
1118
+ /** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
1119
+ whereNotExists(subquery) {
1120
+ return this.#pushExists("and", true, subquery);
1121
+ }
1122
+ /** Alias of {@link whereNotExists} (Lucid parity). */
1123
+ andWhereNotExists(subquery) {
1124
+ return this.#pushExists("and", true, subquery);
1125
+ }
1126
+ /** `OR NOT EXISTS (subquery)` (Lucid parity). */
1127
+ orWhereNotExists(subquery) {
1128
+ return this.#pushExists("or", true, subquery);
1129
+ }
1130
+ whereJsonPath(column, path, operatorOrValue, value) {
1131
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1132
+ }
1133
+ andWhereJsonPath(column, path, operatorOrValue, value) {
1134
+ return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
1135
+ }
1136
+ orWhereJsonPath(column, path, operatorOrValue, value) {
1137
+ return this.#pushJsonPathArgs("or", column, path, operatorOrValue, value);
1138
+ }
1139
+ /** Resolve the optional-operator JSONPath form, then push (default op `=`). */
1140
+ #pushJsonPathArgs(type, column, path, operatorOrValue, value) {
1141
+ const [operator, val] = value === undefined
1142
+ ? ["=", operatorOrValue]
1143
+ : [String(operatorOrValue), value];
1144
+ return this.#pushJson(type, false, "path", column, val, path, String(operator));
1145
+ }
1146
+ /**
1147
+ * Structural JSON match (Lucid `whereJson`) — the column's JSON must equal
1148
+ * `value` (canonical comparison on Postgres/MySQL). AND is the default.
1149
+ */
1150
+ whereJson(column, value) {
1151
+ return this.#pushJson("and", false, "equals", column, value);
1152
+ }
1153
+ /** Alias of {@link whereJson} (Lucid `andWhereJson`). */
1154
+ andWhereJson(column, value) {
1155
+ return this.#pushJson("and", false, "equals", column, value);
1156
+ }
1157
+ /** OR form of {@link whereJson} (Lucid `orWhereJson`). */
1158
+ orWhereJson(column, value) {
1159
+ return this.#pushJson("or", false, "equals", column, value);
1160
+ }
1161
+ /** WHERE NOT structural JSON match (Lucid `whereNotJson`). */
1162
+ whereNotJson(column, value) {
1163
+ return this.#pushJson("and", true, "equals", column, value);
1164
+ }
1165
+ /** Alias of {@link whereNotJson} (Lucid `andWhereNotJson`). */
1166
+ andWhereNotJson(column, value) {
1167
+ return this.#pushJson("and", true, "equals", column, value);
1168
+ }
1169
+ /** OR NOT structural JSON match (Lucid `orWhereNotJson`). */
1170
+ orWhereNotJson(column, value) {
1171
+ return this.#pushJson("or", true, "equals", column, value);
1172
+ }
1173
+ /**
1174
+ * `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
1175
+ * `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
1176
+ *
1177
+ * Postgres and MySQL only — SQLite has no JSON containment operator and the
1178
+ * compiler raises `E_UNSUPPORTED` there.
1179
+ */
1180
+ whereJsonSupersetOf(column, value) {
1181
+ return this.#pushJson("and", false, "superset", column, value);
1182
+ }
1183
+ /** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
1184
+ andWhereJsonSupersetOf(column, value) {
1185
+ return this.#pushJson("and", false, "superset", column, value);
1186
+ }
1187
+ /** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
1188
+ orWhereJsonSupersetOf(column, value) {
1189
+ return this.#pushJson("or", false, "superset", column, value);
1190
+ }
1191
+ /** `WHERE NOT (<col> @> ?)` (Lucid parity). */
1192
+ whereNotJsonSupersetOf(column, value) {
1193
+ return this.#pushJson("and", true, "superset", column, value);
1194
+ }
1195
+ /** `OR NOT (<col> @> ?)` (Lucid parity). */
1196
+ orWhereNotJsonSupersetOf(column, value) {
1197
+ return this.#pushJson("or", true, "superset", column, value);
1198
+ }
1199
+ /**
1200
+ * `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
1201
+ * `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
1202
+ */
1203
+ whereJsonSubsetOf(column, value) {
1204
+ return this.#pushJson("and", false, "subset", column, value);
1205
+ }
1206
+ /** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
1207
+ andWhereJsonSubsetOf(column, value) {
1208
+ return this.#pushJson("and", false, "subset", column, value);
1209
+ }
1210
+ /** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
1211
+ orWhereJsonSubsetOf(column, value) {
1212
+ return this.#pushJson("or", false, "subset", column, value);
1213
+ }
1214
+ /** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
1215
+ whereNotJsonSubsetOf(column, value) {
1216
+ return this.#pushJson("and", true, "subset", column, value);
1217
+ }
1218
+ /** `OR NOT (<col> <@ ?)` (Lucid parity). */
1219
+ orWhereNotJsonSubsetOf(column, value) {
1220
+ return this.#pushJson("or", true, "subset", column, value);
1221
+ }
1222
+ // Lucid's canonical containment names drop the `Of` suffix — kept as aliases.
1223
+ /** Lucid `whereJsonSuperset` (alias of {@link whereJsonSupersetOf}). */
1224
+ whereJsonSuperset(column, value) {
1225
+ return this.#pushJson("and", false, "superset", column, value);
1226
+ }
1227
+ /** Lucid `andWhereJsonSuperset`. */
1228
+ andWhereJsonSuperset(column, value) {
1229
+ return this.#pushJson("and", false, "superset", column, value);
1230
+ }
1231
+ /** Lucid `orWhereJsonSuperset`. */
1232
+ orWhereJsonSuperset(column, value) {
1233
+ return this.#pushJson("or", false, "superset", column, value);
1234
+ }
1235
+ /** Lucid `whereNotJsonSuperset`. */
1236
+ whereNotJsonSuperset(column, value) {
1237
+ return this.#pushJson("and", true, "superset", column, value);
1238
+ }
1239
+ /** Lucid `orWhereNotJsonSuperset`. */
1240
+ orWhereNotJsonSuperset(column, value) {
1241
+ return this.#pushJson("or", true, "superset", column, value);
1242
+ }
1243
+ /** Lucid `whereJsonSubset` (alias of {@link whereJsonSubsetOf}). */
1244
+ whereJsonSubset(column, value) {
1245
+ return this.#pushJson("and", false, "subset", column, value);
1246
+ }
1247
+ /** Lucid `andWhereJsonSubset`. */
1248
+ andWhereJsonSubset(column, value) {
1249
+ return this.#pushJson("and", false, "subset", column, value);
1250
+ }
1251
+ /** Lucid `orWhereJsonSubset`. */
1252
+ orWhereJsonSubset(column, value) {
1253
+ return this.#pushJson("or", false, "subset", column, value);
1254
+ }
1255
+ /** Lucid `whereNotJsonSubset`. */
1256
+ whereNotJsonSubset(column, value) {
1257
+ return this.#pushJson("and", true, "subset", column, value);
1258
+ }
1259
+ /** Lucid `orWhereNotJsonSubset`. */
1260
+ orWhereNotJsonSubset(column, value) {
1261
+ return this.#pushJson("or", true, "subset", column, value);
1262
+ }
1263
+ #pushJson(type, negated, jsonOp, column, value, path, operator) {
1264
+ // A JSONPath is bound, not interpolated, so injection is not the concern
1265
+ // here — a clear early error for a malformed path is. Lucid/Knex paths
1266
+ // start at the document root.
1267
+ if (path !== undefined && !path.startsWith("$")) {
1268
+ throw new Error(`whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`);
1269
+ }
1270
+ // Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
1271
+ // and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
1272
+ // as-is would not cast. A path comparison keeps its scalar value.
1273
+ const bound = jsonOp === "path"
1274
+ ? value
1275
+ : typeof value === "string"
1276
+ ? value
1277
+ : JSON.stringify(value);
1278
+ this.#wheres.push({
1279
+ type,
1280
+ kind: "json",
1281
+ jsonOp,
1282
+ column: this.#resolveColumn(column),
1283
+ negated,
1284
+ path,
1285
+ operator,
1286
+ value: bound,
1287
+ });
1288
+ return this;
1289
+ }
1290
+ #pushExists(type, negated, subquery) {
1291
+ // `#buildSpec` is private, but private access is per-class, not per
1292
+ // instance: another ModelQuery's spec is reachable from here.
1293
+ this.#wheres.push({
1294
+ type,
1295
+ kind: "exists",
1296
+ negated,
1297
+ subquery: subquery.#buildSpec(),
1298
+ });
1299
+ return this;
1300
+ }
592
1301
  whereColumn(left, operator, right) {
593
1302
  return this.#whereColumn("and", left, operator, right);
594
1303
  }
@@ -596,7 +1305,19 @@ export class ModelQuery {
596
1305
  orWhereColumn(left, operator, right) {
597
1306
  return this.#whereColumn("or", left, operator, right);
598
1307
  }
599
- #whereColumn(type, left, operator, right) {
1308
+ /** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
1309
+ whereNotColumn(left, operator, right) {
1310
+ return this.#whereColumn("and", left, operator, right, true);
1311
+ }
1312
+ /** Alias of {@link whereNotColumn} (Lucid parity). */
1313
+ andWhereNotColumn(left, operator, right) {
1314
+ return this.#whereColumn("and", left, operator, right, true);
1315
+ }
1316
+ /** `OR NOT (left <op> right)` (Lucid parity). */
1317
+ orWhereNotColumn(left, operator, right) {
1318
+ return this.#whereColumn("or", left, operator, right, true);
1319
+ }
1320
+ #whereColumn(type, left, operator, right, negated = false) {
600
1321
  if (!WHEREEXPR_OPERATORS.has(operator)) {
601
1322
  throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
602
1323
  }
@@ -606,7 +1327,7 @@ export class ModelQuery {
606
1327
  // `[table.]column` charset. This closes the injection surface regardless of
607
1328
  // what #resolveColumn returns (it can be an identity resolver on sub-queries).
608
1329
  const safe = (name) => {
609
- const resolved = this.#resolveColumn(name);
1330
+ const resolved = this.#resolveColumnReference(name);
610
1331
  if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
611
1332
  throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
612
1333
  }
@@ -617,10 +1338,45 @@ export class ModelQuery {
617
1338
  .map((part) => this.#quote(part))
618
1339
  .join(".");
619
1340
  };
620
- const sql = `${safe(left)} ${operator} ${safe(right)}`;
1341
+ const predicate = `${safe(left)} ${operator} ${safe(right)}`;
1342
+ // Both operands are already validated identifiers and the operator is
1343
+ // allow-listed, so wrapping in NOT(...) adds no new surface.
1344
+ const sql = negated ? `NOT (${predicate})` : predicate;
621
1345
  this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
622
1346
  return this;
623
1347
  }
1348
+ /**
1349
+ * Resolve a column reference that may legitimately point at a table other
1350
+ * than this query's own.
1351
+ *
1352
+ * `#resolveColumn` only knows the entity's own columns, so it rejects
1353
+ * anything qualified. That is right for a value predicate, but wrong for a
1354
+ * column-to-column one: a correlated subquery
1355
+ * (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
1356
+ * and a joined query both have to name another table, and atlas cannot know
1357
+ * that table's columns. So: an unqualified name resolves as usual (typos
1358
+ * still get the helpful error), and a `table.column` naming a different
1359
+ * table passes through — validated against the identifier charset here and
1360
+ * quoted segment by segment by the caller, never interpolated loose. A typo
1361
+ * in that case surfaces as a database error rather than an atlas one, which
1362
+ * is the unavoidable cost of referencing a table we have no metadata for.
1363
+ */
1364
+ #resolveColumnReference(name) {
1365
+ const qualified = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
1366
+ if (!qualified)
1367
+ return this.#resolveColumn(name);
1368
+ const [, table, column] = qualified;
1369
+ // Our own table: resolve the column half so `@Column({ columnName })` and
1370
+ // the camel→snake convention still apply.
1371
+ if (table === this.#tableName) {
1372
+ return `${table}.${this.#resolveColumn(column)}`;
1373
+ }
1374
+ // Another table in scope (outer query or JOIN). Charset-checked by the
1375
+ // regex above and quoted segment by segment by the caller — strict mode
1376
+ // does not apply, since its concern is unvalidated SQL reaching the
1377
+ // compiler and this identifier is validated.
1378
+ return `${table}.${column}`;
1379
+ }
624
1380
  /**
625
1381
  * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
626
1382
  * by the existence of related rows, optionally constrained by a callback.
@@ -645,6 +1401,14 @@ export class ModelQuery {
645
1401
  this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
646
1402
  return this;
647
1403
  }
1404
+ /** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
1405
+ andWhereHas(relationName, callback) {
1406
+ return this.whereHas(relationName, callback);
1407
+ }
1408
+ /** Alias of {@link whereDoesntHave} (Lucid parity). */
1409
+ andWhereDoesntHave(relationName, callback) {
1410
+ return this.whereDoesntHave(relationName, callback);
1411
+ }
648
1412
  /**
649
1413
  * Short form of `whereHas`. With an operator + count, emits a count threshold:
650
1414
  * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
@@ -663,6 +1427,19 @@ export class ModelQuery {
663
1427
  this.#wheres.push(this.#buildExistsClause("and", true, relationName));
664
1428
  return this;
665
1429
  }
1430
+ /** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
1431
+ orDoesntHave(relationName) {
1432
+ this.#wheres.push(this.#buildExistsClause("or", true, relationName));
1433
+ return this;
1434
+ }
1435
+ /** Alias of {@link has} (Lucid parity) — `has` is already AND. */
1436
+ andHas(relationName, countOp, countThreshold) {
1437
+ return this.has(relationName, countOp, countThreshold);
1438
+ }
1439
+ /** Alias of {@link doesntHave} (Lucid parity). */
1440
+ andDoesntHave(relationName) {
1441
+ return this.doesntHave(relationName);
1442
+ }
666
1443
  /**
667
1444
  * Set this query's projection alias — only meaningful when this ModelQuery
668
1445
  * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
@@ -698,23 +1475,25 @@ export class ModelQuery {
698
1475
  // --- Top-level scalar executors (Story 29.5) ---
699
1476
  /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
700
1477
  async count(column = "*") {
701
- const expr = column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
1478
+ const expr = column === "*"
1479
+ ? "COUNT(*)"
1480
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
702
1481
  return Number((await this.#runScalar(expr)) ?? 0);
703
1482
  }
704
1483
  async sum(column) {
705
- const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
1484
+ const v = await this.#runScalar(`SUM(${this.#quoteCol(this.#resolveColumn(column))})`);
706
1485
  return v === null || v === undefined ? null : Number(v);
707
1486
  }
708
1487
  async avg(column) {
709
- const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
1488
+ const v = await this.#runScalar(`AVG(${this.#quoteCol(this.#resolveColumn(column))})`);
710
1489
  return v === null || v === undefined ? null : Number(v);
711
1490
  }
712
1491
  async min(column) {
713
- const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
1492
+ const v = await this.#runScalar(`MIN(${this.#quoteCol(this.#resolveColumn(column))})`);
714
1493
  return v === null || v === undefined ? null : Number(v);
715
1494
  }
716
1495
  async max(column) {
717
- const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
1496
+ const v = await this.#runScalar(`MAX(${this.#quoteCol(this.#resolveColumn(column))})`);
718
1497
  return v === null || v === undefined ? null : Number(v);
719
1498
  }
720
1499
  /**
@@ -741,35 +1520,150 @@ export class ModelQuery {
741
1520
  this.#selectSubqueries.push(this.#buildRelationSubquery(relationName, callback, "aggregate", relationName));
742
1521
  return this;
743
1522
  }
744
- orderBy(column, direction = "asc") {
745
- this.#orderBys.push({ column: this.#resolveColumn(column), direction });
1523
+ orderBy(columnOrTerms, direction = "asc") {
1524
+ // A raw fragment (e.g. `db.ref('posts.created_at')`) orders verbatim.
1525
+ if (columnOrTerms instanceof RawSql) {
1526
+ const dir = direction === "desc" ? "DESC" : "ASC";
1527
+ this.#orderBys.push({ raw: `${columnOrTerms.sql} ${dir}` });
1528
+ return this;
1529
+ }
1530
+ if (Array.isArray(columnOrTerms)) {
1531
+ for (const t of columnOrTerms) {
1532
+ const [col, dir] = typeof t === "string"
1533
+ ? [t, "asc"]
1534
+ : [t.column, t.order ?? "asc"];
1535
+ this.#orderBys.push({
1536
+ column: this.#resolveColumn(col),
1537
+ direction: dir,
1538
+ });
1539
+ }
1540
+ return this;
1541
+ }
1542
+ this.#orderBys.push({
1543
+ column: this.#resolveColumn(columnOrTerms),
1544
+ direction,
1545
+ });
1546
+ return this;
1547
+ }
1548
+ /**
1549
+ * `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
1550
+ * no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
1551
+ * alias.
1552
+ *
1553
+ * query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
1554
+ *
1555
+ * The fragment keeps its position among the plain `orderBy` terms.
1556
+ *
1557
+ * **Strict mode**: like {@link whereRaw}, this throws when
1558
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
1559
+ *
1560
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1561
+ */
1562
+ orderByRaw(sql) {
1563
+ this.#assertRawAllowed("orderByRaw");
1564
+ this.#orderBys.push({ raw: sql });
746
1565
  return this;
747
1566
  }
748
1567
  /**
749
1568
  * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
750
1569
  * through the entity's column map (camelCase → snake_case) like `orderBy`.
751
- * For a raw grouping expression, use a `whereRaw`-style construct via the
752
- * fluent {@link QueryBuilder}.
1570
+ * For a grouping expression with no typed form, see {@link groupByRaw}.
753
1571
  */
754
1572
  groupBy(...columns) {
755
1573
  for (const c of columns)
756
1574
  this.#groupBy.push(this.#resolveColumn(c));
757
1575
  return this;
758
1576
  }
1577
+ /**
1578
+ * `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
1579
+ * no typed form, e.g. `DATE_TRUNC('day', created_at)`.
1580
+ *
1581
+ * The fragment keeps its position among the plain `groupBy` terms.
1582
+ *
1583
+ * **Strict mode**: like {@link whereRaw}, this throws when
1584
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
1585
+ *
1586
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1587
+ */
1588
+ groupByRaw(sql) {
1589
+ this.#assertRawAllowed("groupByRaw");
1590
+ this.#groupBy.push({ raw: sql });
1591
+ return this;
1592
+ }
759
1593
  /**
760
1594
  * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
761
- * The column is passed verbatim to the Rust HAVING compiler, which quotes a
762
- * plain identifier or accepts an allow-listed aggregate expression
763
- * (`COUNT(*)`, `SUM(col)`, …) it is NOT run through the entity column map,
764
- * so aggregate expressions and result aliases both work.
1595
+ * A bare model property is resolved through the entity column map (honouring
1596
+ * `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
1597
+ * expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
1598
+ * `having` can still reference `withCount`/`withAggregate` aliases.
765
1599
  */
766
1600
  having(column, operator, value) {
767
- this.#having.push({ column, operator, value, type: "and" });
1601
+ this.#having.push({
1602
+ column: this.#resolveHavingCol(column),
1603
+ operator,
1604
+ value: this.#prep(column, value),
1605
+ type: "and",
1606
+ });
768
1607
  return this;
769
1608
  }
1609
+ /** HAVING col IS NULL (Lucid/Knex `havingNull`). */
1610
+ havingNull(column) {
1611
+ return this.#pushHaving(column, "IS NULL", null);
1612
+ }
1613
+ /** HAVING col IS NOT NULL (Lucid/Knex `havingNotNull`). */
1614
+ havingNotNull(column) {
1615
+ return this.#pushHaving(column, "IS NOT NULL", null);
1616
+ }
1617
+ /** HAVING col IN (...) (Lucid/Knex `havingIn`). */
1618
+ havingIn(column, values) {
1619
+ return this.#pushHaving(column, "IN", [...values]);
1620
+ }
1621
+ /** HAVING col NOT IN (...) (Lucid/Knex `havingNotIn`). */
1622
+ havingNotIn(column, values) {
1623
+ return this.#pushHaving(column, "NOT IN", [...values]);
1624
+ }
1625
+ /** HAVING col BETWEEN ? AND ? (Lucid/Knex `havingBetween`). */
1626
+ havingBetween(column, range) {
1627
+ return this.#pushHaving(column, "BETWEEN", [...range]);
1628
+ }
1629
+ /** HAVING col NOT BETWEEN ? AND ? (Lucid/Knex `havingNotBetween`). */
1630
+ havingNotBetween(column, range) {
1631
+ return this.#pushHaving(column, "NOT BETWEEN", [...range]);
1632
+ }
1633
+ /** Push a HAVING entry with a raw (non-`#prep`'d) value — for IN/BETWEEN/NULL. */
1634
+ #pushHaving(column, operator, value) {
1635
+ this.#having.push({
1636
+ column: this.#resolveHavingCol(column),
1637
+ operator,
1638
+ value,
1639
+ type: "and",
1640
+ });
1641
+ return this;
1642
+ }
1643
+ /**
1644
+ * Resolve a HAVING column: a bare model property maps to its DB column
1645
+ * (honouring `@Column({ columnName })`), but an aggregate expression
1646
+ * (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
1647
+ * so `having` can still reference `withCount`/`withAggregate` aliases.
1648
+ */
1649
+ #resolveHavingCol(column) {
1650
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column))
1651
+ return column;
1652
+ try {
1653
+ return this.#resolveColumn(column);
1654
+ }
1655
+ catch {
1656
+ return column;
1657
+ }
1658
+ }
770
1659
  /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
771
1660
  orHaving(column, operator, value) {
772
- this.#having.push({ column, operator, value, type: "or" });
1661
+ this.#having.push({
1662
+ column: this.#resolveHavingCol(column),
1663
+ operator,
1664
+ value: this.#prep(column, value),
1665
+ type: "or",
1666
+ });
773
1667
  return this;
774
1668
  }
775
1669
  /**
@@ -781,6 +1675,12 @@ export class ModelQuery {
781
1675
  * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
782
1676
  */
783
1677
  havingRaw(sql, bindings = []) {
1678
+ // Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
1679
+ // surface, so prod hardening must be able to neutralise it too.
1680
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1681
+ throw new Error("havingRaw() is disabled in Atlas strict mode. " +
1682
+ "Use having(column, operator, value) instead.");
1683
+ }
784
1684
  this.#having.push({
785
1685
  kind: "raw",
786
1686
  sql,
@@ -795,48 +1695,224 @@ export class ModelQuery {
795
1695
  * the outer parameter list.
796
1696
  */
797
1697
  union(query) {
798
- this.#unions.push({ query, all: false });
1698
+ this.#unions.push({ query: this.#resolveUnion(query), all: false });
799
1699
  return this;
800
1700
  }
801
1701
  /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
802
1702
  unionAll(query) {
803
- this.#unions.push({ query, all: true });
1703
+ this.#unions.push({ query: this.#resolveUnion(query), all: true });
1704
+ return this;
1705
+ }
1706
+ /** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
1707
+ intersect(query) {
1708
+ this.#unions.push({
1709
+ query: this.#resolveUnion(query),
1710
+ all: false,
1711
+ op: "intersect",
1712
+ });
804
1713
  return this;
805
1714
  }
1715
+ /**
1716
+ * `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
1717
+ *
1718
+ * Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
1719
+ * INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
1720
+ * `E_UNSUPPORTED` there rather than emitting a syntax error.
1721
+ */
1722
+ intersectAll(query) {
1723
+ this.#unions.push({
1724
+ query: this.#resolveUnion(query),
1725
+ all: true,
1726
+ op: "intersect",
1727
+ });
1728
+ return this;
1729
+ }
1730
+ /** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
1731
+ except(query) {
1732
+ this.#unions.push({
1733
+ query: this.#resolveUnion(query),
1734
+ all: false,
1735
+ op: "except",
1736
+ });
1737
+ return this;
1738
+ }
1739
+ /** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
1740
+ exceptAll(query) {
1741
+ this.#unions.push({
1742
+ query: this.#resolveUnion(query),
1743
+ all: true,
1744
+ op: "except",
1745
+ });
1746
+ return this;
1747
+ }
1748
+ /** A compound-query arg — an explicit ModelQuery OR a callback building one on this model. */
1749
+ #resolveUnion(query) {
1750
+ if (typeof query !== "function")
1751
+ return query;
1752
+ const sub = this.#freshQuery();
1753
+ query(sub);
1754
+ return sub;
1755
+ }
1756
+ /** A fresh, empty query on the SAME model + connection (for compound callbacks). */
1757
+ #freshQuery() {
1758
+ return new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
1759
+ }
806
1760
  /**
807
1761
  * `WITH <name> AS (<query>)` — register a Common Table Expression
808
1762
  * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
809
1763
  * sub-query is compiled and its bindings are re-indexed into the outer list.
810
1764
  */
811
1765
  with(name, query) {
1766
+ return this.#pushCte("with", name, query, {});
1767
+ }
1768
+ /**
1769
+ * `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
1770
+ * (Lucid/Knex `withRecursive`), for trees and graph walks.
1771
+ *
1772
+ * RECURSIVE is a property of the WITH clause rather than of one CTE, so a
1773
+ * single recursive entry makes the whole clause recursive — which is what
1774
+ * all three dialects require. Mixing `with()` and `withRecursive()` is fine.
1775
+ *
1776
+ * The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
1777
+ * an anchor `SELECT` unioned with a select that references `<name>`.
1778
+ */
1779
+ withRecursive(name, query, columns) {
1780
+ return this.#pushCte("withRecursive", name, query, {
1781
+ recursive: true,
1782
+ columns,
1783
+ });
1784
+ }
1785
+ /**
1786
+ * `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
1787
+ * once and stashed (Lucid/Knex `withMaterialized`).
1788
+ *
1789
+ * Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
1790
+ * compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
1791
+ */
1792
+ withMaterialized(name, query) {
1793
+ return this.#pushCte("withMaterialized", name, query, {
1794
+ materialized: true,
1795
+ });
1796
+ }
1797
+ /** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
1798
+ withNotMaterialized(name, query) {
1799
+ return this.#pushCte("withNotMaterialized", name, query, {
1800
+ materialized: false,
1801
+ });
1802
+ }
1803
+ #pushCte(method, name, query, options) {
812
1804
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
813
- throw new Error(`with(): CTE name '${name}' is not a valid identifier`);
1805
+ throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
814
1806
  }
815
- this.#ctes.push({ name, query });
1807
+ this.#ctes.push({ name, query: this.#resolveUnion(query), ...options });
1808
+ return this;
1809
+ }
1810
+ /**
1811
+ * `@ManyToMany` only — project EXTRA pivot columns at query time (AdonisJS/Lucid
1812
+ * `pivotColumns([...])`), on top of any declared in the relation decorator. Read
1813
+ * off each loaded relation as `$extras.pivot_<col>`. Inert on non-m2m relations.
1814
+ *
1815
+ * userRepo.query().preload('skills', q => q.pivotColumns(['notes']))
1816
+ */
1817
+ pivotColumns(columns) {
1818
+ this.#extraPivotColumns.push(...columns);
816
1819
  return this;
817
1820
  }
818
1821
  wherePivot(column, operatorOrValue, value) {
1822
+ return this.#pushPivot("and", column, operatorOrValue, value);
1823
+ }
1824
+ andWherePivot(column, operatorOrValue, value) {
1825
+ return this.#pushPivot("and", column, operatorOrValue, value);
1826
+ }
1827
+ orWherePivot(column, operatorOrValue, value) {
1828
+ return this.#pushPivot("or", column, operatorOrValue, value);
1829
+ }
1830
+ /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
1831
+ whereInPivot(column, values) {
1832
+ return this.#pushPivotOp("and", column, "IN", [...values]);
1833
+ }
1834
+ /** Alias of {@link whereInPivot} (Lucid parity). */
1835
+ andWhereInPivot(column, values) {
1836
+ return this.#pushPivotOp("and", column, "IN", [...values]);
1837
+ }
1838
+ /** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
1839
+ orWhereInPivot(column, values) {
1840
+ return this.#pushPivotOp("or", column, "IN", [...values]);
1841
+ }
1842
+ /** Alias of {@link whereInPivot} kept for the earlier atlas name. */
1843
+ wherePivotIn(column, values) {
1844
+ return this.whereInPivot(column, values);
1845
+ }
1846
+ whereNotPivot(column, operatorOrValue, value) {
819
1847
  if (value === undefined) {
820
- this.#pivotWheres.push({ column, operator: "=", value: operatorOrValue });
821
- }
822
- else {
823
- this.#pivotWheres.push({
824
- column,
825
- operator: operatorOrValue,
826
- value,
827
- });
1848
+ return this.#pushPivotOp("and", column, "!=", operatorOrValue);
828
1849
  }
829
- return this;
1850
+ return this.#pushPivotOp("and", column, negateOperator(String(operatorOrValue)), value);
830
1851
  }
831
- /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `wherePivotIn`). */
832
- wherePivotIn(column, values) {
833
- this.#pivotWheres.push({ column, operator: "IN", value: [...values] });
1852
+ andWhereNotPivot(column, operatorOrValue, value) {
1853
+ return value === undefined
1854
+ ? this.#pushPivotOp("and", column, "!=", operatorOrValue)
1855
+ : this.#pushPivotOp("and", column, negateOperator(String(operatorOrValue)), value);
1856
+ }
1857
+ orWhereNotPivot(column, operatorOrValue, value) {
1858
+ return value === undefined
1859
+ ? this.#pushPivotOp("or", column, "!=", operatorOrValue)
1860
+ : this.#pushPivotOp("or", column, negateOperator(String(operatorOrValue)), value);
1861
+ }
1862
+ /** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
1863
+ whereNotInPivot(column, values) {
1864
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
1865
+ }
1866
+ /** Alias of {@link whereNotInPivot} (Lucid parity). */
1867
+ andWhereNotInPivot(column, values) {
1868
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
1869
+ }
1870
+ /** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
1871
+ orWhereNotInPivot(column, values) {
1872
+ return this.#pushPivotOp("or", column, "NOT IN", [...values]);
1873
+ }
1874
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
1875
+ whereNullPivot(column) {
1876
+ return this.#pushPivotOp("and", column, "IS NULL", null);
1877
+ }
1878
+ /** Alias of {@link whereNullPivot} (Lucid parity). */
1879
+ andWhereNullPivot(column) {
1880
+ return this.#pushPivotOp("and", column, "IS NULL", null);
1881
+ }
1882
+ /** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
1883
+ orWhereNullPivot(column) {
1884
+ return this.#pushPivotOp("or", column, "IS NULL", null);
1885
+ }
1886
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
1887
+ whereNotNullPivot(column) {
1888
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
1889
+ }
1890
+ /** Alias of {@link whereNotNullPivot} (Lucid parity). */
1891
+ andWhereNotNullPivot(column) {
1892
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
1893
+ }
1894
+ /** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
1895
+ orWhereNotNullPivot(column) {
1896
+ return this.#pushPivotOp("or", column, "IS NOT NULL", null);
1897
+ }
1898
+ /** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
1899
+ #pushPivot(type, column, operatorOrValue, value) {
1900
+ return value === undefined
1901
+ ? this.#pushPivotOp(type, column, "=", operatorOrValue)
1902
+ : this.#pushPivotOp(type, column, operatorOrValue, value);
1903
+ }
1904
+ #pushPivotOp(type, column, operator, value) {
1905
+ this.#pivotWheres.push({ column, operator, value, type });
834
1906
  return this;
835
1907
  }
836
1908
  /** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
837
1909
  get pivotConstraints() {
838
1910
  return this.#pivotWheres;
839
1911
  }
1912
+ /** Read-only accessor for query-time pivot columns — consumed by the m2m preload resolver. */
1913
+ get pivotColumnRequests() {
1914
+ return this.#extraPivotColumns;
1915
+ }
840
1916
  limit(n) {
841
1917
  // Guard here with a clear message — the Rust spec types limit as
842
1918
  // u64, so a negative/non-integer otherwise surfaces as a cryptic
@@ -848,6 +1924,24 @@ export class ModelQuery {
848
1924
  this.#limit = n;
849
1925
  return this;
850
1926
  }
1927
+ /**
1928
+ * In a has-many preload callback, cap the related rows PER PARENT (Lucid
1929
+ * `groupLimit`) — a plain `.limit()` caps the whole result set across parents.
1930
+ * Compiled with a `ROW_NUMBER() OVER (PARTITION BY <fk> …)` window. Pair with
1931
+ * {@link groupOrderBy} to pick which N per parent.
1932
+ */
1933
+ groupLimit(n) {
1934
+ if (!Number.isInteger(n) || n < 0) {
1935
+ throw new Error(`groupLimit must be a non-negative integer, got ${n}`);
1936
+ }
1937
+ this.#groupLimit = n;
1938
+ return this;
1939
+ }
1940
+ /** ORDER BY for the {@link groupLimit} per-parent window (Lucid `groupOrderBy`). */
1941
+ groupOrderBy(column, direction = "asc") {
1942
+ this.#groupOrderBy = { column, direction };
1943
+ return this;
1944
+ }
851
1945
  offset(n) {
852
1946
  if (!Number.isInteger(n) || n < 0) {
853
1947
  throw new Error(`offset must be a non-negative integer, got ${n}`);
@@ -903,14 +1997,79 @@ export class ModelQuery {
903
1997
  return this.exec().then(onfulfilled, onrejected);
904
1998
  }
905
1999
  /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
2000
+ /**
2001
+ * DB column backing the soft-delete `deletedAt` property — honours a
2002
+ * `@Column({ columnName })` override, read straight from the entity metadata
2003
+ * (not the resolver callback, which is identity for subqueries/preloads).
2004
+ */
2005
+ #deletedAtColumn() {
2006
+ const col = this.#entityClass
2007
+ ? getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === "deletedAt")
2008
+ : undefined;
2009
+ return col?.columnName ?? "deleted_at";
2010
+ }
906
2011
  #buildSpec() {
2012
+ // `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
2013
+ // compiler emits the lock clause only when a base mode is set, so a lone
2014
+ // modifier would be a SILENT no-op (dangerous for job-queue polling that
2015
+ // believes it skips locked rows). Fail loud instead. Order-independent: this
2016
+ // fires whether the modifier was chained before or after the base lock.
2017
+ if (this.#lockModifier && !this.#lockMode) {
2018
+ throw new Error(`${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`);
2019
+ }
2020
+ // With a JOIN and the default `SELECT *`, scope the projection to the base
2021
+ // table's declared columns so joined columns can't clobber the model's fields
2022
+ // (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
2023
+ // AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
2024
+ let selectCols = this.#select;
2025
+ if (this.#joins.length > 0 &&
2026
+ this.#select.length === 1 &&
2027
+ this.#select[0] === "*") {
2028
+ const cols = getColumnMetadata(this.#entityClass).map((c) => `${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`);
2029
+ if (cols.length > 0)
2030
+ selectCols = cols;
2031
+ }
2032
+ else if (!(selectCols.length === 1 && selectCols[0] === "*") &&
2033
+ selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))) {
2034
+ // A partial `select()` of PLAIN columns that omits the primary key would
2035
+ // hydrate a persisted entity with no PK — a later save() would then INSERT
2036
+ // instead of UPDATE (double-write / unique violation / spurious
2037
+ // beforeCreate). Auto-include the (base-table-qualified) PK so model
2038
+ // entities stay saveable. Aggregate/alias/expression selects are left
2039
+ // untouched — use `.pojo()` for those.
2040
+ const pkProp = getPrimaryKey(this.#entityClass);
2041
+ if (pkProp) {
2042
+ const pkCol = getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === pkProp)?.columnName ?? camelToSnake(pkProp);
2043
+ // The PK counts as present ONLY as the bare column or the BASE-table-
2044
+ // qualified column. A joined `other.id` must NOT satisfy it (its leaf
2045
+ // collides with the PK name but it's a different table's row) — otherwise
2046
+ // we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
2047
+ // save(). Appended last, `base.id` also wins the duplicate result key
2048
+ // (rows collect in column order, last-wins) so the base row's PK hydrates.
2049
+ const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
2050
+ if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
2051
+ selectCols = [...selectCols, baseQualifiedPk];
2052
+ }
2053
+ }
2054
+ }
907
2055
  const wheres = [...this.#wheres];
908
- // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes
2056
+ // Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
2057
+ // `.wherePivot()` recorded since the proxy handed back this query (pushed to
2058
+ // the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
2059
+ if (this.#pivotExists) {
2060
+ const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
2061
+ wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
2062
+ }
2063
+ // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
2064
+ // Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
2065
+ // override on the soft-delete column is honoured on the read side too — matching
2066
+ // the write side (delete/restore go through #dbColumn).
909
2067
  if (this.#softDeletes) {
2068
+ const deletedAtCol = this.#deletedAtColumn();
910
2069
  if (this.#softScope === "default") {
911
2070
  wheres.push({
912
2071
  type: "and",
913
- column: "deleted_at",
2072
+ column: deletedAtCol,
914
2073
  operator: "IS NULL",
915
2074
  value: null,
916
2075
  });
@@ -918,7 +2077,7 @@ export class ModelQuery {
918
2077
  else if (this.#softScope === "only-trashed") {
919
2078
  wheres.push({
920
2079
  type: "and",
921
- column: "deleted_at",
2080
+ column: deletedAtCol,
922
2081
  operator: "IS NOT NULL",
923
2082
  value: null,
924
2083
  });
@@ -928,8 +2087,9 @@ export class ModelQuery {
928
2087
  return {
929
2088
  kind: "select",
930
2089
  table: this.#tableName,
931
- select: this.#select,
2090
+ select: selectCols,
932
2091
  selectSubqueries: this.#selectSubqueries,
2092
+ selectRaw: this.#selectRaw,
933
2093
  wheres,
934
2094
  orderBy: this.#orderBys,
935
2095
  groupBy: this.#groupBy,
@@ -937,22 +2097,120 @@ export class ModelQuery {
937
2097
  limit: this.#limit ?? null,
938
2098
  offset: this.#offset ?? null,
939
2099
  distinct: this.#distinct,
940
- ctes: this.#ctes.map((c) => {
941
- const { sql, params } = c.query.toSQL();
942
- return { name: c.name, sql, params };
943
- }),
2100
+ distinctOn: this.#distinctOn,
2101
+ ctes: this.#compiledCtes(),
944
2102
  unions: this.#unions.map((u) => {
945
- const { sql, params } = u.query.toSQL();
946
- return { sql, params, all: u.all };
2103
+ const { sql, params } = u.query.#compiledNative();
2104
+ return { sql, params, all: u.all, op: u.op ?? null };
947
2105
  }),
948
2106
  joins: this.#joins,
949
- lockMode: this.#lockMode,
2107
+ lockMode: this.#lockMode
2108
+ ? this.#lockModifier
2109
+ ? `${this.#lockMode} ${this.#lockModifier}`
2110
+ : this.#lockMode
2111
+ : null,
950
2112
  };
951
2113
  }
952
- /** Build SQL + params via the Rust query compiler. */
953
- toSQL() {
2114
+ /**
2115
+ * Native compiled `{ sql, params }` (Postgres `$N`) — the form atlas executes
2116
+ * and embeds inside parent queries. Public `toSQL()` normalizes to Knex `?`.
2117
+ */
2118
+ #compiledNative() {
954
2119
  const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
955
- return { sql: compiled.statements[0], params: compiled.params };
2120
+ const sql = this.#commentPrefix() + compiled.statements[0];
2121
+ return { sql, params: compiled.params };
2122
+ }
2123
+ /**
2124
+ * Build SQL via the Rust query compiler (Lucid `toSQL`). `.sql` uses `?`
2125
+ * placeholders (Knex-normalized, like Lucid); `.toNative()` yields the native
2126
+ * form. Returns `bindings` (Lucid's name) and `params` (atlas's) — same array.
2127
+ */
2128
+ toSQL() {
2129
+ const { sql, params } = this.#compiledNative();
2130
+ return compiledStatement(sql, params);
2131
+ }
2132
+ /** `{ sql, bindings }` — the compiled native query (Lucid/Knex `toNative`). */
2133
+ toNative() {
2134
+ const { sql, params } = this.#compiledNative();
2135
+ return { sql, bindings: params };
2136
+ }
2137
+ /** The model class this query targets (Adonis Lucid `query.model`). */
2138
+ get model() {
2139
+ return this.#entityClass;
2140
+ }
2141
+ /** Apply `cb` only on the given dialect(s) (Lucid `ifDialect`; Lucid names accepted). */
2142
+ ifDialect(dialect, cb) {
2143
+ const set = (Array.isArray(dialect) ? dialect : [dialect]).map(normalizeDialect);
2144
+ if (set.includes(this.#dialect))
2145
+ cb(this);
2146
+ return this;
2147
+ }
2148
+ /** Apply `cb` on every dialect EXCEPT the given one(s) (Lucid `unlessDialect`). */
2149
+ unlessDialect(dialect, cb) {
2150
+ const set = (Array.isArray(dialect) ? dialect : [dialect]).map(normalizeDialect);
2151
+ if (!set.includes(this.#dialect))
2152
+ cb(this);
2153
+ return this;
2154
+ }
2155
+ /** Prepend a `/* … *​/` SQL comment to the compiled query (Lucid/Knex `comment`). */
2156
+ comment(text) {
2157
+ if (text.includes("*/")) {
2158
+ throw new Error("comment() text may not contain '*/'");
2159
+ }
2160
+ this.#comments.push(text);
2161
+ return this;
2162
+ }
2163
+ /**
2164
+ * Set a caller-facing statement timeout in milliseconds (Lucid `timeout(ms)`).
2165
+ * The awaiting promise rejects after `ms` on the primary result fetch. With
2166
+ * `{ cancel: true }` a SERVER-side statement timeout is also applied — Postgres
2167
+ * `statement_timeout`, MySQL `MAX_EXECUTION_TIME` (SELECT) — so the server
2168
+ * aborts the query, not just the client. (SQLite has no server timeout; the
2169
+ * client race applies.) Called with no argument it clears the timeout.
2170
+ */
2171
+ timeout(ms, options) {
2172
+ // `{ cancel: true }` also applies a SERVER-side statement timeout (Postgres
2173
+ // statement_timeout / MySQL MAX_EXECUTION_TIME for SELECT); SQLite has none,
2174
+ // so the client race applies there.
2175
+ this.#timeoutMs = ms;
2176
+ this.#cancelTimeout = options?.cancel === true;
2177
+ return this;
2178
+ }
2179
+ /**
2180
+ * Race `work` against the configured `.timeout(ms)`. Rejects the awaiter after
2181
+ * `ms`; the losing DB promise is swallowed so a post-timeout driver error never
2182
+ * surfaces as an unhandled rejection. No timeout set → returns `work` as-is.
2183
+ */
2184
+ #raceTimeout(work) {
2185
+ const ms = this.#timeoutMs;
2186
+ if (!ms || ms <= 0)
2187
+ return work;
2188
+ let timer;
2189
+ const guard = new Promise((_, reject) => {
2190
+ timer = setTimeout(() => reject(new Error(`Query timed out after ${ms}ms`)), ms);
2191
+ });
2192
+ work.catch(() => { });
2193
+ return Promise.race([work, guard]).finally(() => clearTimeout(timer));
2194
+ }
2195
+ /** The `/* … *​/` prefix for the compiled SQL, or empty when no comments. */
2196
+ /** Compile the registered CTEs to the wire shape — shared by SELECT and DML. */
2197
+ #compiledCtes() {
2198
+ return this.#ctes.map((c) => {
2199
+ const { sql, params } = c.query.#compiledNative();
2200
+ return {
2201
+ name: c.name,
2202
+ sql,
2203
+ params,
2204
+ recursive: c.recursive ?? false,
2205
+ materialized: c.materialized ?? null,
2206
+ columns: c.columns ?? [],
2207
+ };
2208
+ });
2209
+ }
2210
+ #commentPrefix() {
2211
+ return this.#comments.length > 0
2212
+ ? `${this.#comments.map((c) => `/* ${c} */`).join(" ")} `
2213
+ : "";
956
2214
  }
957
2215
  /**
958
2216
  * Cached exec result. Memoizing the promise makes the builder a one-shot
@@ -976,8 +2234,8 @@ export class ModelQuery {
976
2234
  return results;
977
2235
  }
978
2236
  async #doExec() {
979
- const { sql, params } = this.toSQL();
980
- const rawRows = await this.#db.query(sql, params);
2237
+ const { sql, params } = this.#compiledNative();
2238
+ const rawRows = await this.#raceTimeout(this.#db.query(sql, params, this.#meta("exec")));
981
2239
  // Peel withCount / withAggregate alias columns off the raw row into $extras
982
2240
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
983
2241
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -992,14 +2250,48 @@ export class ModelQuery {
992
2250
  const entity = this.#hydrateFn(row);
993
2251
  for (const [k, v] of Object.entries(picked))
994
2252
  entity.setExtra(k, v);
2253
+ // Thread query-level sideloaded context onto each hydrated instance.
2254
+ if (this.#sideloaded)
2255
+ entity.$sideloaded = { ...this.#sideloaded };
995
2256
  return entity;
996
2257
  });
997
2258
  // Resolve preloads (eager loading)
998
2259
  if (this.#preloads.size > 0 && this.#entityClass && entities.length > 0) {
999
2260
  await this.#resolvePreloads(entities);
1000
2261
  }
2262
+ // rowTransformer callbacks run per instance AFTER preloads, before resolve
2263
+ // (Lucid), so a transformer can read the eager-loaded relations.
2264
+ if (this.#rowTransformers.length > 0) {
2265
+ for (const entity of entities) {
2266
+ for (const transform of this.#rowTransformers)
2267
+ transform.run(entity);
2268
+ }
2269
+ }
1001
2270
  return entities;
1002
2271
  }
2272
+ /**
2273
+ * Execute and return PLAIN row objects (raw snake_case DB columns), skipping
2274
+ * model hydration, `@column({ consume })`, dirty-tracking and preloads —
2275
+ * AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
2276
+ * instances aren't needed.
2277
+ */
2278
+ async pojo() {
2279
+ const { sql, params } = this.#compiledNative();
2280
+ return this.#db.query(sql, params);
2281
+ }
2282
+ /**
2283
+ * Thread arbitrary context onto every instance this query hydrates, exposed as
2284
+ * `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
2285
+ * user, so hooks/computed can read it. REPLACES the current sideloaded data by
2286
+ * default (Lucid); pass `merge = true` to merge instead. The data also
2287
+ * propagates to preloaded relation queries. Chainable.
2288
+ */
2289
+ sideload(values, merge = false) {
2290
+ this.#sideloaded = merge
2291
+ ? { ...this.#sideloaded, ...values }
2292
+ : { ...values };
2293
+ return this;
2294
+ }
1003
2295
  /** Resolve preloaded relations via batched subqueries (no N+1). */
1004
2296
  async #resolvePreloads(entities) {
1005
2297
  if (!this.#entityClass)
@@ -1013,39 +2305,104 @@ export class ModelQuery {
1013
2305
  if (!ctx)
1014
2306
  continue;
1015
2307
  const allRelated = await this.#resolveOneRelation(entities, relationName, relation.type, ctx);
2308
+ // Propagate the parent query's sideloaded context onto the preloaded
2309
+ // relation instances (Adonis Lucid). The related row's own sideloaded
2310
+ // data (if any) wins over the inherited context.
2311
+ if (this.#sideloaded) {
2312
+ for (const related of allRelated) {
2313
+ related.$sideloaded = { ...this.#sideloaded, ...related.$sideloaded };
2314
+ }
2315
+ }
1016
2316
  await this.#applyNestedPreloads(allRelated, ctx);
1017
2317
  }
1018
2318
  }
1019
2319
  /** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
1020
2320
  #buildPreloadContext(relation, relationName) {
1021
2321
  const relatedClass = relation.target();
1022
- const relatedMeta = getEntityMetadata(relatedClass);
1023
- if (!relatedMeta)
1024
- return null;
2322
+ // Boot the related model's metadata on demand (Lucid parity): a preload
2323
+ // must not silently no-op just because the related class hasn't been
2324
+ // touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
2325
+ // static table / naming strategy when the decorator hasn't run.
2326
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1025
2327
  // Resolve row keys against declared column metadata, NOT `in entity` —
1026
2328
  // entities using Adonis' `declare field: T` pattern have no own-properties
1027
2329
  // on a freshly constructed instance, so `key in entity` is always false and
1028
2330
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
1029
- const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
2331
+ // For m2m the pivot's otherKey references `relatedKey` (default the related
2332
+ // PK); the load must filter/index rows by THAT column, not always the PK.
2333
+ const relatedPkName = (relation.type === "manyToMany"
2334
+ ? relation.pivot?.relatedKey
2335
+ : undefined) ??
2336
+ getPrimaryKey(relatedClass) ??
2337
+ "id";
1030
2338
  const validColumns = new Set();
2339
+ // Reverse map (db column → property) so an explicit `@Column({ columnName })`
2340
+ // on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
2341
+ const byDbName = new Map();
2342
+ // Capture the related model's `@Column({ consume })` adapters + its date
2343
+ // columns so preloaded rows hydrate identically to a direct query — dates
2344
+ // become Chronos DateTime, decimal/etc adapters run. Without this, a
2345
+ // preloaded relation left column values raw (Lucid parity bug + a runtime
2346
+ // footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
2347
+ const consumes = new Map();
2348
+ let relatedPkDb = camelToSnake(relatedPkName);
1031
2349
  for (const col of getColumnMetadata(relatedClass)) {
2350
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
1032
2351
  validColumns.add(col.propertyKey);
1033
- validColumns.add(camelToSnake(col.propertyKey));
2352
+ validColumns.add(db);
2353
+ byDbName.set(db, col.propertyKey);
2354
+ if (col.consume)
2355
+ consumes.set(col.propertyKey, col.consume);
2356
+ // The related PK may be multi-word (postId→post_id) or columnName-mapped;
2357
+ // its DB column name is what the WHERE + row indexing must use.
2358
+ if (col.propertyKey === relatedPkName)
2359
+ relatedPkDb = db;
1034
2360
  }
1035
2361
  validColumns.add(relatedPkName);
1036
2362
  validColumns.add(camelToSnake(relatedPkName));
2363
+ const dateCols = getDateColumnConfig(relatedClass);
2364
+ const consumeValue = (prop, value, model) => {
2365
+ const c = consumes.get(prop);
2366
+ // Adonis Lucid signature: (value, attribute, model).
2367
+ if (c)
2368
+ return c(value, prop, model);
2369
+ if (dateCols[prop] && value != null)
2370
+ return dateTimeAtlasAdapter.consume(value);
2371
+ return value;
2372
+ };
2373
+ // A repository for the related model so preloaded instances are hydrated with
2374
+ // the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
2375
+ // not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
2376
+ // refresh()/fresh()/load()/related(). Without this a preloaded relation
2377
+ // looked $isNew/$isLocal/$dirty and a later save() over-updated it.
2378
+ const relatedRepo = new BaseRepository(relatedClass, this.#db, {
2379
+ dialect: this.#dialect,
2380
+ });
2381
+ // Propagate the domain-event bus so save()/create() from a preloaded relation
2382
+ // still dispatch events (a fresh repo has none by default).
2383
+ relatedRepo.onDomainEvents = this.#onDomainEvents;
1037
2384
  const hydrate = (row) => {
1038
2385
  const entity = new relatedClass();
1039
2386
  for (const [key, value] of Object.entries(row)) {
1040
2387
  const camelKey = snakeToCamel(key);
1041
- const targetKey = validColumns.has(camelKey)
1042
- ? camelKey
1043
- : validColumns.has(key)
1044
- ? key
1045
- : null;
2388
+ const targetKey = byDbName.get(key) ??
2389
+ (validColumns.has(camelKey)
2390
+ ? camelKey
2391
+ : validColumns.has(key)
2392
+ ? key
2393
+ : null);
1046
2394
  if (targetKey !== null)
1047
- entity.setProp(targetKey, value);
2395
+ entity.setProp(targetKey, consumeValue(targetKey, value, entity));
1048
2396
  }
2397
+ // Freeze the clean snapshot + mark persisted/from-DB, and back-reference
2398
+ // the related repo (mirrors BaseRepository.#hydrate).
2399
+ entity.markAsPersisted();
2400
+ entity.markAsFromDatabase();
2401
+ Object.defineProperty(entity, REPO_REF, {
2402
+ value: relatedRepo,
2403
+ enumerable: false,
2404
+ configurable: true,
2405
+ });
1049
2406
  return entity;
1050
2407
  };
1051
2408
  return {
@@ -1053,7 +2410,9 @@ export class ModelQuery {
1053
2410
  relationName,
1054
2411
  relatedClass,
1055
2412
  relatedTable: relatedMeta.tableName,
1056
- relatedPk: getPrimaryKey(relatedClass) ?? "id",
2413
+ // DB column name (not property) used as the WHERE column in the related
2414
+ // query AND to index the returned DB rows by their PK value.
2415
+ relatedPk: relatedPkDb,
1057
2416
  hydrate,
1058
2417
  runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
1059
2418
  runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
@@ -1087,15 +2446,17 @@ export class ModelQuery {
1087
2446
  throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
1088
2447
  }
1089
2448
  const throughClass = relation.through();
1090
- const throughMeta = getEntityMetadata(throughClass);
1091
- if (!throughMeta)
1092
- throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
2449
+ const throughMeta = ensureEntityMetadata(throughClass);
1093
2450
  const throughTable = throughMeta.tableName;
1094
2451
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1095
2452
  const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1096
2453
  const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1097
2454
  const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1098
- const secondLocal = relation.secondLocalKey ?? throughPk;
2455
+ // secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
2456
+ // DB column — resolve the through model's key (default: its PK), honouring a
2457
+ // multi-word / columnName PK. (parentLocal stays a property: it's read off
2458
+ // the parent ENTITY, not a row.)
2459
+ const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
1099
2460
  const parentIds = entities
1100
2461
  .map((e) => e[parentLocal])
1101
2462
  .filter((v) => v != null);
@@ -1224,7 +2585,10 @@ export class ModelQuery {
1224
2585
  // on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
1225
2586
  const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1226
2587
  const otherKey = pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
1227
- const pk = getPrimaryKey(this.#entityClass) ?? "id";
2588
+ // The pivot FK stores `parent[localKey]` (default PK) attach() writes it,
2589
+ // so preload MUST read back with the SAME key, else a custom-localKey m2m
2590
+ // writes `user_code = code` but reads `user_code IN (id)` and never matches.
2591
+ const pk = ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1228
2592
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1229
2593
  if (ids.length === 0)
1230
2594
  return [];
@@ -1233,11 +2597,42 @@ export class ModelQuery {
1233
2597
  // also runs (again) inside runRelationQuery against the related table; both
1234
2598
  // runs are pure builder mutations, and pivot constraints are inert there.
1235
2599
  const pivotWheres = [];
2600
+ // Query-time pivotColumns([...]) requested in the preload callback (replayed
2601
+ // on the scratch builder below) OR directly on this query (related().query()).
2602
+ const extraPivotCols = [...this.#extraPivotColumns];
1236
2603
  if (ctx.nestedCallback) {
1237
- const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, (c) => c, false, this.#dialect);
2604
+ const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
1238
2605
  ctx.nestedCallback(scratch);
1239
- for (const c of scratch.pivotConstraints)
1240
- pivotWheres.push({ ...c });
2606
+ extraPivotCols.push(...scratch.pivotColumnRequests);
2607
+ // Apply the pivot column adapters' `prepare` to wherePivot values, so a
2608
+ // filter like wherePivot('amount', new Money(1)) matches what attach()/
2609
+ // sync() stored (they prepare the same extras on write).
2610
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
2611
+ for (const c of scratch.pivotConstraints) {
2612
+ const prep = pivotAdapters[c.column]?.prepare;
2613
+ // Same guards as the attach()/sync() write path: wrap a throwing
2614
+ // adapter with a column-annotated error and reject async adapters,
2615
+ // so filter and write agree on the adapter contract.
2616
+ const apply = (v) => {
2617
+ if (!prep)
2618
+ return v;
2619
+ let out;
2620
+ try {
2621
+ // Adonis Lucid signature: (value, attribute, model). wherePivot is
2622
+ // a query filter — attribute known, no model instance.
2623
+ out = prep(v, c.column, undefined);
2624
+ }
2625
+ catch (err) {
2626
+ throw wrapAdapterError("prepare", c.column, err);
2627
+ }
2628
+ assertNotPromise("prepare", c.column, out);
2629
+ return out;
2630
+ };
2631
+ const value = Array.isArray(c.value)
2632
+ ? c.value.map(apply)
2633
+ : apply(c.value);
2634
+ pivotWheres.push({ ...c, value });
2635
+ }
1241
2636
  }
1242
2637
  // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
1243
2638
  const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
@@ -1251,7 +2646,11 @@ export class ModelQuery {
1251
2646
  ];
1252
2647
  // Step 2 — load all related entities in one query
1253
2648
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
1254
- const pivotCols = pivot.pivotColumns ?? [];
2649
+ // Declared pivot columns (decorator) + any requested at query time
2650
+ // (`pivotColumns([...])`, from the callback or this query), de-duplicated.
2651
+ const pivotCols = [
2652
+ ...new Set([...(pivot.pivotColumns ?? []), ...extraPivotCols]),
2653
+ ];
1255
2654
  const pivotAdapters = pivot.pivotColumnAdapters ?? {};
1256
2655
  // When pivot extras are projected, each (parent, related) edge gets its OWN
1257
2656
  // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
@@ -1282,7 +2681,9 @@ export class ModelQuery {
1282
2681
  for (const col of pivotCols) {
1283
2682
  const rawVal = pivotRow[col];
1284
2683
  const adapter = pivotAdapters[col];
1285
- related.setExtra(`pivot_${col}`, adapter?.consume ? adapter.consume(rawVal) : rawVal);
2684
+ related.setExtra(`pivot_${col}`,
2685
+ // Adonis Lucid signature: (value, attribute, model).
2686
+ adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal);
1286
2687
  }
1287
2688
  allRelated.push(related);
1288
2689
  }
@@ -1305,7 +2706,7 @@ export class ModelQuery {
1305
2706
  async #applyNestedPreloads(relatedEntities, ctx) {
1306
2707
  if (!ctx.nestedCallback || relatedEntities.length === 0)
1307
2708
  return;
1308
- const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass);
2709
+ const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass, buildColumnResolver(ctx.relatedClass), hasSoftDeletes(ctx.relatedClass), this.#dialect, buildValuePreparer(ctx.relatedClass));
1309
2710
  ctx.nestedCallback(sub);
1310
2711
  if (sub.#preloads.size > 0) {
1311
2712
  await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
@@ -1316,12 +2717,22 @@ export class ModelQuery {
1316
2717
  const wheres = [
1317
2718
  { column, operator: "IN", value: values, type: "and" },
1318
2719
  ];
1319
- for (const w of extraWheres) {
2720
+ // The caller's filters go in a parenthesised group, never flat beside the
2721
+ // `IN`. Flat, an `orWherePivot` would read as
2722
+ // `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
2723
+ // other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
2724
+ // With every filter ANDed the two forms are equivalent, so this changes
2725
+ // no existing query.
2726
+ if (extraWheres.length > 0) {
1320
2727
  wheres.push({
1321
- column: w.column,
1322
- operator: w.operator,
1323
- value: w.value,
2728
+ kind: "group",
1324
2729
  type: "and",
2730
+ conditions: extraWheres.map((w) => ({
2731
+ column: w.column,
2732
+ operator: w.operator,
2733
+ value: w.value,
2734
+ type: w.type ?? "and",
2735
+ })),
1325
2736
  });
1326
2737
  }
1327
2738
  const spec = {
@@ -1356,7 +2767,11 @@ export class ModelQuery {
1356
2767
  * inside the callback are re-collected later by `#applyNestedPreloads`.
1357
2768
  */
1358
2769
  async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
1359
- const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c,
2770
+ const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
2771
+ // Resolve columns + prepare values against the RELATED model so a preload
2772
+ // constraint (onQuery / callback) targeting a columnName-mapped or date
2773
+ // column compiles/binds like a direct query on that model.
2774
+ buildColumnResolver(relatedClass),
1360
2775
  // Propagate the RELATED entity's soft-delete flag — hardcoding
1361
2776
  // false here meant `preload('posts')` returned soft-deleted
1362
2777
  // posts even when Post is @SoftDeletes (a data leak). The
@@ -1364,15 +2779,58 @@ export class ModelQuery {
1364
2779
  // matching a direct query on that entity. (with-trashed on the
1365
2780
  // related set, if ever needed, would be opted-in via the
1366
2781
  // preload callback.)
1367
- hasSoftDeletes(relatedClass), this.#dialect);
2782
+ hasSoftDeletes(relatedClass), this.#dialect, buildValuePreparer(relatedClass));
1368
2783
  sub.whereIn(column, values);
1369
2784
  if (relation.onQuery)
1370
2785
  relation.onQuery(sub);
1371
2786
  if (userCallback)
1372
2787
  userCallback(sub);
1373
- const { sql, params } = sub.toSQL();
2788
+ // Top-N per parent (Lucid groupLimit) → a ROW_NUMBER() window over the fk.
2789
+ if (sub.#groupLimit != null) {
2790
+ return this.#runGroupLimited(sub, column, sub.#groupLimit);
2791
+ }
2792
+ const { sql, params } = sub.#compiledNative();
1374
2793
  return this.#db.query(sql, params);
1375
2794
  }
2795
+ /**
2796
+ * Run a has-many preload query capped to `n` rows PER PARENT (Lucid
2797
+ * `groupLimit`). Wraps the inner query with a `ROW_NUMBER() OVER (PARTITION BY
2798
+ * <fk> ORDER BY <groupOrderBy | pk>)` window, then keeps `rn <= n`. Supported on
2799
+ * Postgres, MySQL 8+, and SQLite 3.25+ (all have window functions).
2800
+ */
2801
+ async #runGroupLimited(sub, fkColumn, n) {
2802
+ const quoteSeg = (name) => {
2803
+ const qc = this.#dialect === "mysql" ? "`" : '"';
2804
+ return name
2805
+ .split(".")
2806
+ .map((s) => {
2807
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
2808
+ throw new Error(`groupLimit: unsafe identifier '${name}'`);
2809
+ }
2810
+ return `${qc}${s}${qc}`;
2811
+ })
2812
+ .join(".");
2813
+ };
2814
+ const orderCol = sub.#groupOrderBy
2815
+ ? sub.#resolveColumn(sub.#groupOrderBy.column)
2816
+ : (getPrimaryKey(sub.#entityClass) ?? "id");
2817
+ const dir = sub.#groupOrderBy?.direction === "desc" ? "DESC" : "ASC";
2818
+ sub.#selectRaw.push({
2819
+ sql: `ROW_NUMBER() OVER (PARTITION BY ${quoteSeg(fkColumn)} ORDER BY ${quoteSeg(orderCol)} ${dir}) AS __atlas_rn`,
2820
+ params: [],
2821
+ });
2822
+ const inner = sub.#compiledNative();
2823
+ const ph = this.#dialect === "postgres" ? `$${inner.params.length + 1}` : "?";
2824
+ const outerSql = `SELECT * FROM (${inner.sql}) AS __atlas_grp WHERE __atlas_rn <= ${ph}`;
2825
+ const rows = await this.#db.query(outerSql, [
2826
+ ...inner.params,
2827
+ n,
2828
+ ]);
2829
+ // Strip the window helper column so it doesn't leak into hydration.
2830
+ for (const row of rows)
2831
+ delete row.__atlas_rn;
2832
+ return rows;
2833
+ }
1376
2834
  /**
1377
2835
  * Build a correlated subquery over a relation. Returns `SubqueryProjection`
1378
2836
  * used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
@@ -1403,31 +2861,50 @@ export class ModelQuery {
1403
2861
  throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
1404
2862
  }
1405
2863
  const relatedClass = relation.target();
1406
- const relatedMeta = getEntityMetadata(relatedClass);
1407
- if (!relatedMeta) {
1408
- throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
1409
- }
2864
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1410
2865
  const relatedTable = relatedMeta.tableName;
1411
2866
  const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
1412
2867
  const parentTable = this.#tableName;
1413
- const q = this.#dialect === "mysql"
1414
- ? (name) => `\`${name}\``
1415
- : (name) => `"${name}"`;
1416
- const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c, false, this.#dialect);
2868
+ // Strict single-segment identifier quote. This builds a RAW correlated
2869
+ // subquery fragment (no bind params for identifiers), so every segment must
2870
+ // be validated — a table/key from relation metadata carrying a quote/backtick
2871
+ // would otherwise emit invalid or injectable SQL. Same policy as
2872
+ // BaseRepository's lazy m2m path.
2873
+ const q = (name) => {
2874
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
2875
+ throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
2876
+ }
2877
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
2878
+ };
2879
+ // Table identifiers may be schema-qualified (`schema.table`) — quote each
2880
+ // dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
2881
+ // `public.users_roles` gets wrapped as ONE identifier and silently targets a
2882
+ // table literally named with a dot. Each segment still passes the strict
2883
+ // guard above. Columns stay single-segment via `q`.
2884
+ const qTable = (name) => name.split(".").map(q).join(".");
2885
+ const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
2886
+ // whereHas/withCount constraints run against the RELATED model — resolve
2887
+ // its columns (columnName/multi-word) and prepare its values like a direct query.
2888
+ buildColumnResolver(relatedClass), false, this.#dialect, buildValuePreparer(relatedClass));
2889
+ // `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
2890
+ // PK); resolve each to its DB column via the owning model so a multi-word or
2891
+ // `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
2892
+ // `firstKey`/`secondKey` are DB column names already — left as-is.
2893
+ const resolveParent = buildColumnResolver(this.#entityClass);
1417
2894
  switch (relation.type) {
1418
2895
  case "hasOne":
1419
2896
  case "hasMany": {
1420
2897
  // Honour custom foreignKey/localKey exactly like the eager loader —
1421
2898
  // hard-coding them here produced silently-wrong whereHas/withCount SQL.
1422
2899
  const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1423
- const localKey = relation.localKey ?? parentPk;
1424
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`);
2900
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2901
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`);
1425
2902
  break;
1426
2903
  }
1427
2904
  case "belongsTo": {
1428
2905
  const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
1429
- const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1430
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(ownerKey)} = ${q(parentTable)}.${q(fk)}`);
2906
+ const ownerKey = buildColumnResolver(relatedClass)(relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id");
2907
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`);
1431
2908
  break;
1432
2909
  }
1433
2910
  case "manyToMany": {
@@ -1439,11 +2916,12 @@ export class ModelQuery {
1439
2916
  // name stripped of a trailing `s` — see the eager loader above.
1440
2917
  const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1441
2918
  const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1442
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1443
- const localKey = relation.localKey ?? parentPk;
1444
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} IN ` +
1445
- `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1446
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`);
2919
+ const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
2920
+ const relatedPk = getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPkProp)?.columnName ?? camelToSnake(relatedPkProp);
2921
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2922
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(relatedPk)} IN ` +
2923
+ `(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
2924
+ `WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`);
1447
2925
  break;
1448
2926
  }
1449
2927
  case "hasOneThrough":
@@ -1455,36 +2933,45 @@ export class ModelQuery {
1455
2933
  throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
1456
2934
  }
1457
2935
  const throughClass = relation.through();
1458
- const throughMeta = getEntityMetadata(throughClass);
1459
- if (!throughMeta) {
1460
- throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
1461
- }
2936
+ const throughMeta = ensureEntityMetadata(throughClass);
1462
2937
  const throughTable = throughMeta.tableName;
1463
2938
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1464
- const parentLocal = relation.localKey ?? parentPk;
2939
+ const parentLocal = resolveParent(relation.localKey ?? parentPk);
1465
2940
  const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1466
2941
  const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1467
- const secondLocal = relation.secondLocalKey ?? throughPk;
1468
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(secondKey)} IN ` +
1469
- `(SELECT ${q(secondLocal)} FROM ${q(throughTable)} ` +
1470
- `WHERE ${q(throughTable)}.${q(firstKey)} = ${q(parentTable)}.${q(parentLocal)})`);
2942
+ const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
2943
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(secondKey)} IN ` +
2944
+ `(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
2945
+ `WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`);
1471
2946
  break;
1472
2947
  }
1473
2948
  }
1474
2949
  return sub;
1475
2950
  }
1476
- innerJoin(table, leftOrBuild, right) {
1477
- return this.#pushJoin("INNER", table, leftOrBuild, right);
2951
+ join(table, leftOrBuild, operatorOrRight, right) {
2952
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
2953
+ }
2954
+ innerJoin(table, leftOrBuild, operatorOrRight, right) {
2955
+ return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
1478
2956
  }
1479
- leftJoin(table, leftOrBuild, right) {
1480
- return this.#pushJoin("LEFT", table, leftOrBuild, right);
2957
+ leftJoin(table, leftOrBuild, operatorOrRight, right) {
2958
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
1481
2959
  }
1482
- rightJoin(table, leftOrBuild, right) {
1483
- return this.#pushJoin("RIGHT", table, leftOrBuild, right);
2960
+ leftOuterJoin(table, leftOrBuild, operatorOrRight, right) {
2961
+ return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
2962
+ }
2963
+ rightJoin(table, leftOrBuild, operatorOrRight, right) {
2964
+ return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
2965
+ }
2966
+ rightOuterJoin(table, leftOrBuild, operatorOrRight, right) {
2967
+ return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
2968
+ }
2969
+ fullOuterJoin(table, leftOrBuild, operatorOrRight, right) {
2970
+ return this.#pushJoin("FULL OUTER", table, leftOrBuild, operatorOrRight, right);
1484
2971
  }
1485
2972
  crossJoin(table) {
1486
- const tq = this.#quote(table);
1487
- this.#joins.push(`CROSS JOIN ${tq}`);
2973
+ const tq = this.#quoteCol(table);
2974
+ this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
1488
2975
  return this;
1489
2976
  }
1490
2977
  /**
@@ -1503,12 +2990,12 @@ export class ModelQuery {
1503
2990
  *
1504
2991
  * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
1505
2992
  */
1506
- joinRaw(fragment) {
2993
+ joinRaw(fragment, bindings = []) {
1507
2994
  if (isAtlasStrictMode() && !isInternalBypass()) {
1508
2995
  throw new Error("joinRaw() is disabled in Atlas strict mode. " +
1509
2996
  "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
1510
2997
  }
1511
- this.#joins.push(fragment);
2998
+ this.#joins.push({ sql: fragment, params: [...bindings] });
1512
2999
  return this;
1513
3000
  }
1514
3001
  /**
@@ -1526,21 +3013,56 @@ export class ModelQuery {
1526
3013
  return this.innerJoin(table, left, right);
1527
3014
  }
1528
3015
  // === Story 29.5 — aggregates / exists / pluck =====================================================
1529
- distinct() {
3016
+ /**
3017
+ * SELECT DISTINCT (Lucid/Knex `distinct`). With columns, those replace the
3018
+ * projection too — `distinct('a', 'b')` ≈ `SELECT DISTINCT a, b`.
3019
+ */
3020
+ distinct(...columns) {
1530
3021
  this.#distinct = true;
3022
+ if (columns.length > 0) {
3023
+ this.#select = columns.map((c) => this.#resolveSelect(c));
3024
+ }
3025
+ return this;
3026
+ }
3027
+ /**
3028
+ * `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
3029
+ * `columns` (Lucid/Knex `distinctOn`). Takes precedence over
3030
+ * {@link distinct}.
3031
+ *
3032
+ * Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
3033
+ * would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
3034
+ * return a *different* result set rather than fail — a silent wrong answer
3035
+ * is worse than an error.
3036
+ *
3037
+ * Postgres also requires the leading `ORDER BY` terms to match `columns`;
3038
+ * that is left to the database to enforce.
3039
+ */
3040
+ distinctOn(...columns) {
3041
+ for (const c of columns)
3042
+ this.#distinctOn.push(this.#resolveColumn(c));
1531
3043
  return this;
1532
3044
  }
1533
3045
  /** `SELECT COUNT(DISTINCT col)`. */
1534
3046
  async countDistinct(column) {
1535
- return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0);
3047
+ return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`)) ?? 0);
3048
+ }
3049
+ /** `SUM(DISTINCT col)` (Lucid parity). */
3050
+ async sumDistinct(column) {
3051
+ const v = await this.#runScalar(`SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
3052
+ return v === null || v === undefined ? null : Number(v);
3053
+ }
3054
+ /** `AVG(DISTINCT col)` (Lucid parity). */
3055
+ async avgDistinct(column) {
3056
+ const v = await this.#runScalar(`AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
3057
+ return v === null || v === undefined ? null : Number(v);
1536
3058
  }
1537
3059
  /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
1538
3060
  async exists() {
1539
3061
  const clone = this.clone();
1540
3062
  clone.#select = ["1"];
1541
3063
  clone.#limit = 1;
1542
- const { sql, params } = clone.toSQL();
1543
- const rows = await this.#db.query(sql, params);
3064
+ const { sql, params } = clone.#compiledNative();
3065
+ const rows = await this.#db.query(sql, params, this.#meta("exists"));
1544
3066
  return rows.length > 0;
1545
3067
  }
1546
3068
  async doesntExist() {
@@ -1551,7 +3073,7 @@ export class ModelQuery {
1551
3073
  const col = this.#resolveColumn(column);
1552
3074
  const clone = this.clone();
1553
3075
  clone.#select = [col];
1554
- const { sql, params } = clone.toSQL();
3076
+ const { sql, params } = clone.#compiledNative();
1555
3077
  const rows = await this.#db.query(sql, params);
1556
3078
  return rows.map((row) => {
1557
3079
  const v = row[col];
@@ -1596,9 +3118,11 @@ export class ModelQuery {
1596
3118
  elseFn(this);
1597
3119
  return this;
1598
3120
  }
1599
- unless(condition, fn) {
3121
+ unless(condition, fn, elseFn) {
1600
3122
  if (!condition)
1601
3123
  fn(this);
3124
+ else if (elseFn)
3125
+ elseFn(this);
1602
3126
  return this;
1603
3127
  }
1604
3128
  // === Story 29.10 — pagination =====================================================================
@@ -1606,26 +3130,53 @@ export class ModelQuery {
1606
3130
  async paginate(page, perPage) {
1607
3131
  const p = Math.max(1, Math.floor(page));
1608
3132
  const pp = Math.max(1, Math.floor(perPage));
1609
- // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
1610
- // tenant scope) propagates into both the COUNT and the data fetch.
1611
- await fireHooks(this.#entityClass, "beforePaginate", this);
1612
- // Parallel COUNT(*) + data fetch
3133
+ // Adonis Lucid hook order:
3134
+ // beforePaginate([countQuery, query]) beforeFetch(query)
3135
+ // → (count + data queries) → afterPaginate(paginator) → afterFetch(rows)
3136
+ // The COUNT and data builders are separate clones so a beforePaginate hook
3137
+ // can constrain BOTH (keeping totals in sync), exactly as documented.
1613
3138
  const countQ = this.clone();
1614
- countQ.#select = ["COUNT(*) AS count"];
3139
+ const dataQ = this.clone();
3140
+ await fireHooks(this.#entityClass, "beforePaginate", [countQ, dataQ]);
3141
+ // beforeFetch fires on the main (data) query before either query runs.
3142
+ await fireHooks(this.#entityClass, "beforeFetch", dataQ);
3143
+ // COUNT(*) — strip pagination/order noise from the count clone.
1615
3144
  countQ.#limit = undefined;
1616
3145
  countQ.#offset = undefined;
1617
3146
  countQ.#orderBys = [];
1618
- const { sql: cSql, params: cParams } = countQ.toSQL();
1619
- const cRows = await this.#db.query(cSql, cParams);
3147
+ let cSql;
3148
+ let cParams;
3149
+ if (countQ.#groupBy.length > 0) {
3150
+ // A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
3151
+ // group's own size), so `rows[0].count` would be the first group's size, not
3152
+ // the number of pages. Lucid counts via a subquery: wrap the grouped query
3153
+ // (select + groupBy + having preserved) and count its rows = group count.
3154
+ const inner = countQ.#compiledNative();
3155
+ cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
3156
+ cParams = inner.params;
3157
+ }
3158
+ else {
3159
+ countQ.#select = ["COUNT(*) AS count"];
3160
+ const flat = countQ.#compiledNative();
3161
+ cSql = flat.sql;
3162
+ cParams = flat.params;
3163
+ }
3164
+ const cRows = await this.#db.query(cSql, cParams, this.#meta("paginate"));
1620
3165
  const total = Number(cRows[0]?.count ?? 0);
1621
- const dataQ = this.clone();
1622
3166
  dataQ.#limit = pp;
1623
3167
  dataQ.#offset = (p - 1) * pp;
1624
- // `#doExec` (not `exec`) so the generic beforeFetch/afterFetch don't fire on
1625
- // top of the paginate hooks paginate is its own terminal.
3168
+ // `#doExec` runs the raw fetch + preloads. beforeFetch already fired above;
3169
+ // afterFetch fires AFTER afterPaginate (Lucid order), so #doExec must not
3170
+ // fire either itself — hence #doExec, not exec().
1626
3171
  const items = await dataQ.#doExec();
1627
- await fireHooks(this.#entityClass, "afterPaginate", items);
1628
- return new Paginator(items, { total, perPage: pp, currentPage: p });
3172
+ const metaKeys = this.#entityClass
3173
+ ? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
3174
+ : undefined;
3175
+ const paginator = new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
3176
+ // Lucid: afterPaginate(paginator) then afterFetch(rows).
3177
+ await fireHooks(this.#entityClass, "afterPaginate", paginator);
3178
+ await fireHooks(this.#entityClass, "afterFetch", items);
3179
+ return paginator;
1629
3180
  }
1630
3181
  /**
1631
3182
  * Cursor-based pagination — base64 opaque keyset, multi-column aware.
@@ -1641,7 +3192,13 @@ export class ModelQuery {
1641
3192
  * accepts row-value comparisons.
1642
3193
  */
1643
3194
  async cursorPaginate(opts) {
1644
- const cols = (Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]).map((c) => this.#resolveColumn(c));
3195
+ // Keep BOTH forms: `props` (model property names) to read the cursor value
3196
+ // off the hydrated entity, and `cols` (resolved DB columns) for the SQL
3197
+ // ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
3198
+ // encode `undefined` into the cursor (entity exposes the property, not the
3199
+ // DB column) — an unstable / stuck cursor.
3200
+ const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
3201
+ const cols = props.map((c) => this.#resolveColumn(c));
1645
3202
  if (cols.length === 0)
1646
3203
  throw new Error("cursorPaginate requires at least one orderBy column");
1647
3204
  const lim = Math.max(1, Math.floor(opts.limit));
@@ -1685,7 +3242,7 @@ export class ModelQuery {
1685
3242
  const items = hasMore ? rows.slice(0, lim) : rows;
1686
3243
  const last = items[items.length - 1];
1687
3244
  const nextCursor = hasMore && last
1688
- ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString("base64")
3245
+ ? Buffer.from(JSON.stringify({ v: props.map((p) => last[p]) })).toString("base64")
1689
3246
  : null;
1690
3247
  return { items, nextCursor, hasMore };
1691
3248
  }
@@ -1702,75 +3259,178 @@ export class ModelQuery {
1702
3259
  this.#debugFlag = flag;
1703
3260
  return this;
1704
3261
  }
3262
+ /**
3263
+ * Attach arbitrary metadata to the `db:query` event this query emits (Adonis
3264
+ * Lucid `reporterData`) — request id, user id, feature flag, … A listener
3265
+ * reads it off `event.reporterData`. Repeated calls merge. Setting it also
3266
+ * forces emission (like {@link debug}), so the data actually reaches a listener.
3267
+ */
3268
+ reporterData(data) {
3269
+ this.#reporterData = { ...this.#reporterData, ...data };
3270
+ this.#debugFlag = true;
3271
+ return this;
3272
+ }
3273
+ /**
3274
+ * Context attached to each statement this query runs, so a `db:query`
3275
+ * listener can say which model and which call produced it — and so
3276
+ * {@link debug} can force emission for this query alone.
3277
+ *
3278
+ * Note the connection's own `debug: true` emits every statement regardless;
3279
+ * `meta` only enriches the event and opens the per-query override.
3280
+ */
3281
+ #meta(method) {
3282
+ return {
3283
+ model: this.#entityClass.name,
3284
+ method,
3285
+ debug: this.#debugFlag,
3286
+ reporterData: this.#reporterData,
3287
+ serverTimeoutMs: this.#cancelTimeout && this.#timeoutMs != null
3288
+ ? this.#timeoutMs
3289
+ : undefined,
3290
+ };
3291
+ }
1705
3292
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
1706
3293
  toQuery() {
1707
- const { sql, params } = this.toSQL();
1708
- let i = 0;
1709
- return sql.replace(/\?|\$\d+/g, () => {
1710
- const v = params[i++];
1711
- return this.#literalEscape(v);
1712
- });
3294
+ const { sql, params } = this.#compiledNative();
3295
+ return interpolateQuery(sql, params);
1713
3296
  }
1714
3297
  /** Deep clone of this query — mutations on the clone never affect the original. */
1715
3298
  clone() {
1716
- const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect);
3299
+ const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
1717
3300
  c.#softScope = this.#softScope;
1718
3301
  c.#wheres = structuredCloneSafe(this.#wheres);
1719
3302
  c.#orderBys = [...this.#orderBys];
1720
3303
  c.#select = [...this.#select];
1721
3304
  c.#limit = this.#limit;
3305
+ c.#groupLimit = this.#groupLimit;
3306
+ c.#groupOrderBy = this.#groupOrderBy
3307
+ ? { ...this.#groupOrderBy }
3308
+ : undefined;
1722
3309
  c.#offset = this.#offset;
1723
3310
  c.#preloads = new Map(this.#preloads);
3311
+ c.#rowTransformers = [...this.#rowTransformers];
1724
3312
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
1725
- c.#joins = [...this.#joins];
3313
+ c.#selectRaw = this.#selectRaw.map((s) => ({
3314
+ ...s,
3315
+ params: [...s.params],
3316
+ }));
3317
+ c.#timeoutMs = this.#timeoutMs;
3318
+ c.#cancelTimeout = this.#cancelTimeout;
3319
+ c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
1726
3320
  c.#lockMode = this.#lockMode;
3321
+ c.#lockModifier = this.#lockModifier;
3322
+ c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
1727
3323
  c.#distinct = this.#distinct;
3324
+ c.#distinctOn = [...this.#distinctOn];
1728
3325
  c.#groupBy = [...this.#groupBy];
1729
3326
  c.#having = structuredCloneSafe(this.#having);
1730
- c.#ctes = this.#ctes.map((e) => ({ name: e.name, query: e.query.clone() }));
3327
+ c.#ctes = this.#ctes.map((e) => ({
3328
+ name: e.name,
3329
+ query: e.query.clone(),
3330
+ recursive: e.recursive,
3331
+ materialized: e.materialized,
3332
+ columns: e.columns ? [...e.columns] : undefined,
3333
+ }));
1731
3334
  c.#unions = this.#unions.map((u) => ({
1732
3335
  query: u.query.clone(),
1733
3336
  all: u.all,
3337
+ op: u.op,
1734
3338
  }));
1735
3339
  c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
3340
+ c.#extraPivotColumns = [...this.#extraPivotColumns];
3341
+ // Pure closure over pivot metadata — safe to share by reference; it reads the
3342
+ // clone's own #pivotWheres at build time (passed in), holding no query state.
3343
+ c.#pivotExists = this.#pivotExists;
1736
3344
  c.#debugFlag = this.#debugFlag;
3345
+ c.#reporterData = this.#reporterData
3346
+ ? { ...this.#reporterData }
3347
+ : undefined;
3348
+ c.#comments = [...this.#comments];
1737
3349
  return c;
1738
3350
  }
1739
- // === Story 30.2 — update / delete fluent ===========================================================
1740
- /** Execute a fluent UPDATE. Returns affected rows (or rows when `returning` is set). */
1741
- async update(patch, returning) {
3351
+ update(patchOrColumn, valueOrReturning) {
3352
+ const patch = typeof patchOrColumn === "string"
3353
+ ? { [patchOrColumn]: valueOrReturning }
3354
+ : patchOrColumn;
3355
+ const returning = typeof patchOrColumn === "string"
3356
+ ? undefined
3357
+ : valueOrReturning;
1742
3358
  if (!patch || Object.keys(patch).length === 0) {
1743
3359
  throw new Error("update() requires a non-empty payload");
1744
3360
  }
1745
- const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), v]);
1746
- const spec = {
3361
+ // Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
3362
+ // like BaseRepository's write paths — the fluent update() must not bypass it.
3363
+ // A `db.raw(...)` value is emitted as a raw SET expression instead.
3364
+ const setPairs = Object.entries(patch).map(([k, v]) => v instanceof RawSql
3365
+ ? [this.#resolveColumn(k), { raw: v.sql, rawParams: [...v.params] }]
3366
+ : [this.#resolveColumn(k), this.#prepareValue(k, v)]);
3367
+ return this.#makeDml((r) => ({
1747
3368
  kind: "update",
1748
3369
  table: this.#tableName,
1749
3370
  set: setPairs,
1750
3371
  wheres: this.#wheresForDml(),
1751
- returning: returning ?? [],
1752
- };
1753
- const compiled = compileStatementNative(spec, this.#dialect);
1754
- if (returning && returning.length > 0) {
1755
- return this.#db.query(compiled.statements[0], compiled.params);
1756
- }
1757
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
1758
- return r.rowsAffected ?? 0;
3372
+ returning: r,
3373
+ ctes: this.#compiledCtes(),
3374
+ }), returning);
1759
3375
  }
1760
- /** Execute a fluent DELETE. Returns affected rows (or rows when `returning` is set). */
1761
- async delete(returning) {
1762
- const spec = {
3376
+ /**
3377
+ * Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
3378
+ * scoped rows (stamps `deleted_at`) — consistent with the entity-level
3379
+ * `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
3380
+ * model it issues a hard `DELETE`. Returns affected rows (or rows when
3381
+ * `returning` is set).
3382
+ */
3383
+ delete(returning) {
3384
+ if (this.#softDeletes) {
3385
+ const stampedAt = new Date().toISOString();
3386
+ return this.#makeDml((r) => ({
3387
+ kind: "update",
3388
+ table: this.#tableName,
3389
+ set: [[this.#deletedAtColumn(), stampedAt]],
3390
+ wheres: this.#wheresForDml(),
3391
+ returning: r,
3392
+ ctes: this.#compiledCtes(),
3393
+ }), returning);
3394
+ }
3395
+ return this.forceDelete(returning);
3396
+ }
3397
+ /** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
3398
+ forceDelete(returning) {
3399
+ return this.#makeDml((r) => ({
1763
3400
  kind: "delete",
1764
3401
  table: this.#tableName,
1765
3402
  wheres: this.#wheresForDml(),
1766
- returning: returning ?? [],
1767
- };
1768
- const compiled = compileStatementNative(spec, this.#dialect);
1769
- if (returning && returning.length > 0) {
1770
- return this.#db.query(compiled.statements[0], compiled.params);
3403
+ returning: r,
3404
+ ctes: this.#compiledCtes(),
3405
+ }), returning);
3406
+ }
3407
+ /**
3408
+ * Bulk restore: clear `deleted_at` on the trashed rows matching the user's
3409
+ * predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
3410
+ * on a non-soft-delete model. Independent of the current soft-scope — it always
3411
+ * targets trashed rows (`deleted_at IS NOT NULL`).
3412
+ */
3413
+ restore(returning) {
3414
+ if (!this.#softDeletes) {
3415
+ return new DmlBuilder(() => Promise.resolve(0), () => ({ sql: "", bindings: [], params: [] }), this.#dmlHooks());
1771
3416
  }
1772
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
1773
- return r.rowsAffected ?? 0;
3417
+ return this.#makeDml((r) => {
3418
+ const wheres = this.#userWheresForDml();
3419
+ wheres.push({
3420
+ column: this.#deletedAtColumn(),
3421
+ operator: "IS NOT NULL",
3422
+ value: null,
3423
+ type: "and",
3424
+ });
3425
+ return {
3426
+ kind: "update",
3427
+ table: this.#tableName,
3428
+ set: [[this.#deletedAtColumn(), null]],
3429
+ wheres,
3430
+ returning: r,
3431
+ ctes: this.#compiledCtes(),
3432
+ };
3433
+ }, returning);
1774
3434
  }
1775
3435
  increment(colOrPatch, amount = 1) {
1776
3436
  return this.#runIncDec("increment", colOrPatch, amount);
@@ -1797,57 +3457,237 @@ export class ModelQuery {
1797
3457
  }
1798
3458
  return this;
1799
3459
  }
3460
+ /** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
3461
+ forNoKeyUpdate() {
3462
+ if (this.#dialect === "postgres") {
3463
+ this.#lockMode = "FOR NO KEY UPDATE";
3464
+ }
3465
+ else {
3466
+ console.warn(`[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`);
3467
+ }
3468
+ return this;
3469
+ }
3470
+ /** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
3471
+ forKeyShare() {
3472
+ if (this.#dialect === "postgres") {
3473
+ this.#lockMode = "FOR KEY SHARE";
3474
+ }
3475
+ else {
3476
+ console.warn(`[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`);
3477
+ }
3478
+ return this;
3479
+ }
3480
+ /**
3481
+ * Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
3482
+ * waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
3483
+ */
3484
+ skipLocked() {
3485
+ if (this.#dialect === "sqlite") {
3486
+ console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
3487
+ }
3488
+ else {
3489
+ this.#lockModifier = "SKIP LOCKED";
3490
+ }
3491
+ return this;
3492
+ }
3493
+ /**
3494
+ * Append `NOWAIT` to the lock clause — error immediately instead of waiting on
3495
+ * a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
3496
+ */
3497
+ noWait() {
3498
+ if (this.#dialect === "sqlite") {
3499
+ console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
3500
+ }
3501
+ else {
3502
+ this.#lockModifier = "NOWAIT";
3503
+ }
3504
+ return this;
3505
+ }
1800
3506
  // === Private helpers ==============================================================================
1801
3507
  #quote(name) {
1802
3508
  return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1803
3509
  }
1804
3510
  /** Quote a `table.column` reference on both sides of the dot. */
1805
3511
  #quoteCol(ref) {
1806
- if (ref.includes(".")) {
1807
- const [t, c] = ref.split(".", 2);
1808
- return `${this.#quote(t)}.${this.#quote(c)}`;
1809
- }
1810
- return this.#quote(ref);
1811
- }
1812
- #pushJoin(kind, table, leftOrBuild, right) {
1813
- const tq = this.#quote(table);
3512
+ // Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
3513
+ // identifier smuggling a `"`/backtick would break out of the quoting on the
3514
+ // join path (which the Rust screen doesn't re-validate). Strict
3515
+ // `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
3516
+ // helpers injection-safe. Use joinRaw() for anything more complex.
3517
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
3518
+ throw new Error(`Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`);
3519
+ }
3520
+ return ref
3521
+ .split(".")
3522
+ .map((seg) => this.#quote(seg))
3523
+ .join(".");
3524
+ }
3525
+ #pushJoin(kind, table, leftOrBuild, operatorOrRight, right) {
3526
+ const tq = this.#quoteCol(table);
1814
3527
  if (typeof leftOrBuild === "function") {
3528
+ const parts = [];
1815
3529
  const jb = {
1816
- parts: [],
1817
- on(l, r) {
1818
- this.parts.push({ kind: "and", left: l, right: r });
1819
- return this;
3530
+ parts,
3531
+ on: (l, opOrR, r) => {
3532
+ parts.push(r === undefined
3533
+ ? { kind: "and", left: l, right: opOrR }
3534
+ : { kind: "and", left: l, operator: opOrR, right: r });
3535
+ return jb;
3536
+ },
3537
+ andOn: (l, opOrR, r) => {
3538
+ parts.push(r === undefined
3539
+ ? { kind: "and", left: l, right: opOrR }
3540
+ : { kind: "and", left: l, operator: opOrR, right: r });
3541
+ return jb;
3542
+ },
3543
+ orOn: (l, opOrR, r) => {
3544
+ parts.push(r === undefined
3545
+ ? { kind: "or", left: l, right: opOrR }
3546
+ : { kind: "or", left: l, operator: opOrR, right: r });
3547
+ return jb;
3548
+ },
3549
+ onVal: (l, v) => {
3550
+ parts.push({ kind: "and", left: l, value: { v } });
3551
+ return jb;
3552
+ },
3553
+ andOnVal: (l, v) => {
3554
+ parts.push({ kind: "and", left: l, value: { v } });
3555
+ return jb;
3556
+ },
3557
+ orOnVal: (l, v) => {
3558
+ parts.push({ kind: "or", left: l, value: { v } });
3559
+ return jb;
3560
+ },
3561
+ onIn: (l, values) => {
3562
+ parts.push({ kind: "and", left: l, values: [...values] });
3563
+ return jb;
1820
3564
  },
1821
- andOn(l, r) {
1822
- this.parts.push({ kind: "and", left: l, right: r });
1823
- return this;
3565
+ onNotIn: (l, values) => {
3566
+ parts.push({
3567
+ kind: "and",
3568
+ left: l,
3569
+ values: [...values],
3570
+ notIn: true,
3571
+ });
3572
+ return jb;
3573
+ },
3574
+ onNull: (l) => {
3575
+ parts.push({ kind: "and", left: l, nullOp: "IS NULL" });
3576
+ return jb;
3577
+ },
3578
+ onNotNull: (l) => {
3579
+ parts.push({ kind: "and", left: l, nullOp: "IS NOT NULL" });
3580
+ return jb;
3581
+ },
3582
+ onBetween: (l, range) => {
3583
+ parts.push({ kind: "and", left: l, between: [range[0], range[1]] });
3584
+ return jb;
1824
3585
  },
1825
- andOnVal(l, _v) {
1826
- this.parts.push({ kind: "and", left: l, right: "?" });
1827
- return this;
3586
+ onNotBetween: (l, range) => {
3587
+ parts.push({
3588
+ kind: "and",
3589
+ left: l,
3590
+ between: [range[0], range[1]],
3591
+ notBetween: true,
3592
+ });
3593
+ return jb;
3594
+ },
3595
+ onExists: (sub) => {
3596
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
3597
+ parts.push({ kind: "and", exists: { sql, params, not: false } });
3598
+ return jb;
3599
+ },
3600
+ onNotExists: (sub) => {
3601
+ const { sql, params } = this.#resolveUnion(sub).#compiledNative();
3602
+ parts.push({ kind: "and", exists: { sql, params, not: true } });
3603
+ return jb;
1828
3604
  },
1829
3605
  };
1830
3606
  leftOrBuild(jb);
1831
- const on = jb.parts
3607
+ // A BASE-table column runs the full model prepare (DateTime→ISO + @Column
3608
+ // adapters/casts), keyed by its property. A FOREIGN join column must NOT
3609
+ // borrow the root model's adapter for a same-named column on another
3610
+ // table — apply only universal type-lowering, matching Knex's
3611
+ // model-agnostic join binding.
3612
+ const prep = (col, v) => {
3613
+ const dot = col.lastIndexOf(".");
3614
+ const tablePrefix = dot >= 0 ? col.slice(0, dot) : "";
3615
+ const leaf = dot >= 0 ? col.slice(dot + 1) : col;
3616
+ const isBaseColumn = tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
3617
+ return isBaseColumn ? this.#prepareValue(leaf, v) : lowerJoinValue(v);
3618
+ };
3619
+ const params = [];
3620
+ const on = parts
1832
3621
  .map((p, i) => {
1833
3622
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
1834
- return `${prefix} ${this.#quoteCol(p.left)} = ${p.right === "?" ? "?" : this.#quoteCol(p.right)}`;
3623
+ if (p.exists) {
3624
+ params.push(...p.exists.params);
3625
+ return `${prefix} ${p.exists.not ? "NOT EXISTS" : "EXISTS"} (${p.exists.sql})`;
3626
+ }
3627
+ const col = this.#quoteCol(p.left ?? "");
3628
+ if (p.nullOp) {
3629
+ return `${prefix} ${col} ${p.nullOp}`;
3630
+ }
3631
+ if (p.between) {
3632
+ params.push(prep(p.left ?? "", p.between[0]), prep(p.left ?? "", p.between[1]));
3633
+ return `${prefix} ${col} ${p.notBetween ? "NOT BETWEEN" : "BETWEEN"} ? AND ?`;
3634
+ }
3635
+ if (p.values) {
3636
+ const placeholders = p.values.map(() => "?").join(", ");
3637
+ for (const v of p.values)
3638
+ params.push(prep(p.left ?? "", v));
3639
+ return `${prefix} ${col} ${p.notIn ? "NOT IN" : "IN"} (${placeholders})`;
3640
+ }
3641
+ if (p.value) {
3642
+ params.push(prep(p.left ?? "", p.value.v));
3643
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ?`;
3644
+ }
3645
+ return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ${this.#quoteCol(p.right ?? "")}`;
1835
3646
  })
1836
3647
  .join(" ");
1837
- this.#joins.push(`${kind} JOIN ${tq} ${on}`);
3648
+ this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
1838
3649
  return this;
1839
3650
  }
1840
- if (right === undefined)
3651
+ // String form: 3-arg `(left, right)` or 4-arg `(left, operator, right)`.
3652
+ const left = leftOrBuild;
3653
+ const operator = right === undefined ? "=" : (operatorOrRight ?? "=");
3654
+ const rightCol = right === undefined ? operatorOrRight : right;
3655
+ if (rightCol === undefined)
1841
3656
  throw new Error("join() with string form requires both left and right operands");
1842
- this.#joins.push(`${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`);
3657
+ this.#joins.push({
3658
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(left)} ${this.#validateJoinOp(operator)} ${this.#quoteCol(rightCol)}`,
3659
+ params: [],
3660
+ });
1843
3661
  return this;
1844
3662
  }
3663
+ /** Allowlist the comparison operator embedded verbatim into a JOIN's ON SQL. */
3664
+ #validateJoinOp(op) {
3665
+ const t = op.trim();
3666
+ const up = t.toUpperCase();
3667
+ const allowed = new Set([
3668
+ "=",
3669
+ "<>",
3670
+ "!=",
3671
+ "<",
3672
+ "<=",
3673
+ ">",
3674
+ ">=",
3675
+ "LIKE",
3676
+ "NOT LIKE",
3677
+ "ILIKE",
3678
+ ]);
3679
+ if (allowed.has(t))
3680
+ return t;
3681
+ if (allowed.has(up))
3682
+ return up;
3683
+ throw new Error(`Unsupported join operator '${op}'.`);
3684
+ }
1845
3685
  async #runScalar(expr) {
1846
3686
  const clone = this.clone();
1847
3687
  clone.#select = [`${expr} AS __scalar__`];
1848
3688
  clone.#orderBys = [];
1849
- const { sql, params } = clone.toSQL();
1850
- const rows = await this.#db.query(sql, params);
3689
+ const { sql, params } = clone.#compiledNative();
3690
+ const rows = await this.#raceTimeout(this.#db.query(sql, params));
1851
3691
  const row = rows[0];
1852
3692
  return row ? row.__scalar__ : null;
1853
3693
  }
@@ -1860,42 +3700,101 @@ export class ModelQuery {
1860
3700
  set: setPairs,
1861
3701
  wheres: this.#wheresForDml(),
1862
3702
  returning: [],
3703
+ ctes: this.#compiledCtes(),
1863
3704
  };
1864
3705
  const compiled = compileStatementNative(spec, this.#dialect);
1865
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
3706
+ const r = await this.#raceTimeout(this.#db.execute(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
1866
3707
  return r.rowsAffected ?? 0;
1867
3708
  }
1868
3709
  /**
1869
- * Flatten the SELECT wheres to DML-compatible wheres. Standard predicates
1870
- * and `whereRaw` fragments pass through; `group` / `exists` / `inSub` are
1871
- * still rejected because the DML compiler's WHERE lowering does not yet
1872
- * handle nested sub-queries or correlated EXISTS.
3710
+ * The user's own WHERE predicates for DML (no soft-delete scope). The DML
3711
+ * compiler now REUSES the SELECT compiler's WHERE lowering, so every predicate
3712
+ * the read builder accepts standard, `whereRaw`, `whereExists`, sub-queries,
3713
+ * and nested groups is valid for update/delete too. Passed through unchanged
3714
+ * (same as `#buildSpec` does for reads).
1873
3715
  */
3716
+ #userWheresForDml() {
3717
+ return [...this.#wheres];
3718
+ }
1874
3719
  #wheresForDml() {
1875
- const out = [];
1876
- for (const w of this.#wheres) {
1877
- if ("kind" in w) {
1878
- if (w.kind === "raw") {
1879
- out.push({
1880
- kind: "raw",
1881
- sql: w.sql,
1882
- bindings: w.bindings,
1883
- type: w.type,
1884
- });
1885
- continue;
1886
- }
1887
- throw new Error(`update/delete do not support '${w.kind}' WHERE clauses. ` +
1888
- `Supported: plain predicates and whereRaw. Use a raw UPDATE/DELETE for complex criteria.`);
3720
+ const out = this.#userWheresForDml();
3721
+ // Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
3722
+ // update/delete/increment/decrement must NOT touch trashed rows under the
3723
+ // default scope — otherwise `query().where(x)` would denote a different row
3724
+ // set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
3725
+ // `.onlyTrashed()` restricts to trashed (mirrors reads).
3726
+ if (this.#softDeletes) {
3727
+ const deletedAtCol = this.#deletedAtColumn();
3728
+ if (this.#softScope === "default") {
3729
+ out.push({
3730
+ column: deletedAtCol,
3731
+ operator: "IS NULL",
3732
+ value: null,
3733
+ type: "and",
3734
+ });
3735
+ }
3736
+ else if (this.#softScope === "only-trashed") {
3737
+ out.push({
3738
+ column: deletedAtCol,
3739
+ operator: "IS NOT NULL",
3740
+ value: null,
3741
+ type: "and",
3742
+ });
1889
3743
  }
1890
- out.push({
1891
- column: w.column,
1892
- operator: w.operator,
1893
- value: w.value,
1894
- type: w.type,
1895
- });
1896
3744
  }
1897
3745
  return out;
1898
3746
  }
3747
+ /** Compile a DML spec with the comment prefix — for a lazy builder's `.toSQL()`. */
3748
+ #compileDmlSpec(spec) {
3749
+ const compiled = compileStatementNative(spec, this.#dialect);
3750
+ const sql = this.#commentPrefix() + compiled.statements[0];
3751
+ return { sql, bindings: compiled.params, params: compiled.params };
3752
+ }
3753
+ /** Hooks a lazy DML builder delegates back to (upsert clauses are N/A on a model query). */
3754
+ #dmlHooks() {
3755
+ return {
3756
+ onConflict: () => { },
3757
+ merge: () => { },
3758
+ ignore: () => { },
3759
+ returning: (...cols) => {
3760
+ for (const c of cols) {
3761
+ if (Array.isArray(c))
3762
+ this.#dmlReturning.push(...c);
3763
+ else
3764
+ this.#dmlReturning.push(c);
3765
+ }
3766
+ },
3767
+ timeout: (ms) => {
3768
+ this.#timeoutMs = ms;
3769
+ },
3770
+ comment: (t) => {
3771
+ this.comment(t);
3772
+ },
3773
+ debug: (enabled) => {
3774
+ this.debug(enabled);
3775
+ },
3776
+ reporterData: (data) => {
3777
+ this.reporterData(data);
3778
+ },
3779
+ };
3780
+ }
3781
+ /** Wrap a DML spec-builder as a lazy, chainable, inspectable {@link DmlBuilder}. */
3782
+ #makeDml(build, paramReturning) {
3783
+ const resolved = () => [...(paramReturning ?? []), ...this.#dmlReturning].map((c) => this.#resolveSelect(c));
3784
+ return new DmlBuilder(() => {
3785
+ const r = resolved();
3786
+ return this.#runDml(build(r), r);
3787
+ }, () => this.#compileDmlSpec(build(resolved())), this.#dmlHooks());
3788
+ }
3789
+ /** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
3790
+ async #runDml(spec, returning) {
3791
+ const compiled = compileStatementNative(spec, this.#dialect);
3792
+ if (returning && returning.length > 0) {
3793
+ return this.#raceTimeout(this.#db.query(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
3794
+ }
3795
+ const r = await this.#raceTimeout(this.#db.execute(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
3796
+ return r.rowsAffected ?? 0;
3797
+ }
1899
3798
  /**
1900
3799
  * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
1901
3800
  *
@@ -1909,18 +3808,6 @@ export class ModelQuery {
1909
3808
  * parameters via the Rust compiler — this escaper is never on the hot path.
1910
3809
  * If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
1911
3810
  */
1912
- #literalEscape(v) {
1913
- if (v === null || v === undefined)
1914
- return "NULL";
1915
- if (typeof v === "number")
1916
- return String(v);
1917
- if (typeof v === "boolean")
1918
- return v ? "1" : "0";
1919
- if (v instanceof Date)
1920
- return `'${v.toISOString()}'`;
1921
- // Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
1922
- return `'${String(v).replace(/'/g, "''")}'`;
1923
- }
1924
3811
  /**
1925
3812
  * Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
1926
3813
  * on the SAME table is used as the scratch builder so the callback can call
@@ -1928,7 +3815,7 @@ export class ModelQuery {
1928
3815
  * groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
1929
3816
  */
1930
3817
  #buildGroup(type, callback) {
1931
- const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect);
3818
+ const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect, this.#prepareValue);
1932
3819
  callback(scratch);
1933
3820
  return { type, kind: "group", conditions: scratch.#wheres };
1934
3821
  }
@@ -1968,7 +3855,7 @@ export class ModelQuery {
1968
3855
  type,
1969
3856
  column: resolved,
1970
3857
  operator: "=",
1971
- value: operatorOrValue,
3858
+ value: this.#prep(column, operatorOrValue),
1972
3859
  });
1973
3860
  }
1974
3861
  else {
@@ -1976,11 +3863,21 @@ export class ModelQuery {
1976
3863
  type,
1977
3864
  column: resolved,
1978
3865
  operator: operatorOrValue,
1979
- value,
3866
+ value: this.#prep(column, value),
1980
3867
  });
1981
3868
  }
1982
3869
  return this;
1983
3870
  }
3871
+ /**
3872
+ * Lower a WHERE/search value (or each element of an array) to its DB form via
3873
+ * the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
3874
+ * adapter column used as a predicate binds the same shape the write path stores.
3875
+ */
3876
+ #prep(column, value) {
3877
+ return Array.isArray(value)
3878
+ ? value.map((v) => this.#prepareValue(column, v))
3879
+ : this.#prepareValue(column, value);
3880
+ }
1984
3881
  /**
1985
3882
  * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
1986
3883
  * Used by the nested-preload machinery to recurse without re-running the root select.