@c9up/atlas 0.1.18 → 0.2.0

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 (127) hide show
  1. package/README.md +55 -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 +6 -0
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +2 -2
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +171 -7
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -31
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +91 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +193 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +77 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1423 -354
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +429 -11
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1733 -145
  26. package/dist/ModelQuery.js.map +1 -1
  27. package/dist/Transaction.d.ts +17 -0
  28. package/dist/Transaction.d.ts.map +1 -1
  29. package/dist/Transaction.js +57 -5
  30. package/dist/Transaction.js.map +1 -1
  31. package/dist/adapters/NapiDbAdapter.d.ts +33 -4
  32. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  33. package/dist/adapters/NapiDbAdapter.js +101 -11
  34. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  35. package/dist/console/migrationCommands.d.ts +48 -0
  36. package/dist/console/migrationCommands.d.ts.map +1 -0
  37. package/dist/console/migrationCommands.js +220 -0
  38. package/dist/console/migrationCommands.js.map +1 -0
  39. package/dist/decorators/entity.d.ts +37 -6
  40. package/dist/decorators/entity.d.ts.map +1 -1
  41. package/dist/decorators/entity.js +32 -2
  42. package/dist/decorators/entity.js.map +1 -1
  43. package/dist/events.d.ts +64 -0
  44. package/dist/events.d.ts.map +1 -0
  45. package/dist/events.js +82 -0
  46. package/dist/events.js.map +1 -0
  47. package/dist/index.d.ts +5 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/metadata-keys.d.ts +3 -2
  52. package/dist/metadata-keys.d.ts.map +1 -1
  53. package/dist/naming/NamingStrategy.d.ts +7 -0
  54. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  55. package/dist/naming/NamingStrategy.js +16 -0
  56. package/dist/naming/NamingStrategy.js.map +1 -1
  57. package/dist/schema/Migration.d.ts +26 -3
  58. package/dist/schema/Migration.d.ts.map +1 -1
  59. package/dist/schema/Migration.js +33 -24
  60. package/dist/schema/Migration.js.map +1 -1
  61. package/dist/schema/MigrationRunner.d.ts +43 -32
  62. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  63. package/dist/schema/MigrationRunner.js +211 -26
  64. package/dist/schema/MigrationRunner.js.map +1 -1
  65. package/dist/schema/Schema.d.ts +57 -0
  66. package/dist/schema/Schema.d.ts.map +1 -1
  67. package/dist/schema/Schema.js +138 -3
  68. package/dist/schema/Schema.js.map +1 -1
  69. package/dist/schema/SchemaCheck.d.ts.map +1 -1
  70. package/dist/schema/SchemaCheck.js +3 -1
  71. package/dist/schema/SchemaCheck.js.map +1 -1
  72. package/dist/schema/TableBuilder.d.ts +247 -8
  73. package/dist/schema/TableBuilder.d.ts.map +1 -1
  74. package/dist/schema/TableBuilder.js +607 -41
  75. package/dist/schema/TableBuilder.js.map +1 -1
  76. package/dist/schema/catalog.d.ts +47 -0
  77. package/dist/schema/catalog.d.ts.map +1 -0
  78. package/dist/schema/catalog.js +111 -0
  79. package/dist/schema/catalog.js.map +1 -0
  80. package/dist/schema/introspect.js.map +1 -1
  81. package/dist/schema/types.d.ts +150 -1
  82. package/dist/schema/types.d.ts.map +1 -1
  83. package/dist/schema/types.js +11 -0
  84. package/dist/schema/types.js.map +1 -1
  85. package/dist/services/db.d.ts +6 -0
  86. package/dist/services/db.d.ts.map +1 -1
  87. package/dist/services/db.js +17 -0
  88. package/dist/services/db.js.map +1 -1
  89. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  90. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  91. package/dist/testing/DatabaseCleanup.js +21 -18
  92. package/dist/testing/DatabaseCleanup.js.map +1 -1
  93. package/dist/testing/Factory.d.ts +70 -5
  94. package/dist/testing/Factory.d.ts.map +1 -1
  95. package/dist/testing/Factory.js +209 -10
  96. package/dist/testing/Factory.js.map +1 -1
  97. package/index.darwin-arm64.node +0 -0
  98. package/index.darwin-x64.node +0 -0
  99. package/index.linux-arm64-gnu.node +0 -0
  100. package/index.linux-x64-gnu.node +0 -0
  101. package/index.win32-x64-msvc.node +0 -0
  102. package/package.json +4 -1
  103. package/scripts/guard-publish.mjs +15 -0
  104. package/src/AtlasProvider.ts +8 -1
  105. package/src/BaseEntity.ts +449 -40
  106. package/src/BaseModel.ts +324 -0
  107. package/src/BaseRepository.ts +1659 -371
  108. package/src/ModelQuery.ts +2290 -203
  109. package/src/Transaction.ts +68 -5
  110. package/src/adapters/NapiDbAdapter.ts +159 -10
  111. package/src/console/migrationCommands.ts +258 -0
  112. package/src/decorators/entity.ts +53 -6
  113. package/src/events.ts +112 -0
  114. package/src/index.ts +19 -0
  115. package/src/metadata-keys.ts +3 -2
  116. package/src/naming/NamingStrategy.ts +23 -0
  117. package/src/schema/Migration.ts +42 -3
  118. package/src/schema/MigrationRunner.ts +270 -27
  119. package/src/schema/Schema.ts +210 -3
  120. package/src/schema/SchemaCheck.ts +7 -2
  121. package/src/schema/TableBuilder.ts +735 -41
  122. package/src/schema/catalog.ts +166 -0
  123. package/src/schema/introspect.ts +3 -4
  124. package/src/schema/types.ts +137 -2
  125. package/src/services/db.ts +28 -0
  126. package/src/testing/DatabaseCleanup.ts +23 -22
  127. package/src/testing/Factory.ts +332 -15
@@ -7,8 +7,14 @@
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";
17
+ import { getNamingStrategy } from "./naming/NamingStrategy.js";
12
18
  import { compileStatementNative, getAtlasDialect, } from "./query/native.js";
13
19
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
14
20
  /**
@@ -28,6 +34,49 @@ const WHEREEXPR_OPERATORS = new Set([
28
34
  "LIKE",
29
35
  "NOT LIKE",
30
36
  ]);
37
+ /**
38
+ * SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
39
+ * They are just letters (pass the charset guard) but would let the fragment alter
40
+ * the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
41
+ * alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
42
+ */
43
+ const WHEREEXPR_FORBIDDEN_WORDS = new Set([
44
+ "OR",
45
+ "AND",
46
+ "NOT",
47
+ "IS",
48
+ "NULL",
49
+ "IN",
50
+ "LIKE",
51
+ "ILIKE",
52
+ "BETWEEN",
53
+ "EXISTS",
54
+ "ANY",
55
+ "ALL",
56
+ "SOME",
57
+ "CASE",
58
+ "WHEN",
59
+ "THEN",
60
+ "ELSE",
61
+ "END",
62
+ "SELECT",
63
+ "FROM",
64
+ "WHERE",
65
+ "JOIN",
66
+ "UNION",
67
+ "INTERSECT",
68
+ "EXCEPT",
69
+ "HAVING",
70
+ "GROUP",
71
+ "ORDER",
72
+ "BY",
73
+ "LIMIT",
74
+ "OFFSET",
75
+ "AS",
76
+ "DISTINCT",
77
+ "TRUE",
78
+ "FALSE",
79
+ ]);
31
80
  /** True when every `(` in `s` has a matching `)` and none closes early. */
32
81
  function hasBalancedParens(s) {
33
82
  let depth = 0;
@@ -42,6 +91,97 @@ function hasBalancedParens(s) {
42
91
  }
43
92
  return depth === 0;
44
93
  }
94
+ /**
95
+ * Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
96
+ * and the snake_case convention. Used to build correlated/preload subqueries on a
97
+ * RELATED model so their WHERE/join columns resolve like a direct query would.
98
+ */
99
+ function buildColumnResolver(entityClass) {
100
+ const map = new Map();
101
+ for (const col of getColumnMetadata(entityClass)) {
102
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
103
+ map.set(col.propertyKey, db);
104
+ map.set(db, db);
105
+ }
106
+ return (col) => map.get(col) ?? camelToSnake(col);
107
+ }
108
+ /**
109
+ * Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
110
+ * (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
111
+ * preload/whereHas constraint on a RELATED model prepares its values like a direct query.
112
+ */
113
+ function buildValuePreparer(entityClass) {
114
+ const prepares = new Map();
115
+ // Reverse map (db column → property) so a caller passing a DB name or an
116
+ // explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
117
+ // still routes through the property-keyed prepare/date maps — mirrors
118
+ // BaseRepository.#applyPrepare.
119
+ const byDbName = new Map();
120
+ for (const col of getColumnMetadata(entityClass)) {
121
+ if (col.prepare)
122
+ prepares.set(col.propertyKey, col.prepare);
123
+ byDbName.set(col.columnName ?? camelToSnake(col.propertyKey), col.propertyKey);
124
+ }
125
+ const dateCols = getDateColumnConfig(entityClass);
126
+ return (key, value) => {
127
+ const prop = byDbName.get(key) ?? key;
128
+ const p = prepares.get(prop);
129
+ // Query-builder value transform — no model instance, but the attribute is
130
+ // known (Adonis Lucid signature: value, attribute, model).
131
+ if (p)
132
+ return p(value, prop, undefined);
133
+ if (dateCols[prop] && value != null) {
134
+ if (value instanceof Date)
135
+ return value.toISOString();
136
+ return dateTimeAtlasAdapter.prepare(value);
137
+ }
138
+ return value;
139
+ };
140
+ }
141
+ /** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
142
+ function joinValueHasToISO(v) {
143
+ return (typeof v === "object" &&
144
+ v !== null &&
145
+ "toISO" in v &&
146
+ typeof v.toISO === "function");
147
+ }
148
+ /**
149
+ * Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
150
+ * `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
151
+ * column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
152
+ * borrow the root model's adapter for a same-named column on a different table
153
+ * (Knex binds join values model-agnostically; we add only safe universal
154
+ * serialization so a DateTime still lowers to ISO like `where()`).
155
+ */
156
+ function lowerJoinValue(value) {
157
+ if (value instanceof Date)
158
+ return value.toISOString();
159
+ if (joinValueHasToISO(value))
160
+ return value.toISO();
161
+ return value;
162
+ }
163
+ /**
164
+ * Does a join column's table reference (`ref`) denote the root model's own table
165
+ * (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
166
+ * model declares (default schema) — `orders` matches a `public.orders` model — but
167
+ * it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
168
+ * accepts `orders.col`, while an unqualified `orders` model rejects
169
+ * `archive.orders.col` (a different schema the model never named) — keeping it
170
+ * foreign so the root model's `@Column` adapters aren't misapplied to it.
171
+ */
172
+ function sameTableRef(ref, modelTable) {
173
+ const rs = ref.split(".");
174
+ const ms = modelTable.split(".");
175
+ // The reference cannot be MORE qualified than the model (it can only drop the
176
+ // schema, never assert a new one) — otherwise treat it as a foreign table.
177
+ if (rs.length > ms.length)
178
+ return false;
179
+ for (let i = 1; i <= rs.length; i++) {
180
+ if (rs[rs.length - i] !== ms[ms.length - i])
181
+ return false;
182
+ }
183
+ return true;
184
+ }
45
185
  /** Set an empty relation value on every parent and return no related rows. */
46
186
  function assignEmptyRelation(entities, relationName, single) {
47
187
  for (const e of entities)
@@ -68,10 +208,12 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
68
208
  return throughToParent;
69
209
  }
70
210
  /**
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.
211
+ * Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
212
+ * `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
213
+ * call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
214
+ * builder paths. The connection-level `db.query()` / `db.execute()` stay available
215
+ * as the explicit, parameterised break-glass. Intended for prod hardening on apps
216
+ * that can't audit every call site manually.
75
217
  *
76
218
  * Enable via:
77
219
  * - `setAtlasStrictMode(true)` at app bootstrap
@@ -82,7 +224,7 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
82
224
  * `__internal: true` flag on the call — not exposed in the public types.
83
225
  */
84
226
  let atlasStrictMode;
85
- /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
227
+ /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
86
228
  export function setAtlasStrictMode(enabled) {
87
229
  atlasStrictMode = enabled;
88
230
  }
@@ -120,14 +262,46 @@ export class Paginator {
120
262
  meta;
121
263
  #baseUrl;
122
264
  #queryString = {};
123
- constructor(items, base) {
265
+ #metaKeys;
266
+ constructor(items, base, metaKeys) {
124
267
  this.items = items;
125
268
  const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
126
269
  this.meta = { ...base, lastPage, firstPage: 1 };
270
+ this.#metaKeys = metaKeys;
127
271
  }
128
272
  all() {
129
273
  return this.items;
130
274
  }
275
+ // Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
276
+ // carried in `.meta`, exposed directly on the instance for convenience.
277
+ /** Total row count across all pages. */
278
+ get total() {
279
+ return this.meta.total;
280
+ }
281
+ /** Rows per page. */
282
+ get perPage() {
283
+ return this.meta.perPage;
284
+ }
285
+ /** The current page number. */
286
+ get currentPage() {
287
+ return this.meta.currentPage;
288
+ }
289
+ /** The last page number. */
290
+ get lastPage() {
291
+ return this.meta.lastPage;
292
+ }
293
+ /** The first page number (always 1). */
294
+ get firstPage() {
295
+ return this.meta.firstPage;
296
+ }
297
+ /** True when there is more than one page of results (AdonisJS `hasPages`). */
298
+ get hasPages() {
299
+ return this.meta.lastPage > 1;
300
+ }
301
+ /** True when there is at least one more page after the current one (AdonisJS `hasMorePages`). */
302
+ get hasMorePages() {
303
+ return this.meta.currentPage < this.meta.lastPage;
304
+ }
131
305
  serialize(opts) {
132
306
  const data = this.items.map((item) => {
133
307
  if (!opts?.fields)
@@ -137,7 +311,9 @@ export class Paginator {
137
311
  picked[f] = item[f];
138
312
  return picked;
139
313
  });
140
- return { data, meta: this.meta };
314
+ // Same meta shape as toJSON — snake_case keys via the naming strategy's
315
+ // paginationMetaKeys, plus page URLs when a baseUrl is set.
316
+ return { data, meta: this.#buildMeta() };
141
317
  }
142
318
  baseUrl(url) {
143
319
  this.#baseUrl = url;
@@ -147,26 +323,70 @@ export class Paginator {
147
323
  this.#queryString = qs;
148
324
  return this;
149
325
  }
150
- toJSON() {
151
- const meta = {
152
- ...this.meta,
153
- };
326
+ /**
327
+ * Build the URL for a page number, honouring `baseUrl` + `queryString`.
328
+ * Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
329
+ */
330
+ getUrl(page) {
331
+ if (!this.#baseUrl)
332
+ return "";
333
+ const params = new URLSearchParams();
334
+ for (const [k, v] of Object.entries(this.#queryString))
335
+ params.set(k, String(v));
336
+ params.set("page", String(page));
337
+ return `${this.#baseUrl}?${params.toString()}`;
338
+ }
339
+ /** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
340
+ getNextPageUrl() {
341
+ return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
342
+ }
343
+ /** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
344
+ getPreviousPageUrl() {
345
+ return this.meta.currentPage > 1
346
+ ? this.getUrl(this.meta.currentPage - 1)
347
+ : null;
348
+ }
349
+ /** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
350
+ getUrlsForRange(start, end) {
351
+ const lo = Math.max(1, start);
352
+ const hi = Math.min(this.meta.lastPage, end);
353
+ const range = [];
354
+ for (let page = lo; page <= hi; page++)
355
+ range.push({
356
+ page,
357
+ url: this.getUrl(page),
358
+ isActive: page === this.meta.currentPage,
359
+ });
360
+ return range;
361
+ }
362
+ /**
363
+ * Build the serialized `meta` object: the raw camelCase fields plus page URLs
364
+ * (when a baseUrl is set), remapped through the naming strategy's
365
+ * `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
366
+ * Shared by {@link toJSON} and {@link serialize} so they never diverge.
367
+ */
368
+ #buildMeta() {
369
+ const raw = { ...this.meta };
154
370
  if (this.#baseUrl) {
155
- const build = (page) => {
156
- const params = new URLSearchParams();
157
- for (const [k, v] of Object.entries(this.#queryString))
158
- params.set(k, String(v));
159
- params.set("page", String(page));
160
- return `${this.#baseUrl}?${params.toString()}`;
161
- };
162
- meta.firstPageUrl = build(1);
163
- meta.lastPageUrl = build(this.meta.lastPage);
164
- if (this.meta.currentPage < this.meta.lastPage)
165
- meta.nextPageUrl = build(this.meta.currentPage + 1);
166
- if (this.meta.currentPage > 1)
167
- meta.previousPageUrl = build(this.meta.currentPage - 1);
371
+ raw.firstPageUrl = this.getUrl(1);
372
+ raw.lastPageUrl = this.getUrl(this.meta.lastPage);
373
+ const next = this.getNextPageUrl();
374
+ const prev = this.getPreviousPageUrl();
375
+ if (next)
376
+ raw.nextPageUrl = next;
377
+ if (prev)
378
+ raw.previousPageUrl = prev;
168
379
  }
169
- return { data: this.items, meta };
380
+ const keys = this.#metaKeys;
381
+ if (!keys)
382
+ return raw;
383
+ const meta = {};
384
+ for (const [k, v] of Object.entries(raw))
385
+ meta[keys[k] ?? k] = v;
386
+ return meta;
387
+ }
388
+ toJSON() {
389
+ return { data: this.items, meta: this.#buildMeta() };
170
390
  }
171
391
  }
172
392
  /** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
@@ -193,15 +413,38 @@ export class ModelQuery {
193
413
  #subqueryAlias;
194
414
  /** Raw JOIN fragments — Story 29.4. */
195
415
  #joins = [];
196
- /** Row lock mode — Story 30.8. */
416
+ /** Row lock base mode — Story 30.8. */
197
417
  #lockMode = null;
418
+ /** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
419
+ #lockModifier = null;
420
+ /** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
421
+ #sideloaded = null;
198
422
  /** Per-query debug flag — Story 29.11. */
199
423
  #debugFlag = false;
200
424
  /** Distinct flag — Story 29.5. */
201
425
  #distinct = false;
426
+ #distinctOn = [];
427
+ /** GROUP BY columns (Lucid parity). */
428
+ #groupBy = [];
429
+ /** HAVING clauses — structured + raw (Lucid parity). */
430
+ #having = [];
431
+ /** CTEs registered via `.with()` (Lucid parity). */
432
+ #ctes = [];
433
+ /** UNION / UNION ALL branches (Lucid parity). */
434
+ #unions = [];
435
+ /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
436
+ #pivotWheres = [];
437
+ /**
438
+ * Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
439
+ * the relation proxy's scoped query; invoked at `#buildSpec()` time with the
440
+ * CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
441
+ * back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
442
+ * time would freeze the predicate before those calls and silently drop them).
443
+ */
444
+ #pivotExists;
202
445
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
203
446
  #dialect;
204
- constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect()) {
447
+ constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect(), prepareValue = (_c, v) => v, onDomainEvents) {
205
448
  this.#tableName = tableName;
206
449
  this.#db = db;
207
450
  this.#hydrateFn = hydrateFn;
@@ -209,7 +452,13 @@ export class ModelQuery {
209
452
  this.#resolveColumn = resolveColumn;
210
453
  this.#softDeletes = softDeletes;
211
454
  this.#dialect = dialect;
455
+ this.#prepareValue = prepareValue;
456
+ this.#onDomainEvents = onDomainEvents;
212
457
  }
458
+ /** @see ValuePreparer — identity unless the owning repository wires prepare in. */
459
+ #prepareValue;
460
+ /** Domain-event bus threaded from the owning repository — propagated to preload repos. */
461
+ #onDomainEvents;
213
462
  /** Include soft-deleted rows in the result (default behavior excludes them). */
214
463
  withTrashed() {
215
464
  this.#softScope = "with-trashed";
@@ -234,11 +483,29 @@ export class ModelQuery {
234
483
  }
235
484
  /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
236
485
  select(columns) {
237
- this.#select = Array.isArray(columns)
486
+ const list = Array.isArray(columns)
238
487
  ? columns
239
488
  : columns.split(",").map((c) => c.trim());
489
+ this.#select = list.map((c) => this.#resolveSelect(c));
240
490
  return this;
241
491
  }
492
+ /**
493
+ * Resolve a bare model-property select/returning target to its DB column
494
+ * (honouring `@Column({ columnName })`), leaving expressions / aliases /
495
+ * qualified names / `*` untouched. A bare identifier IS validated through the
496
+ * column resolver — so a typo like `select('lable')` raises the same Atlas
497
+ * error as `where`/`orderBy`, rather than reaching the DB.
498
+ */
499
+ #resolveSelect(col) {
500
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col))
501
+ return this.#resolveColumn(col);
502
+ // `col as alias` — resolve the (bare) column part to its DB name, keep the
503
+ // alias verbatim, so `select('label as name')` honours a columnName override.
504
+ const aliased = col.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
505
+ if (aliased)
506
+ return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
507
+ return col;
508
+ }
242
509
  where(columnOrCb, operatorOrValue, value) {
243
510
  if (typeof columnOrCb === "function") {
244
511
  this.#wheres.push(this.#buildGroup("and", columnOrCb));
@@ -271,13 +538,63 @@ export class ModelQuery {
271
538
  });
272
539
  return this;
273
540
  }
541
+ andWhere(columnOrCb, operatorOrValue, value) {
542
+ // The 2-arg overload must not forward a phantom third argument: `where`
543
+ // switches on `value === undefined` to tell `(col, value)` from
544
+ // `(col, operator, value)`.
545
+ return typeof columnOrCb === "function"
546
+ ? this.where(columnOrCb)
547
+ : value === undefined
548
+ ? this.where(columnOrCb, operatorOrValue)
549
+ : this.where(columnOrCb, operatorOrValue, value);
550
+ }
551
+ /** Alias of {@link whereNot} (Lucid parity). */
552
+ andWhereNot(column, value) {
553
+ return this.whereNot(column, value);
554
+ }
555
+ /** Alias of {@link whereIn} (Lucid parity). */
556
+ andWhereIn(column, values) {
557
+ return this.whereIn(column, values);
558
+ }
559
+ /** Alias of {@link whereNotIn} (Lucid parity). */
560
+ andWhereNotIn(column, values) {
561
+ return this.whereNotIn(column, values);
562
+ }
563
+ /** Alias of {@link whereNull} (Lucid parity). */
564
+ andWhereNull(column) {
565
+ return this.whereNull(column);
566
+ }
567
+ /** Alias of {@link whereNotNull} (Lucid parity). */
568
+ andWhereNotNull(column) {
569
+ return this.whereNotNull(column);
570
+ }
571
+ /** Alias of {@link whereBetween} (Lucid parity). */
572
+ andWhereBetween(column, range) {
573
+ return this.whereBetween(column, range);
574
+ }
575
+ /** Alias of {@link whereNotBetween} (Lucid parity). */
576
+ andWhereNotBetween(column, range) {
577
+ return this.whereNotBetween(column, range);
578
+ }
579
+ /** Alias of {@link whereLike} (Lucid parity). */
580
+ andWhereLike(column, pattern) {
581
+ return this.whereLike(column, pattern);
582
+ }
583
+ /** Alias of {@link whereILike} (Lucid parity). */
584
+ andWhereILike(column, pattern) {
585
+ return this.whereILike(column, pattern);
586
+ }
587
+ /** Alias of {@link whereColumn} (Lucid parity). */
588
+ andWhereColumn(left, operator, right) {
589
+ return this.whereColumn(left, operator, right);
590
+ }
274
591
  /** `WHERE col != ?` — negation of `where`. */
275
592
  whereNot(column, value) {
276
593
  this.#wheres.push({
277
594
  type: "and",
278
595
  column: this.#resolveColumn(column),
279
596
  operator: "!=",
280
- value,
597
+ value: this.#prep(column, value),
281
598
  });
282
599
  return this;
283
600
  }
@@ -297,7 +614,7 @@ export class ModelQuery {
297
614
  type: "and",
298
615
  column: this.#resolveColumn(column),
299
616
  operator: "IN",
300
- value: [...source],
617
+ value: this.#prep(column, [...source]),
301
618
  });
302
619
  return this;
303
620
  }
@@ -317,7 +634,7 @@ export class ModelQuery {
317
634
  type: "and",
318
635
  column: this.#resolveColumn(column),
319
636
  operator: "NOT IN",
320
- value: [...source],
637
+ value: this.#prep(column, [...source]),
321
638
  });
322
639
  return this;
323
640
  }
@@ -327,7 +644,7 @@ export class ModelQuery {
327
644
  type: "and",
328
645
  column: this.#resolveColumn(column),
329
646
  operator: "BETWEEN",
330
- value: [...range],
647
+ value: this.#prep(column, [...range]),
331
648
  });
332
649
  return this;
333
650
  }
@@ -337,7 +654,7 @@ export class ModelQuery {
337
654
  type: "and",
338
655
  column: this.#resolveColumn(column),
339
656
  operator: "NOT BETWEEN",
340
- value: [...range],
657
+ value: this.#prep(column, [...range]),
341
658
  });
342
659
  return this;
343
660
  }
@@ -365,6 +682,119 @@ export class ModelQuery {
365
682
  });
366
683
  return this;
367
684
  }
685
+ // ─── OR-combined variants (AdonisJS orWhere* family) ─────────
686
+ // Same predicates as the whereX methods above, combined with OR instead of
687
+ // AND — the named ergonomics Lucid exposes (vs emulating with `orWhere(cb)`).
688
+ /** `OR col IS NULL`. */
689
+ orWhereNull(column) {
690
+ this.#wheres.push({
691
+ type: "or",
692
+ column: this.#resolveColumn(column),
693
+ operator: "IS NULL",
694
+ value: null,
695
+ });
696
+ return this;
697
+ }
698
+ /** `OR col IS NOT NULL`. */
699
+ orWhereNotNull(column) {
700
+ this.#wheres.push({
701
+ type: "or",
702
+ column: this.#resolveColumn(column),
703
+ operator: "IS NOT NULL",
704
+ value: null,
705
+ });
706
+ return this;
707
+ }
708
+ /** `OR col != ?`. */
709
+ orWhereNot(column, value) {
710
+ this.#wheres.push({
711
+ type: "or",
712
+ column: this.#resolveColumn(column),
713
+ operator: "!=",
714
+ value: this.#prep(column, value),
715
+ });
716
+ return this;
717
+ }
718
+ /** `OR col IN (...)` — array or `ModelQuery` subquery source. */
719
+ orWhereIn(column, source) {
720
+ if (source instanceof _a) {
721
+ this.#wheres.push({
722
+ type: "or",
723
+ kind: "inSub",
724
+ negated: false,
725
+ column: this.#resolveColumn(column),
726
+ subquery: source.#buildSpec(),
727
+ });
728
+ return this;
729
+ }
730
+ this.#wheres.push({
731
+ type: "or",
732
+ column: this.#resolveColumn(column),
733
+ operator: "IN",
734
+ value: this.#prep(column, [...source]),
735
+ });
736
+ return this;
737
+ }
738
+ /** `OR col NOT IN (...)` — array or `ModelQuery` subquery source. */
739
+ orWhereNotIn(column, source) {
740
+ if (source instanceof _a) {
741
+ this.#wheres.push({
742
+ type: "or",
743
+ kind: "inSub",
744
+ negated: true,
745
+ column: this.#resolveColumn(column),
746
+ subquery: source.#buildSpec(),
747
+ });
748
+ return this;
749
+ }
750
+ this.#wheres.push({
751
+ type: "or",
752
+ column: this.#resolveColumn(column),
753
+ operator: "NOT IN",
754
+ value: this.#prep(column, [...source]),
755
+ });
756
+ return this;
757
+ }
758
+ /** `OR col BETWEEN ? AND ?`. */
759
+ orWhereBetween(column, range) {
760
+ this.#wheres.push({
761
+ type: "or",
762
+ column: this.#resolveColumn(column),
763
+ operator: "BETWEEN",
764
+ value: this.#prep(column, [...range]),
765
+ });
766
+ return this;
767
+ }
768
+ /** `OR col NOT BETWEEN ? AND ?`. */
769
+ orWhereNotBetween(column, range) {
770
+ this.#wheres.push({
771
+ type: "or",
772
+ column: this.#resolveColumn(column),
773
+ operator: "NOT BETWEEN",
774
+ value: this.#prep(column, [...range]),
775
+ });
776
+ return this;
777
+ }
778
+ /** `OR col LIKE ?`. */
779
+ orWhereLike(column, pattern) {
780
+ this.#wheres.push({
781
+ type: "or",
782
+ column: this.#resolveColumn(column),
783
+ operator: "LIKE",
784
+ value: pattern,
785
+ });
786
+ return this;
787
+ }
788
+ /** `OR col ILIKE ?` (rewritten to LOWER() LIKE LOWER() on sqlite/mysql). */
789
+ orWhereILike(column, pattern) {
790
+ this.#wheres.push({
791
+ type: "or",
792
+ column: this.#resolveColumn(column),
793
+ operator: "ILIKE",
794
+ value: pattern,
795
+ });
796
+ return this;
797
+ }
368
798
  /**
369
799
  * **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
370
800
  * `?`-style bindings. The Rust compiler re-indexes the placeholders so they
@@ -404,15 +834,47 @@ export class ModelQuery {
404
834
  * Not exported from the package barrel — only accessible inside the Atlas
405
835
  * codebase via direct ModelQuery instance access.
406
836
  */
407
- #pushWhereRaw(sql, bindings = []) {
837
+ #pushWhereRaw(sql, bindings = [], type = "and") {
408
838
  this.#wheres.push({
409
- type: "and",
839
+ type,
410
840
  kind: "raw",
411
841
  sql,
412
842
  bindings: [...bindings],
413
843
  });
414
844
  return this;
415
845
  }
846
+ /** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
847
+ andWhereRaw(sql, bindings = []) {
848
+ return this.whereRaw(sql, bindings);
849
+ }
850
+ /**
851
+ * `OR <raw fragment>` (Lucid parity).
852
+ *
853
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
854
+ * Subject to the same strict-mode gate as {@link whereRaw}.
855
+ */
856
+ orWhereRaw(sql, bindings = []) {
857
+ this.#assertRawAllowed("orWhereRaw");
858
+ return this.#pushWhereRaw(sql, bindings, "or");
859
+ }
860
+ /** Shared strict-mode gate for the raw WHERE entry points. */
861
+ #assertRawAllowed(method) {
862
+ if (isAtlasStrictMode() && !isInternalBypass()) {
863
+ throw new Error(`${method}() is disabled in Atlas strict mode. ` +
864
+ "Use whereExpr() or a structured builder method instead. " +
865
+ "Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.");
866
+ }
867
+ }
868
+ /**
869
+ * Framework-internal: register the deferred m2m EXISTS predicate for a lazy
870
+ * `related().query()`. The builder is re-invoked on every `#buildSpec()` with
871
+ * the pivot constraints known at that moment, so `.wherePivot()` added after
872
+ * the proxy returned still applies. Not exported from the barrel.
873
+ */
874
+ setPivotExistsBuilder(builder) {
875
+ this.#pivotExists = builder;
876
+ return this;
877
+ }
416
878
  whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
417
879
  // 3-arg form: whereExpr(col, op, value)
418
880
  // 4-arg form: whereExpr(col, extraExpr, op, value)
@@ -431,6 +893,18 @@ export class ModelQuery {
431
893
  if (!hasBalancedParens(extra)) {
432
894
  throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
433
895
  }
896
+ // The charset blocks comparison/quote symbols, but bare SQL keywords
897
+ // (OR / AND / IS / NOT / SELECT …) are just letters and would slip
898
+ // through, letting `extra` alter the predicate's logical structure
899
+ // (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
900
+ // SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
901
+ // token — arithmetic on columns/numbers/functions only.
902
+ for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
903
+ if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
904
+ throw new Error(`whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
905
+ "whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.");
906
+ }
907
+ }
434
908
  // `op` is interpolated raw into the fragment below, so it MUST be
435
909
  // allow-listed — the 3-arg path gets this from the Rust operator
436
910
  // validation, but the raw 4-arg path bypasses Rust and would
@@ -447,11 +921,250 @@ export class ModelQuery {
447
921
  // the operator against the allow-list above.
448
922
  if (hasExtra) {
449
923
  const q = this.#quote(resolved);
450
- return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
924
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
925
+ this.#prep(column, value),
926
+ ]);
451
927
  }
452
- this.#wheres.push({ type: "and", column: resolved, operator: op, value });
928
+ this.#wheres.push({
929
+ type: "and",
930
+ column: resolved,
931
+ operator: op,
932
+ value: this.#prep(column, value),
933
+ });
453
934
  return this;
454
935
  }
936
+ /**
937
+ * Compare two COLUMNS (AdonisJS/Knex `whereColumn`) — `WHERE "a" op "b"`.
938
+ * Both sides go through the identifier quoter (injection-safe) and the
939
+ * operator is allow-listed; nothing is bound (it's a column reference, not a
940
+ * value), which the standard `where`/`whereExpr` value-binding path can't do.
941
+ */
942
+ // ─── EXISTS ───────────────────────────────────────────────
943
+ //
944
+ // `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
945
+ // builder `repo.query()` actually hands back, so it was unreachable from
946
+ // normal use. The subquery is another `ModelQuery`; correlate it to the
947
+ // outer table with `whereColumn`:
948
+ //
949
+ // userRepo.query().whereExists(
950
+ // postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
951
+ // )
952
+ //
953
+ // For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
954
+ // join predicate from the relation metadata.
955
+ /** `WHERE EXISTS (subquery)` (Lucid parity). */
956
+ whereExists(subquery) {
957
+ return this.#pushExists("and", false, subquery);
958
+ }
959
+ /** Alias of {@link whereExists} (Lucid parity). */
960
+ andWhereExists(subquery) {
961
+ return this.#pushExists("and", false, subquery);
962
+ }
963
+ /** `OR EXISTS (subquery)` (Lucid parity). */
964
+ orWhereExists(subquery) {
965
+ return this.#pushExists("or", false, subquery);
966
+ }
967
+ /** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
968
+ whereNotExists(subquery) {
969
+ return this.#pushExists("and", true, subquery);
970
+ }
971
+ /** Alias of {@link whereNotExists} (Lucid parity). */
972
+ andWhereNotExists(subquery) {
973
+ return this.#pushExists("and", true, subquery);
974
+ }
975
+ /** `OR NOT EXISTS (subquery)` (Lucid parity). */
976
+ orWhereNotExists(subquery) {
977
+ return this.#pushExists("or", true, subquery);
978
+ }
979
+ // ─── JSON ─────────────────────────────────────────────────
980
+ //
981
+ // Every value crosses the boundary as a bound param — the path and the
982
+ // compared value both. Only the column is a quoted identifier. Path access
983
+ // and containment are each spelled per dialect, and SQLite has no
984
+ // containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
985
+ /**
986
+ * `WHERE <col at path> <op> ?` — compare a value inside a JSON column
987
+ * (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
988
+ *
989
+ * query.whereJsonPath('data', '$.address.city', '=', 'Paris')
990
+ */
991
+ whereJsonPath(column, path, operator, value) {
992
+ return this.#pushJson("and", false, "path", column, value, path, operator);
993
+ }
994
+ /** Alias of {@link whereJsonPath} (Lucid parity). */
995
+ andWhereJsonPath(column, path, operator, value) {
996
+ return this.#pushJson("and", false, "path", column, value, path, operator);
997
+ }
998
+ /** `OR <col at path> <op> ?` (Lucid parity). */
999
+ orWhereJsonPath(column, path, operator, value) {
1000
+ return this.#pushJson("or", false, "path", column, value, path, operator);
1001
+ }
1002
+ /**
1003
+ * `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
1004
+ * `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
1005
+ *
1006
+ * Postgres and MySQL only — SQLite has no JSON containment operator and the
1007
+ * compiler raises `E_UNSUPPORTED` there.
1008
+ */
1009
+ whereJsonSupersetOf(column, value) {
1010
+ return this.#pushJson("and", false, "superset", column, value);
1011
+ }
1012
+ /** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
1013
+ andWhereJsonSupersetOf(column, value) {
1014
+ return this.#pushJson("and", false, "superset", column, value);
1015
+ }
1016
+ /** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
1017
+ orWhereJsonSupersetOf(column, value) {
1018
+ return this.#pushJson("or", false, "superset", column, value);
1019
+ }
1020
+ /** `WHERE NOT (<col> @> ?)` (Lucid parity). */
1021
+ whereNotJsonSupersetOf(column, value) {
1022
+ return this.#pushJson("and", true, "superset", column, value);
1023
+ }
1024
+ /** `OR NOT (<col> @> ?)` (Lucid parity). */
1025
+ orWhereNotJsonSupersetOf(column, value) {
1026
+ return this.#pushJson("or", true, "superset", column, value);
1027
+ }
1028
+ /**
1029
+ * `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
1030
+ * `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
1031
+ */
1032
+ whereJsonSubsetOf(column, value) {
1033
+ return this.#pushJson("and", false, "subset", column, value);
1034
+ }
1035
+ /** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
1036
+ andWhereJsonSubsetOf(column, value) {
1037
+ return this.#pushJson("and", false, "subset", column, value);
1038
+ }
1039
+ /** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
1040
+ orWhereJsonSubsetOf(column, value) {
1041
+ return this.#pushJson("or", false, "subset", column, value);
1042
+ }
1043
+ /** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
1044
+ whereNotJsonSubsetOf(column, value) {
1045
+ return this.#pushJson("and", true, "subset", column, value);
1046
+ }
1047
+ /** `OR NOT (<col> <@ ?)` (Lucid parity). */
1048
+ orWhereNotJsonSubsetOf(column, value) {
1049
+ return this.#pushJson("or", true, "subset", column, value);
1050
+ }
1051
+ #pushJson(type, negated, jsonOp, column, value, path, operator) {
1052
+ // A JSONPath is bound, not interpolated, so injection is not the concern
1053
+ // here — a clear early error for a malformed path is. Lucid/Knex paths
1054
+ // start at the document root.
1055
+ if (path !== undefined && !path.startsWith("$")) {
1056
+ throw new Error(`whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`);
1057
+ }
1058
+ // Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
1059
+ // and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
1060
+ // as-is would not cast. A path comparison keeps its scalar value.
1061
+ const bound = jsonOp === "path"
1062
+ ? value
1063
+ : typeof value === "string"
1064
+ ? value
1065
+ : JSON.stringify(value);
1066
+ this.#wheres.push({
1067
+ type,
1068
+ kind: "json",
1069
+ jsonOp,
1070
+ column: this.#resolveColumn(column),
1071
+ negated,
1072
+ path,
1073
+ operator,
1074
+ value: bound,
1075
+ });
1076
+ return this;
1077
+ }
1078
+ #pushExists(type, negated, subquery) {
1079
+ // `#buildSpec` is private, but private access is per-class, not per
1080
+ // instance: another ModelQuery's spec is reachable from here.
1081
+ this.#wheres.push({
1082
+ type,
1083
+ kind: "exists",
1084
+ negated,
1085
+ subquery: subquery.#buildSpec(),
1086
+ });
1087
+ return this;
1088
+ }
1089
+ whereColumn(left, operator, right) {
1090
+ return this.#whereColumn("and", left, operator, right);
1091
+ }
1092
+ /** `OR`-combined {@link whereColumn}. */
1093
+ orWhereColumn(left, operator, right) {
1094
+ return this.#whereColumn("or", left, operator, right);
1095
+ }
1096
+ /** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
1097
+ whereNotColumn(left, operator, right) {
1098
+ return this.#whereColumn("and", left, operator, right, true);
1099
+ }
1100
+ /** Alias of {@link whereNotColumn} (Lucid parity). */
1101
+ andWhereNotColumn(left, operator, right) {
1102
+ return this.#whereColumn("and", left, operator, right, true);
1103
+ }
1104
+ /** `OR NOT (left <op> right)` (Lucid parity). */
1105
+ orWhereNotColumn(left, operator, right) {
1106
+ return this.#whereColumn("or", left, operator, right, true);
1107
+ }
1108
+ #whereColumn(type, left, operator, right, negated = false) {
1109
+ if (!WHEREEXPR_OPERATORS.has(operator)) {
1110
+ throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
1111
+ }
1112
+ // Both operands are interpolated as raw identifiers (no value binding for a
1113
+ // column reference), and #quote is a plain wrapper that does NOT escape an
1114
+ // embedded quote — so validate each RESOLVED identifier against a strict
1115
+ // `[table.]column` charset. This closes the injection surface regardless of
1116
+ // what #resolveColumn returns (it can be an identity resolver on sub-queries).
1117
+ const safe = (name) => {
1118
+ const resolved = this.#resolveColumnReference(name);
1119
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
1120
+ throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
1121
+ }
1122
+ // Quote each dotted segment separately → `"table"."column"`, never a
1123
+ // single mis-quoted `"table.column"`.
1124
+ return resolved
1125
+ .split(".")
1126
+ .map((part) => this.#quote(part))
1127
+ .join(".");
1128
+ };
1129
+ const predicate = `${safe(left)} ${operator} ${safe(right)}`;
1130
+ // Both operands are already validated identifiers and the operator is
1131
+ // allow-listed, so wrapping in NOT(...) adds no new surface.
1132
+ const sql = negated ? `NOT (${predicate})` : predicate;
1133
+ this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
1134
+ return this;
1135
+ }
1136
+ /**
1137
+ * Resolve a column reference that may legitimately point at a table other
1138
+ * than this query's own.
1139
+ *
1140
+ * `#resolveColumn` only knows the entity's own columns, so it rejects
1141
+ * anything qualified. That is right for a value predicate, but wrong for a
1142
+ * column-to-column one: a correlated subquery
1143
+ * (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
1144
+ * and a joined query both have to name another table, and atlas cannot know
1145
+ * that table's columns. So: an unqualified name resolves as usual (typos
1146
+ * still get the helpful error), and a `table.column` naming a different
1147
+ * table passes through — validated against the identifier charset here and
1148
+ * quoted segment by segment by the caller, never interpolated loose. A typo
1149
+ * in that case surfaces as a database error rather than an atlas one, which
1150
+ * is the unavoidable cost of referencing a table we have no metadata for.
1151
+ */
1152
+ #resolveColumnReference(name) {
1153
+ const qualified = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
1154
+ if (!qualified)
1155
+ return this.#resolveColumn(name);
1156
+ const [, table, column] = qualified;
1157
+ // Our own table: resolve the column half so `@Column({ columnName })` and
1158
+ // the camel→snake convention still apply.
1159
+ if (table === this.#tableName) {
1160
+ return `${table}.${this.#resolveColumn(column)}`;
1161
+ }
1162
+ // Another table in scope (outer query or JOIN). Charset-checked by the
1163
+ // regex above and quoted segment by segment by the caller — strict mode
1164
+ // does not apply, since its concern is unvalidated SQL reaching the
1165
+ // compiler and this identifier is validated.
1166
+ return `${table}.${column}`;
1167
+ }
455
1168
  /**
456
1169
  * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
457
1170
  * by the existence of related rows, optionally constrained by a callback.
@@ -476,6 +1189,14 @@ export class ModelQuery {
476
1189
  this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
477
1190
  return this;
478
1191
  }
1192
+ /** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
1193
+ andWhereHas(relationName, callback) {
1194
+ return this.whereHas(relationName, callback);
1195
+ }
1196
+ /** Alias of {@link whereDoesntHave} (Lucid parity). */
1197
+ andWhereDoesntHave(relationName, callback) {
1198
+ return this.whereDoesntHave(relationName, callback);
1199
+ }
479
1200
  /**
480
1201
  * Short form of `whereHas`. With an operator + count, emits a count threshold:
481
1202
  * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
@@ -494,6 +1215,19 @@ export class ModelQuery {
494
1215
  this.#wheres.push(this.#buildExistsClause("and", true, relationName));
495
1216
  return this;
496
1217
  }
1218
+ /** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
1219
+ orDoesntHave(relationName) {
1220
+ this.#wheres.push(this.#buildExistsClause("or", true, relationName));
1221
+ return this;
1222
+ }
1223
+ /** Alias of {@link has} (Lucid parity) — `has` is already AND. */
1224
+ andHas(relationName, countOp, countThreshold) {
1225
+ return this.has(relationName, countOp, countThreshold);
1226
+ }
1227
+ /** Alias of {@link doesntHave} (Lucid parity). */
1228
+ andDoesntHave(relationName) {
1229
+ return this.doesntHave(relationName);
1230
+ }
497
1231
  /**
498
1232
  * Set this query's projection alias — only meaningful when this ModelQuery
499
1233
  * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
@@ -529,23 +1263,25 @@ export class ModelQuery {
529
1263
  // --- Top-level scalar executors (Story 29.5) ---
530
1264
  /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
531
1265
  async count(column = "*") {
532
- const expr = column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
1266
+ const expr = column === "*"
1267
+ ? "COUNT(*)"
1268
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
533
1269
  return Number((await this.#runScalar(expr)) ?? 0);
534
1270
  }
535
1271
  async sum(column) {
536
- const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
1272
+ const v = await this.#runScalar(`SUM(${this.#quoteCol(this.#resolveColumn(column))})`);
537
1273
  return v === null || v === undefined ? null : Number(v);
538
1274
  }
539
1275
  async avg(column) {
540
- const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
1276
+ const v = await this.#runScalar(`AVG(${this.#quoteCol(this.#resolveColumn(column))})`);
541
1277
  return v === null || v === undefined ? null : Number(v);
542
1278
  }
543
1279
  async min(column) {
544
- const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
1280
+ const v = await this.#runScalar(`MIN(${this.#quoteCol(this.#resolveColumn(column))})`);
545
1281
  return v === null || v === undefined ? null : Number(v);
546
1282
  }
547
1283
  async max(column) {
548
- const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
1284
+ const v = await this.#runScalar(`MAX(${this.#quoteCol(this.#resolveColumn(column))})`);
549
1285
  return v === null || v === undefined ? null : Number(v);
550
1286
  }
551
1287
  /**
@@ -576,6 +1312,290 @@ export class ModelQuery {
576
1312
  this.#orderBys.push({ column: this.#resolveColumn(column), direction });
577
1313
  return this;
578
1314
  }
1315
+ /**
1316
+ * `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
1317
+ * no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
1318
+ * alias.
1319
+ *
1320
+ * query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
1321
+ *
1322
+ * The fragment keeps its position among the plain `orderBy` terms.
1323
+ *
1324
+ * **Strict mode**: like {@link whereRaw}, this throws when
1325
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
1326
+ *
1327
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1328
+ */
1329
+ orderByRaw(sql) {
1330
+ this.#assertRawAllowed("orderByRaw");
1331
+ this.#orderBys.push({ raw: sql });
1332
+ return this;
1333
+ }
1334
+ /**
1335
+ * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
1336
+ * through the entity's column map (camelCase → snake_case) like `orderBy`.
1337
+ * For a grouping expression with no typed form, see {@link groupByRaw}.
1338
+ */
1339
+ groupBy(...columns) {
1340
+ for (const c of columns)
1341
+ this.#groupBy.push(this.#resolveColumn(c));
1342
+ return this;
1343
+ }
1344
+ /**
1345
+ * `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
1346
+ * no typed form, e.g. `DATE_TRUNC('day', created_at)`.
1347
+ *
1348
+ * The fragment keeps its position among the plain `groupBy` terms.
1349
+ *
1350
+ * **Strict mode**: like {@link whereRaw}, this throws when
1351
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
1352
+ *
1353
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1354
+ */
1355
+ groupByRaw(sql) {
1356
+ this.#assertRawAllowed("groupByRaw");
1357
+ this.#groupBy.push({ raw: sql });
1358
+ return this;
1359
+ }
1360
+ /**
1361
+ * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
1362
+ * A bare model property is resolved through the entity column map (honouring
1363
+ * `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
1364
+ * expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
1365
+ * `having` can still reference `withCount`/`withAggregate` aliases.
1366
+ */
1367
+ having(column, operator, value) {
1368
+ this.#having.push({
1369
+ column: this.#resolveHavingCol(column),
1370
+ operator,
1371
+ value: this.#prep(column, value),
1372
+ type: "and",
1373
+ });
1374
+ return this;
1375
+ }
1376
+ /**
1377
+ * Resolve a HAVING column: a bare model property maps to its DB column
1378
+ * (honouring `@Column({ columnName })`), but an aggregate expression
1379
+ * (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
1380
+ * so `having` can still reference `withCount`/`withAggregate` aliases.
1381
+ */
1382
+ #resolveHavingCol(column) {
1383
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column))
1384
+ return column;
1385
+ try {
1386
+ return this.#resolveColumn(column);
1387
+ }
1388
+ catch {
1389
+ return column;
1390
+ }
1391
+ }
1392
+ /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
1393
+ orHaving(column, operator, value) {
1394
+ this.#having.push({
1395
+ column: this.#resolveHavingCol(column),
1396
+ operator,
1397
+ value: this.#prep(column, value),
1398
+ type: "or",
1399
+ });
1400
+ return this;
1401
+ }
1402
+ /**
1403
+ * **⚠ UNSAFE** — append a raw SQL `HAVING` fragment with `?` bindings
1404
+ * (AdonisJS/Lucid `havingRaw`). The Rust compiler re-indexes the placeholders;
1405
+ * everything else in `sql` is trusted verbatim. All values must go through
1406
+ * `bindings`.
1407
+ *
1408
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1409
+ */
1410
+ havingRaw(sql, bindings = []) {
1411
+ // Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
1412
+ // surface, so prod hardening must be able to neutralise it too.
1413
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1414
+ throw new Error("havingRaw() is disabled in Atlas strict mode. " +
1415
+ "Use having(column, operator, value) instead.");
1416
+ }
1417
+ this.#having.push({
1418
+ kind: "raw",
1419
+ sql,
1420
+ bindings: [...bindings],
1421
+ type: "and",
1422
+ });
1423
+ return this;
1424
+ }
1425
+ /**
1426
+ * `UNION (<query>)` (AdonisJS/Lucid `union`). The other query is compiled and
1427
+ * appended as a parenthesised UNION branch; its bindings are re-indexed into
1428
+ * the outer parameter list.
1429
+ */
1430
+ union(query) {
1431
+ this.#unions.push({ query, all: false });
1432
+ return this;
1433
+ }
1434
+ /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
1435
+ unionAll(query) {
1436
+ this.#unions.push({ query, all: true });
1437
+ return this;
1438
+ }
1439
+ /** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
1440
+ intersect(query) {
1441
+ this.#unions.push({ query, all: false, op: "intersect" });
1442
+ return this;
1443
+ }
1444
+ /**
1445
+ * `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
1446
+ *
1447
+ * Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
1448
+ * INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
1449
+ * `E_UNSUPPORTED` there rather than emitting a syntax error.
1450
+ */
1451
+ intersectAll(query) {
1452
+ this.#unions.push({ query, all: true, op: "intersect" });
1453
+ return this;
1454
+ }
1455
+ /** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
1456
+ except(query) {
1457
+ this.#unions.push({ query, all: false, op: "except" });
1458
+ return this;
1459
+ }
1460
+ /** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
1461
+ exceptAll(query) {
1462
+ this.#unions.push({ query, all: true, op: "except" });
1463
+ return this;
1464
+ }
1465
+ /**
1466
+ * `WITH <name> AS (<query>)` — register a Common Table Expression
1467
+ * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
1468
+ * sub-query is compiled and its bindings are re-indexed into the outer list.
1469
+ */
1470
+ with(name, query) {
1471
+ return this.#pushCte("with", name, query, {});
1472
+ }
1473
+ /**
1474
+ * `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
1475
+ * (Lucid/Knex `withRecursive`), for trees and graph walks.
1476
+ *
1477
+ * RECURSIVE is a property of the WITH clause rather than of one CTE, so a
1478
+ * single recursive entry makes the whole clause recursive — which is what
1479
+ * all three dialects require. Mixing `with()` and `withRecursive()` is fine.
1480
+ *
1481
+ * The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
1482
+ * an anchor `SELECT` unioned with a select that references `<name>`.
1483
+ */
1484
+ withRecursive(name, query) {
1485
+ return this.#pushCte("withRecursive", name, query, { recursive: true });
1486
+ }
1487
+ /**
1488
+ * `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
1489
+ * once and stashed (Lucid/Knex `withMaterialized`).
1490
+ *
1491
+ * Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
1492
+ * compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
1493
+ */
1494
+ withMaterialized(name, query) {
1495
+ return this.#pushCte("withMaterialized", name, query, {
1496
+ materialized: true,
1497
+ });
1498
+ }
1499
+ /** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
1500
+ withNotMaterialized(name, query) {
1501
+ return this.#pushCte("withNotMaterialized", name, query, {
1502
+ materialized: false,
1503
+ });
1504
+ }
1505
+ #pushCte(method, name, query, options) {
1506
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1507
+ throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
1508
+ }
1509
+ this.#ctes.push({ name, query, ...options });
1510
+ return this;
1511
+ }
1512
+ wherePivot(column, operatorOrValue, value) {
1513
+ return this.#pushPivot("and", column, operatorOrValue, value);
1514
+ }
1515
+ andWherePivot(column, operatorOrValue, value) {
1516
+ return this.#pushPivot("and", column, operatorOrValue, value);
1517
+ }
1518
+ orWherePivot(column, operatorOrValue, value) {
1519
+ return this.#pushPivot("or", column, operatorOrValue, value);
1520
+ }
1521
+ /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
1522
+ whereInPivot(column, values) {
1523
+ return this.#pushPivotOp("and", column, "IN", [...values]);
1524
+ }
1525
+ /** Alias of {@link whereInPivot} (Lucid parity). */
1526
+ andWhereInPivot(column, values) {
1527
+ return this.#pushPivotOp("and", column, "IN", [...values]);
1528
+ }
1529
+ /** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
1530
+ orWhereInPivot(column, values) {
1531
+ return this.#pushPivotOp("or", column, "IN", [...values]);
1532
+ }
1533
+ /** Alias of {@link whereInPivot} kept for the earlier atlas name. */
1534
+ wherePivotIn(column, values) {
1535
+ return this.whereInPivot(column, values);
1536
+ }
1537
+ /** `@ManyToMany` only — `WHERE <pivotCol> != <value>` on the pivot table (AdonisJS Lucid `whereNotPivot`). */
1538
+ whereNotPivot(column, value) {
1539
+ return this.#pushPivotOp("and", column, "!=", value);
1540
+ }
1541
+ /** Alias of {@link whereNotPivot} (Lucid parity). */
1542
+ andWhereNotPivot(column, value) {
1543
+ return this.#pushPivotOp("and", column, "!=", value);
1544
+ }
1545
+ /** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
1546
+ orWhereNotPivot(column, value) {
1547
+ return this.#pushPivotOp("or", column, "!=", value);
1548
+ }
1549
+ /** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
1550
+ whereNotInPivot(column, values) {
1551
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
1552
+ }
1553
+ /** Alias of {@link whereNotInPivot} (Lucid parity). */
1554
+ andWhereNotInPivot(column, values) {
1555
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
1556
+ }
1557
+ /** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
1558
+ orWhereNotInPivot(column, values) {
1559
+ return this.#pushPivotOp("or", column, "NOT IN", [...values]);
1560
+ }
1561
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
1562
+ whereNullPivot(column) {
1563
+ return this.#pushPivotOp("and", column, "IS NULL", null);
1564
+ }
1565
+ /** Alias of {@link whereNullPivot} (Lucid parity). */
1566
+ andWhereNullPivot(column) {
1567
+ return this.#pushPivotOp("and", column, "IS NULL", null);
1568
+ }
1569
+ /** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
1570
+ orWhereNullPivot(column) {
1571
+ return this.#pushPivotOp("or", column, "IS NULL", null);
1572
+ }
1573
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
1574
+ whereNotNullPivot(column) {
1575
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
1576
+ }
1577
+ /** Alias of {@link whereNotNullPivot} (Lucid parity). */
1578
+ andWhereNotNullPivot(column) {
1579
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
1580
+ }
1581
+ /** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
1582
+ orWhereNotNullPivot(column) {
1583
+ return this.#pushPivotOp("or", column, "IS NOT NULL", null);
1584
+ }
1585
+ /** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
1586
+ #pushPivot(type, column, operatorOrValue, value) {
1587
+ return value === undefined
1588
+ ? this.#pushPivotOp(type, column, "=", operatorOrValue)
1589
+ : this.#pushPivotOp(type, column, operatorOrValue, value);
1590
+ }
1591
+ #pushPivotOp(type, column, operator, value) {
1592
+ this.#pivotWheres.push({ column, operator, value, type });
1593
+ return this;
1594
+ }
1595
+ /** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
1596
+ get pivotConstraints() {
1597
+ return this.#pivotWheres;
1598
+ }
579
1599
  limit(n) {
580
1600
  // Guard here with a clear message — the Rust spec types limit as
581
1601
  // u64, so a negative/non-integer otherwise surfaces as a cryptic
@@ -611,6 +1631,21 @@ export class ModelQuery {
611
1631
  throw new Error(`No ${this.#tableName} found matching query`);
612
1632
  return result;
613
1633
  }
1634
+ /**
1635
+ * Return the single matching row, or throw if there are zero OR more than one
1636
+ * (AdonisJS/Laravel `sole`). Use when exactly one row is a correctness
1637
+ * invariant — a second match signals a bug the silent `first()` would hide.
1638
+ */
1639
+ async sole() {
1640
+ const rows = await this.limit(2).exec();
1641
+ if (rows.length === 0) {
1642
+ throw new Error(`No ${this.#tableName} found matching query (sole()).`);
1643
+ }
1644
+ if (rows.length > 1) {
1645
+ throw new Error(`Expected exactly one ${this.#tableName} but the query matched multiple rows (sole()).`);
1646
+ }
1647
+ return rows[0];
1648
+ }
614
1649
  /**
615
1650
  * Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
616
1651
  * A chain like `await repo.query().where('active', true).orderBy('id')`
@@ -627,14 +1662,79 @@ export class ModelQuery {
627
1662
  return this.exec().then(onfulfilled, onrejected);
628
1663
  }
629
1664
  /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
1665
+ /**
1666
+ * DB column backing the soft-delete `deletedAt` property — honours a
1667
+ * `@Column({ columnName })` override, read straight from the entity metadata
1668
+ * (not the resolver callback, which is identity for subqueries/preloads).
1669
+ */
1670
+ #deletedAtColumn() {
1671
+ const col = this.#entityClass
1672
+ ? getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === "deletedAt")
1673
+ : undefined;
1674
+ return col?.columnName ?? "deleted_at";
1675
+ }
630
1676
  #buildSpec() {
1677
+ // `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
1678
+ // compiler emits the lock clause only when a base mode is set, so a lone
1679
+ // modifier would be a SILENT no-op (dangerous for job-queue polling that
1680
+ // believes it skips locked rows). Fail loud instead. Order-independent: this
1681
+ // fires whether the modifier was chained before or after the base lock.
1682
+ if (this.#lockModifier && !this.#lockMode) {
1683
+ 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).`);
1684
+ }
1685
+ // With a JOIN and the default `SELECT *`, scope the projection to the base
1686
+ // table's declared columns so joined columns can't clobber the model's fields
1687
+ // (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
1688
+ // AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
1689
+ let selectCols = this.#select;
1690
+ if (this.#joins.length > 0 &&
1691
+ this.#select.length === 1 &&
1692
+ this.#select[0] === "*") {
1693
+ const cols = getColumnMetadata(this.#entityClass).map((c) => `${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`);
1694
+ if (cols.length > 0)
1695
+ selectCols = cols;
1696
+ }
1697
+ else if (!(selectCols.length === 1 && selectCols[0] === "*") &&
1698
+ selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))) {
1699
+ // A partial `select()` of PLAIN columns that omits the primary key would
1700
+ // hydrate a persisted entity with no PK — a later save() would then INSERT
1701
+ // instead of UPDATE (double-write / unique violation / spurious
1702
+ // beforeCreate). Auto-include the (base-table-qualified) PK so model
1703
+ // entities stay saveable. Aggregate/alias/expression selects are left
1704
+ // untouched — use `.pojo()` for those.
1705
+ const pkProp = getPrimaryKey(this.#entityClass);
1706
+ if (pkProp) {
1707
+ const pkCol = getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === pkProp)?.columnName ?? camelToSnake(pkProp);
1708
+ // The PK counts as present ONLY as the bare column or the BASE-table-
1709
+ // qualified column. A joined `other.id` must NOT satisfy it (its leaf
1710
+ // collides with the PK name but it's a different table's row) — otherwise
1711
+ // we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
1712
+ // save(). Appended last, `base.id` also wins the duplicate result key
1713
+ // (rows collect in column order, last-wins) so the base row's PK hydrates.
1714
+ const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
1715
+ if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
1716
+ selectCols = [...selectCols, baseQualifiedPk];
1717
+ }
1718
+ }
1719
+ }
631
1720
  const wheres = [...this.#wheres];
632
- // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes
1721
+ // Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
1722
+ // `.wherePivot()` recorded since the proxy handed back this query (pushed to
1723
+ // the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
1724
+ if (this.#pivotExists) {
1725
+ const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
1726
+ wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
1727
+ }
1728
+ // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
1729
+ // Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
1730
+ // override on the soft-delete column is honoured on the read side too — matching
1731
+ // the write side (delete/restore go through #dbColumn).
633
1732
  if (this.#softDeletes) {
1733
+ const deletedAtCol = this.#deletedAtColumn();
634
1734
  if (this.#softScope === "default") {
635
1735
  wheres.push({
636
1736
  type: "and",
637
- column: "deleted_at",
1737
+ column: deletedAtCol,
638
1738
  operator: "IS NULL",
639
1739
  value: null,
640
1740
  });
@@ -642,7 +1742,7 @@ export class ModelQuery {
642
1742
  else if (this.#softScope === "only-trashed") {
643
1743
  wheres.push({
644
1744
  type: "and",
645
- column: "deleted_at",
1745
+ column: deletedAtCol,
646
1746
  operator: "IS NOT NULL",
647
1747
  value: null,
648
1748
  });
@@ -652,19 +1752,36 @@ export class ModelQuery {
652
1752
  return {
653
1753
  kind: "select",
654
1754
  table: this.#tableName,
655
- select: this.#select,
1755
+ select: selectCols,
656
1756
  selectSubqueries: this.#selectSubqueries,
657
1757
  wheres,
658
1758
  orderBy: this.#orderBys,
659
- groupBy: [],
660
- having: [],
1759
+ groupBy: this.#groupBy,
1760
+ having: this.#having,
661
1761
  limit: this.#limit ?? null,
662
1762
  offset: this.#offset ?? null,
663
1763
  distinct: this.#distinct,
664
- ctes: [],
665
- unions: [],
1764
+ distinctOn: this.#distinctOn,
1765
+ ctes: this.#ctes.map((c) => {
1766
+ const { sql, params } = c.query.toSQL();
1767
+ return {
1768
+ name: c.name,
1769
+ sql,
1770
+ params,
1771
+ recursive: c.recursive ?? false,
1772
+ materialized: c.materialized ?? null,
1773
+ };
1774
+ }),
1775
+ unions: this.#unions.map((u) => {
1776
+ const { sql, params } = u.query.toSQL();
1777
+ return { sql, params, all: u.all, op: u.op ?? null };
1778
+ }),
666
1779
  joins: this.#joins,
667
- lockMode: this.#lockMode,
1780
+ lockMode: this.#lockMode
1781
+ ? this.#lockModifier
1782
+ ? `${this.#lockMode} ${this.#lockModifier}`
1783
+ : this.#lockMode
1784
+ : null,
668
1785
  };
669
1786
  }
670
1787
  /** Build SQL + params via the Rust query compiler. */
@@ -695,7 +1812,7 @@ export class ModelQuery {
695
1812
  }
696
1813
  async #doExec() {
697
1814
  const { sql, params } = this.toSQL();
698
- const rawRows = await this.#db.query(sql, params);
1815
+ const rawRows = await this.#db.query(sql, params, this.#meta("exec"));
699
1816
  // Peel withCount / withAggregate alias columns off the raw row into $extras
700
1817
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
701
1818
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -710,6 +1827,9 @@ export class ModelQuery {
710
1827
  const entity = this.#hydrateFn(row);
711
1828
  for (const [k, v] of Object.entries(picked))
712
1829
  entity.setExtra(k, v);
1830
+ // Thread query-level sideloaded context onto each hydrated instance.
1831
+ if (this.#sideloaded)
1832
+ entity.$sideloaded = { ...this.#sideloaded };
713
1833
  return entity;
714
1834
  });
715
1835
  // Resolve preloads (eager loading)
@@ -718,6 +1838,25 @@ export class ModelQuery {
718
1838
  }
719
1839
  return entities;
720
1840
  }
1841
+ /**
1842
+ * Execute and return PLAIN row objects (raw snake_case DB columns), skipping
1843
+ * model hydration, `@column({ consume })`, dirty-tracking and preloads —
1844
+ * AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
1845
+ * instances aren't needed.
1846
+ */
1847
+ async pojo() {
1848
+ const { sql, params } = this.toSQL();
1849
+ return this.#db.query(sql, params);
1850
+ }
1851
+ /**
1852
+ * Thread arbitrary context onto every instance this query hydrates, exposed as
1853
+ * `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
1854
+ * user, so hooks/computed can read it. Merges across calls. Chainable.
1855
+ */
1856
+ sideload(values) {
1857
+ this.#sideloaded = { ...this.#sideloaded, ...values };
1858
+ return this;
1859
+ }
721
1860
  /** Resolve preloaded relations via batched subqueries (no N+1). */
722
1861
  async #resolvePreloads(entities) {
723
1862
  if (!this.#entityClass)
@@ -737,33 +1876,84 @@ export class ModelQuery {
737
1876
  /** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
738
1877
  #buildPreloadContext(relation, relationName) {
739
1878
  const relatedClass = relation.target();
740
- const relatedMeta = getEntityMetadata(relatedClass);
741
- if (!relatedMeta)
742
- return null;
1879
+ // Boot the related model's metadata on demand (Lucid parity): a preload
1880
+ // must not silently no-op just because the related class hasn't been
1881
+ // touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
1882
+ // static table / naming strategy when the decorator hasn't run.
1883
+ const relatedMeta = ensureEntityMetadata(relatedClass);
743
1884
  // Resolve row keys against declared column metadata, NOT `in entity` —
744
1885
  // entities using Adonis' `declare field: T` pattern have no own-properties
745
1886
  // on a freshly constructed instance, so `key in entity` is always false and
746
1887
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
747
1888
  const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
748
1889
  const validColumns = new Set();
1890
+ // Reverse map (db column → property) so an explicit `@Column({ columnName })`
1891
+ // on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
1892
+ const byDbName = new Map();
1893
+ // Capture the related model's `@Column({ consume })` adapters + its date
1894
+ // columns so preloaded rows hydrate identically to a direct query — dates
1895
+ // become Chronos DateTime, decimal/etc adapters run. Without this, a
1896
+ // preloaded relation left column values raw (Lucid parity bug + a runtime
1897
+ // footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
1898
+ const consumes = new Map();
1899
+ let relatedPkDb = camelToSnake(relatedPkName);
749
1900
  for (const col of getColumnMetadata(relatedClass)) {
1901
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
750
1902
  validColumns.add(col.propertyKey);
751
- validColumns.add(camelToSnake(col.propertyKey));
1903
+ validColumns.add(db);
1904
+ byDbName.set(db, col.propertyKey);
1905
+ if (col.consume)
1906
+ consumes.set(col.propertyKey, col.consume);
1907
+ // The related PK may be multi-word (postId→post_id) or columnName-mapped;
1908
+ // its DB column name is what the WHERE + row indexing must use.
1909
+ if (col.propertyKey === relatedPkName)
1910
+ relatedPkDb = db;
752
1911
  }
753
1912
  validColumns.add(relatedPkName);
754
1913
  validColumns.add(camelToSnake(relatedPkName));
1914
+ const dateCols = getDateColumnConfig(relatedClass);
1915
+ const consumeValue = (prop, value, model) => {
1916
+ const c = consumes.get(prop);
1917
+ // Adonis Lucid signature: (value, attribute, model).
1918
+ if (c)
1919
+ return c(value, prop, model);
1920
+ if (dateCols[prop] && value != null)
1921
+ return dateTimeAtlasAdapter.consume(value);
1922
+ return value;
1923
+ };
1924
+ // A repository for the related model so preloaded instances are hydrated with
1925
+ // the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
1926
+ // not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
1927
+ // refresh()/fresh()/load()/related(). Without this a preloaded relation
1928
+ // looked $isNew/$isLocal/$dirty and a later save() over-updated it.
1929
+ const relatedRepo = new BaseRepository(relatedClass, this.#db, {
1930
+ dialect: this.#dialect,
1931
+ });
1932
+ // Propagate the domain-event bus so save()/create() from a preloaded relation
1933
+ // still dispatch events (a fresh repo has none by default).
1934
+ relatedRepo.onDomainEvents = this.#onDomainEvents;
755
1935
  const hydrate = (row) => {
756
1936
  const entity = new relatedClass();
757
1937
  for (const [key, value] of Object.entries(row)) {
758
1938
  const camelKey = snakeToCamel(key);
759
- const targetKey = validColumns.has(camelKey)
760
- ? camelKey
761
- : validColumns.has(key)
762
- ? key
763
- : null;
1939
+ const targetKey = byDbName.get(key) ??
1940
+ (validColumns.has(camelKey)
1941
+ ? camelKey
1942
+ : validColumns.has(key)
1943
+ ? key
1944
+ : null);
764
1945
  if (targetKey !== null)
765
- entity.setProp(targetKey, value);
1946
+ entity.setProp(targetKey, consumeValue(targetKey, value, entity));
766
1947
  }
1948
+ // Freeze the clean snapshot + mark persisted/from-DB, and back-reference
1949
+ // the related repo (mirrors BaseRepository.#hydrate).
1950
+ entity.markAsPersisted();
1951
+ entity.markAsFromDatabase();
1952
+ Object.defineProperty(entity, REPO_REF, {
1953
+ value: relatedRepo,
1954
+ enumerable: false,
1955
+ configurable: true,
1956
+ });
767
1957
  return entity;
768
1958
  };
769
1959
  return {
@@ -771,7 +1961,9 @@ export class ModelQuery {
771
1961
  relationName,
772
1962
  relatedClass,
773
1963
  relatedTable: relatedMeta.tableName,
774
- relatedPk: getPrimaryKey(relatedClass) ?? "id",
1964
+ // DB column name (not property) used as the WHERE column in the related
1965
+ // query AND to index the returned DB rows by their PK value.
1966
+ relatedPk: relatedPkDb,
775
1967
  hydrate,
776
1968
  runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
777
1969
  runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
@@ -805,15 +1997,17 @@ export class ModelQuery {
805
1997
  throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
806
1998
  }
807
1999
  const throughClass = relation.through();
808
- const throughMeta = getEntityMetadata(throughClass);
809
- if (!throughMeta)
810
- throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
2000
+ const throughMeta = ensureEntityMetadata(throughClass);
811
2001
  const throughTable = throughMeta.tableName;
812
2002
  const throughPk = getPrimaryKey(throughClass) ?? "id";
813
2003
  const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
814
2004
  const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
815
2005
  const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
816
- const secondLocal = relation.secondLocalKey ?? throughPk;
2006
+ // secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
2007
+ // DB column — resolve the through model's key (default: its PK), honouring a
2008
+ // multi-word / columnName PK. (parentLocal stays a property: it's read off
2009
+ // the parent ENTITY, not a row.)
2010
+ const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
817
2011
  const parentIds = entities
818
2012
  .map((e) => e[parentLocal])
819
2013
  .filter((v) => v != null);
@@ -942,12 +2136,53 @@ export class ModelQuery {
942
2136
  // on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
943
2137
  const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
944
2138
  const otherKey = pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
945
- const pk = getPrimaryKey(this.#entityClass) ?? "id";
2139
+ // The pivot FK stores `parent[localKey]` (default PK) attach() writes it,
2140
+ // so preload MUST read back with the SAME key, else a custom-localKey m2m
2141
+ // writes `user_code = code` but reads `user_code IN (id)` and never matches.
2142
+ const pk = ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
946
2143
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
947
2144
  if (ids.length === 0)
948
2145
  return [];
949
- // Step 1 — pivot table: find (foreignKey otherKey) pairs
950
- const pivotRows = await ctx.runInQuery(pivot.pivotTable, foreignKey, ids);
2146
+ // Extract PIVOT-table constraints (wherePivot / wherePivotIn) from the
2147
+ // preload callback by replaying it on a throwaway builder. The callback
2148
+ // also runs (again) inside runRelationQuery against the related table; both
2149
+ // runs are pure builder mutations, and pivot constraints are inert there.
2150
+ const pivotWheres = [];
2151
+ if (ctx.nestedCallback) {
2152
+ const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
2153
+ ctx.nestedCallback(scratch);
2154
+ // Apply the pivot column adapters' `prepare` to wherePivot values, so a
2155
+ // filter like wherePivot('amount', new Money(1)) matches what attach()/
2156
+ // sync() stored (they prepare the same extras on write).
2157
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
2158
+ for (const c of scratch.pivotConstraints) {
2159
+ const prep = pivotAdapters[c.column]?.prepare;
2160
+ // Same guards as the attach()/sync() write path: wrap a throwing
2161
+ // adapter with a column-annotated error and reject async adapters,
2162
+ // so filter and write agree on the adapter contract.
2163
+ const apply = (v) => {
2164
+ if (!prep)
2165
+ return v;
2166
+ let out;
2167
+ try {
2168
+ // Adonis Lucid signature: (value, attribute, model). wherePivot is
2169
+ // a query filter — attribute known, no model instance.
2170
+ out = prep(v, c.column, undefined);
2171
+ }
2172
+ catch (err) {
2173
+ throw wrapAdapterError("prepare", c.column, err);
2174
+ }
2175
+ assertNotPromise("prepare", c.column, out);
2176
+ return out;
2177
+ };
2178
+ const value = Array.isArray(c.value)
2179
+ ? c.value.map(apply)
2180
+ : apply(c.value);
2181
+ pivotWheres.push({ ...c, value });
2182
+ }
2183
+ }
2184
+ // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
2185
+ const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
951
2186
  if (pivotRows.length === 0) {
952
2187
  for (const entity of entities)
953
2188
  entity.setProp(relationName, []);
@@ -958,17 +2193,46 @@ export class ModelQuery {
958
2193
  ];
959
2194
  // Step 2 — load all related entities in one query
960
2195
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
2196
+ const pivotCols = pivot.pivotColumns ?? [];
2197
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
2198
+ // When pivot extras are projected, each (parent, related) edge gets its OWN
2199
+ // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
2200
+ // across parents (Lucid gives distinct pivot-bearing instances). Otherwise a
2201
+ // single shared instance per related PK is reused (cheaper, current behaviour).
2202
+ const projectPivot = pivotCols.length > 0;
2203
+ const rawByRelatedPk = new Map();
961
2204
  const byRelatedPk = new Map();
962
2205
  const allRelated = [];
963
2206
  for (const row of relRows) {
964
- const hydrated = ctx.hydrate(row);
965
- byRelatedPk.set(row[ctx.relatedPk], hydrated);
966
- allRelated.push(hydrated);
2207
+ rawByRelatedPk.set(row[ctx.relatedPk], row);
2208
+ if (!projectPivot) {
2209
+ const hydrated = ctx.hydrate(row);
2210
+ byRelatedPk.set(row[ctx.relatedPk], hydrated);
2211
+ allRelated.push(hydrated);
2212
+ }
967
2213
  }
968
- // Step 3 — group via the pivot
2214
+ // Step 3 — group via the pivot, projecting declared pivotColumns into
2215
+ // `$extras.pivot_<col>` (running each column's `consume` adapter if any).
969
2216
  const grouped = new Map();
970
2217
  for (const pivotRow of pivotRows) {
971
- const related = byRelatedPk.get(pivotRow[otherKey]);
2218
+ let related;
2219
+ if (projectPivot) {
2220
+ const raw = rawByRelatedPk.get(pivotRow[otherKey]);
2221
+ if (!raw)
2222
+ continue;
2223
+ related = ctx.hydrate(raw);
2224
+ for (const col of pivotCols) {
2225
+ const rawVal = pivotRow[col];
2226
+ const adapter = pivotAdapters[col];
2227
+ related.setExtra(`pivot_${col}`,
2228
+ // Adonis Lucid signature: (value, attribute, model).
2229
+ adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal);
2230
+ }
2231
+ allRelated.push(related);
2232
+ }
2233
+ else {
2234
+ related = byRelatedPk.get(pivotRow[otherKey]);
2235
+ }
972
2236
  if (!related)
973
2237
  continue;
974
2238
  const parentId = pivotRow[foreignKey];
@@ -985,20 +2249,41 @@ export class ModelQuery {
985
2249
  async #applyNestedPreloads(relatedEntities, ctx) {
986
2250
  if (!ctx.nestedCallback || relatedEntities.length === 0)
987
2251
  return;
988
- const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass);
2252
+ 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));
989
2253
  ctx.nestedCallback(sub);
990
2254
  if (sub.#preloads.size > 0) {
991
2255
  await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
992
2256
  }
993
2257
  }
994
2258
  /** Compile + execute a `SELECT * FROM <table> WHERE <column> IN (...)` via the Rust compiler. */
995
- async #runInQuery(table, column, values) {
2259
+ async #runInQuery(table, column, values, extraWheres = []) {
2260
+ const wheres = [
2261
+ { column, operator: "IN", value: values, type: "and" },
2262
+ ];
2263
+ // The caller's filters go in a parenthesised group, never flat beside the
2264
+ // `IN`. Flat, an `orWherePivot` would read as
2265
+ // `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
2266
+ // other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
2267
+ // With every filter ANDed the two forms are equivalent, so this changes
2268
+ // no existing query.
2269
+ if (extraWheres.length > 0) {
2270
+ wheres.push({
2271
+ kind: "group",
2272
+ type: "and",
2273
+ conditions: extraWheres.map((w) => ({
2274
+ column: w.column,
2275
+ operator: w.operator,
2276
+ value: w.value,
2277
+ type: w.type ?? "and",
2278
+ })),
2279
+ });
2280
+ }
996
2281
  const spec = {
997
2282
  kind: "select",
998
2283
  table,
999
2284
  select: ["*"],
1000
2285
  selectSubqueries: [],
1001
- wheres: [{ column, operator: "IN", value: values, type: "and" }],
2286
+ wheres,
1002
2287
  orderBy: [],
1003
2288
  groupBy: [],
1004
2289
  having: [],
@@ -1025,7 +2310,11 @@ export class ModelQuery {
1025
2310
  * inside the callback are re-collected later by `#applyNestedPreloads`.
1026
2311
  */
1027
2312
  async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
1028
- const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c,
2313
+ const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
2314
+ // Resolve columns + prepare values against the RELATED model so a preload
2315
+ // constraint (onQuery / callback) targeting a columnName-mapped or date
2316
+ // column compiles/binds like a direct query on that model.
2317
+ buildColumnResolver(relatedClass),
1029
2318
  // Propagate the RELATED entity's soft-delete flag — hardcoding
1030
2319
  // false here meant `preload('posts')` returned soft-deleted
1031
2320
  // posts even when Post is @SoftDeletes (a data leak). The
@@ -1033,7 +2322,7 @@ export class ModelQuery {
1033
2322
  // matching a direct query on that entity. (with-trashed on the
1034
2323
  // related set, if ever needed, would be opted-in via the
1035
2324
  // preload callback.)
1036
- hasSoftDeletes(relatedClass), this.#dialect);
2325
+ hasSoftDeletes(relatedClass), this.#dialect, buildValuePreparer(relatedClass));
1037
2326
  sub.whereIn(column, values);
1038
2327
  if (relation.onQuery)
1039
2328
  relation.onQuery(sub);
@@ -1072,28 +2361,50 @@ export class ModelQuery {
1072
2361
  throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
1073
2362
  }
1074
2363
  const relatedClass = relation.target();
1075
- const relatedMeta = getEntityMetadata(relatedClass);
1076
- if (!relatedMeta) {
1077
- throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
1078
- }
2364
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1079
2365
  const relatedTable = relatedMeta.tableName;
1080
2366
  const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
1081
2367
  const parentTable = this.#tableName;
1082
- const q = this.#dialect === "mysql"
1083
- ? (name) => `\`${name}\``
1084
- : (name) => `"${name}"`;
1085
- const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c, false, this.#dialect);
2368
+ // Strict single-segment identifier quote. This builds a RAW correlated
2369
+ // subquery fragment (no bind params for identifiers), so every segment must
2370
+ // be validated — a table/key from relation metadata carrying a quote/backtick
2371
+ // would otherwise emit invalid or injectable SQL. Same policy as
2372
+ // BaseRepository's lazy m2m path.
2373
+ const q = (name) => {
2374
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
2375
+ throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
2376
+ }
2377
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
2378
+ };
2379
+ // Table identifiers may be schema-qualified (`schema.table`) — quote each
2380
+ // dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
2381
+ // `public.users_roles` gets wrapped as ONE identifier and silently targets a
2382
+ // table literally named with a dot. Each segment still passes the strict
2383
+ // guard above. Columns stay single-segment via `q`.
2384
+ const qTable = (name) => name.split(".").map(q).join(".");
2385
+ const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
2386
+ // whereHas/withCount constraints run against the RELATED model — resolve
2387
+ // its columns (columnName/multi-word) and prepare its values like a direct query.
2388
+ buildColumnResolver(relatedClass), false, this.#dialect, buildValuePreparer(relatedClass));
2389
+ // `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
2390
+ // PK); resolve each to its DB column via the owning model so a multi-word or
2391
+ // `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
2392
+ // `firstKey`/`secondKey` are DB column names already — left as-is.
2393
+ const resolveParent = buildColumnResolver(this.#entityClass);
1086
2394
  switch (relation.type) {
1087
2395
  case "hasOne":
1088
2396
  case "hasMany": {
1089
- const fk = `${camelToSnake(this.#entityClass.name)}_id`;
1090
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(parentPk)}`);
2397
+ // Honour custom foreignKey/localKey exactly like the eager loader —
2398
+ // hard-coding them here produced silently-wrong whereHas/withCount SQL.
2399
+ const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2400
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2401
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`);
1091
2402
  break;
1092
2403
  }
1093
2404
  case "belongsTo": {
1094
- const fk = `${camelToSnake(relatedClass.name)}_id`;
1095
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1096
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} = ${q(parentTable)}.${q(fk)}`);
2405
+ const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
2406
+ const ownerKey = buildColumnResolver(relatedClass)(relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id");
2407
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`);
1097
2408
  break;
1098
2409
  }
1099
2410
  case "manyToMany": {
@@ -1105,20 +2416,35 @@ export class ModelQuery {
1105
2416
  // name stripped of a trailing `s` — see the eager loader above.
1106
2417
  const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1107
2418
  const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1108
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1109
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} IN ` +
1110
- `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1111
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(parentPk)})`);
2419
+ const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
2420
+ const relatedPk = getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPkProp)?.columnName ?? camelToSnake(relatedPkProp);
2421
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2422
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(relatedPk)} IN ` +
2423
+ `(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
2424
+ `WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`);
2425
+ break;
2426
+ }
2427
+ case "hasOneThrough":
2428
+ case "hasManyThrough": {
2429
+ // Two-hop correlated EXISTS: parent → through → related. Mirrors the
2430
+ // eager loader's key resolution (`#resolveThrough`) exactly so
2431
+ // whereHas/withCount agree with what preload() would return.
2432
+ if (!relation.through) {
2433
+ throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
2434
+ }
2435
+ const throughClass = relation.through();
2436
+ const throughMeta = ensureEntityMetadata(throughClass);
2437
+ const throughTable = throughMeta.tableName;
2438
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
2439
+ const parentLocal = resolveParent(relation.localKey ?? parentPk);
2440
+ const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2441
+ const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
2442
+ const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
2443
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(secondKey)} IN ` +
2444
+ `(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
2445
+ `WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`);
1112
2446
  break;
1113
2447
  }
1114
- default:
1115
- // hasOneThrough / hasManyThrough build a 2-hop correlated subquery,
1116
- // which isn't implemented here. Fail loud — falling through would
1117
- // leave `sub` WITHOUT a join predicate, so whereHas/withCount would
1118
- // silently match/count EVERY related row.
1119
- throw new Error(`whereHas/withCount on a '${relation.type}' relation ` +
1120
- `(${this.#entityClass.name}.${relationName}) is not supported yet. ` +
1121
- `Use a direct hasMany/belongsTo/manyToMany relation, or filter via a sub-query.`);
1122
2448
  }
1123
2449
  return sub;
1124
2450
  }
@@ -1132,8 +2458,8 @@ export class ModelQuery {
1132
2458
  return this.#pushJoin("RIGHT", table, leftOrBuild, right);
1133
2459
  }
1134
2460
  crossJoin(table) {
1135
- const tq = this.#quote(table);
1136
- this.#joins.push(`CROSS JOIN ${tq}`);
2461
+ const tq = this.#quoteCol(table);
2462
+ this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
1137
2463
  return this;
1138
2464
  }
1139
2465
  /**
@@ -1152,12 +2478,12 @@ export class ModelQuery {
1152
2478
  *
1153
2479
  * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
1154
2480
  */
1155
- joinRaw(fragment) {
2481
+ joinRaw(fragment, bindings = []) {
1156
2482
  if (isAtlasStrictMode() && !isInternalBypass()) {
1157
2483
  throw new Error("joinRaw() is disabled in Atlas strict mode. " +
1158
2484
  "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
1159
2485
  }
1160
- this.#joins.push(fragment);
2486
+ this.#joins.push({ sql: fragment, params: [...bindings] });
1161
2487
  return this;
1162
2488
  }
1163
2489
  /**
@@ -1179,9 +2505,37 @@ export class ModelQuery {
1179
2505
  this.#distinct = true;
1180
2506
  return this;
1181
2507
  }
2508
+ /**
2509
+ * `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
2510
+ * `columns` (Lucid/Knex `distinctOn`). Takes precedence over
2511
+ * {@link distinct}.
2512
+ *
2513
+ * Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
2514
+ * would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
2515
+ * return a *different* result set rather than fail — a silent wrong answer
2516
+ * is worse than an error.
2517
+ *
2518
+ * Postgres also requires the leading `ORDER BY` terms to match `columns`;
2519
+ * that is left to the database to enforce.
2520
+ */
2521
+ distinctOn(...columns) {
2522
+ for (const c of columns)
2523
+ this.#distinctOn.push(this.#resolveColumn(c));
2524
+ return this;
2525
+ }
1182
2526
  /** `SELECT COUNT(DISTINCT col)`. */
1183
2527
  async countDistinct(column) {
1184
- return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0);
2528
+ return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`)) ?? 0);
2529
+ }
2530
+ /** `SUM(DISTINCT col)` (Lucid parity). */
2531
+ async sumDistinct(column) {
2532
+ const v = await this.#runScalar(`SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
2533
+ return v === null || v === undefined ? null : Number(v);
2534
+ }
2535
+ /** `AVG(DISTINCT col)` (Lucid parity). */
2536
+ async avgDistinct(column) {
2537
+ const v = await this.#runScalar(`AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
2538
+ return v === null || v === undefined ? null : Number(v);
1185
2539
  }
1186
2540
  /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
1187
2541
  async exists() {
@@ -1189,7 +2543,7 @@ export class ModelQuery {
1189
2543
  clone.#select = ["1"];
1190
2544
  clone.#limit = 1;
1191
2545
  const { sql, params } = clone.toSQL();
1192
- const rows = await this.#db.query(sql, params);
2546
+ const rows = await this.#db.query(sql, params, this.#meta("exists"));
1193
2547
  return rows.length > 0;
1194
2548
  }
1195
2549
  async doesntExist() {
@@ -1258,14 +2612,29 @@ export class ModelQuery {
1258
2612
  // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
1259
2613
  // tenant scope) propagates into both the COUNT and the data fetch.
1260
2614
  await fireHooks(this.#entityClass, "beforePaginate", this);
1261
- // Parallel COUNT(*) + data fetch
2615
+ // COUNT(*) + data fetch
1262
2616
  const countQ = this.clone();
1263
- countQ.#select = ["COUNT(*) AS count"];
1264
2617
  countQ.#limit = undefined;
1265
2618
  countQ.#offset = undefined;
1266
2619
  countQ.#orderBys = [];
1267
- const { sql: cSql, params: cParams } = countQ.toSQL();
1268
- const cRows = await this.#db.query(cSql, cParams);
2620
+ let cSql;
2621
+ let cParams;
2622
+ if (countQ.#groupBy.length > 0) {
2623
+ // A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
2624
+ // group's own size), so `rows[0].count` would be the first group's size, not
2625
+ // the number of pages. Lucid counts via a subquery: wrap the grouped query
2626
+ // (select + groupBy + having preserved) and count its rows = group count.
2627
+ const inner = countQ.toSQL();
2628
+ cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
2629
+ cParams = inner.params;
2630
+ }
2631
+ else {
2632
+ countQ.#select = ["COUNT(*) AS count"];
2633
+ const flat = countQ.toSQL();
2634
+ cSql = flat.sql;
2635
+ cParams = flat.params;
2636
+ }
2637
+ const cRows = await this.#db.query(cSql, cParams, this.#meta("paginate"));
1269
2638
  const total = Number(cRows[0]?.count ?? 0);
1270
2639
  const dataQ = this.clone();
1271
2640
  dataQ.#limit = pp;
@@ -1274,7 +2643,10 @@ export class ModelQuery {
1274
2643
  // top of the paginate hooks — paginate is its own terminal.
1275
2644
  const items = await dataQ.#doExec();
1276
2645
  await fireHooks(this.#entityClass, "afterPaginate", items);
1277
- return new Paginator(items, { total, perPage: pp, currentPage: p });
2646
+ const metaKeys = this.#entityClass
2647
+ ? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
2648
+ : undefined;
2649
+ return new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
1278
2650
  }
1279
2651
  /**
1280
2652
  * Cursor-based pagination — base64 opaque keyset, multi-column aware.
@@ -1290,7 +2662,13 @@ export class ModelQuery {
1290
2662
  * accepts row-value comparisons.
1291
2663
  */
1292
2664
  async cursorPaginate(opts) {
1293
- const cols = (Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]).map((c) => this.#resolveColumn(c));
2665
+ // Keep BOTH forms: `props` (model property names) to read the cursor value
2666
+ // off the hydrated entity, and `cols` (resolved DB columns) for the SQL
2667
+ // ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
2668
+ // encode `undefined` into the cursor (entity exposes the property, not the
2669
+ // DB column) — an unstable / stuck cursor.
2670
+ const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
2671
+ const cols = props.map((c) => this.#resolveColumn(c));
1294
2672
  if (cols.length === 0)
1295
2673
  throw new Error("cursorPaginate requires at least one orderBy column");
1296
2674
  const lim = Math.max(1, Math.floor(opts.limit));
@@ -1334,7 +2712,7 @@ export class ModelQuery {
1334
2712
  const items = hasMore ? rows.slice(0, lim) : rows;
1335
2713
  const last = items[items.length - 1];
1336
2714
  const nextCursor = hasMore && last
1337
- ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString("base64")
2715
+ ? Buffer.from(JSON.stringify({ v: props.map((p) => last[p]) })).toString("base64")
1338
2716
  : null;
1339
2717
  return { items, nextCursor, hasMore };
1340
2718
  }
@@ -1351,6 +2729,21 @@ export class ModelQuery {
1351
2729
  this.#debugFlag = flag;
1352
2730
  return this;
1353
2731
  }
2732
+ /**
2733
+ * Context attached to each statement this query runs, so a `db:query`
2734
+ * listener can say which model and which call produced it — and so
2735
+ * {@link debug} can force emission for this query alone.
2736
+ *
2737
+ * Note the connection's own `debug: true` emits every statement regardless;
2738
+ * `meta` only enriches the event and opens the per-query override.
2739
+ */
2740
+ #meta(method) {
2741
+ return {
2742
+ model: this.#entityClass.name,
2743
+ method,
2744
+ debug: this.#debugFlag,
2745
+ };
2746
+ }
1354
2747
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
1355
2748
  toQuery() {
1356
2749
  const { sql, params } = this.toSQL();
@@ -1362,7 +2755,7 @@ export class ModelQuery {
1362
2755
  }
1363
2756
  /** Deep clone of this query — mutations on the clone never affect the original. */
1364
2757
  clone() {
1365
- const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect);
2758
+ const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
1366
2759
  c.#softScope = this.#softScope;
1367
2760
  c.#wheres = structuredCloneSafe(this.#wheres);
1368
2761
  c.#orderBys = [...this.#orderBys];
@@ -1371,9 +2764,29 @@ export class ModelQuery {
1371
2764
  c.#offset = this.#offset;
1372
2765
  c.#preloads = new Map(this.#preloads);
1373
2766
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
1374
- c.#joins = [...this.#joins];
2767
+ c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
1375
2768
  c.#lockMode = this.#lockMode;
2769
+ c.#lockModifier = this.#lockModifier;
2770
+ c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
1376
2771
  c.#distinct = this.#distinct;
2772
+ c.#distinctOn = [...this.#distinctOn];
2773
+ c.#groupBy = [...this.#groupBy];
2774
+ c.#having = structuredCloneSafe(this.#having);
2775
+ c.#ctes = this.#ctes.map((e) => ({
2776
+ name: e.name,
2777
+ query: e.query.clone(),
2778
+ recursive: e.recursive,
2779
+ materialized: e.materialized,
2780
+ }));
2781
+ c.#unions = this.#unions.map((u) => ({
2782
+ query: u.query.clone(),
2783
+ all: u.all,
2784
+ op: u.op,
2785
+ }));
2786
+ c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
2787
+ // Pure closure over pivot metadata — safe to share by reference; it reads the
2788
+ // clone's own #pivotWheres at build time (passed in), holding no query state.
2789
+ c.#pivotExists = this.#pivotExists;
1377
2790
  c.#debugFlag = this.#debugFlag;
1378
2791
  return c;
1379
2792
  }
@@ -1383,13 +2796,15 @@ export class ModelQuery {
1383
2796
  if (!patch || Object.keys(patch).length === 0) {
1384
2797
  throw new Error("update() requires a non-empty payload");
1385
2798
  }
1386
- const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), v]);
2799
+ // Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
2800
+ // like BaseRepository's write paths — the fluent update() must not bypass it.
2801
+ const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), this.#prepareValue(k, v)]);
1387
2802
  const spec = {
1388
2803
  kind: "update",
1389
2804
  table: this.#tableName,
1390
2805
  set: setPairs,
1391
2806
  wheres: this.#wheresForDml(),
1392
- returning: returning ?? [],
2807
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
1393
2808
  };
1394
2809
  const compiled = compileStatementNative(spec, this.#dialect);
1395
2810
  if (returning && returning.length > 0) {
@@ -1398,20 +2813,60 @@ export class ModelQuery {
1398
2813
  const r = await this.#db.execute(compiled.statements[0], compiled.params);
1399
2814
  return r.rowsAffected ?? 0;
1400
2815
  }
1401
- /** Execute a fluent DELETE. Returns affected rows (or rows when `returning` is set). */
2816
+ /**
2817
+ * Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
2818
+ * scoped rows (stamps `deleted_at`) — consistent with the entity-level
2819
+ * `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
2820
+ * model it issues a hard `DELETE`. Returns affected rows (or rows when
2821
+ * `returning` is set).
2822
+ */
1402
2823
  async delete(returning) {
2824
+ if (this.#softDeletes) {
2825
+ const spec = {
2826
+ kind: "update",
2827
+ table: this.#tableName,
2828
+ set: [[this.#deletedAtColumn(), new Date().toISOString()]],
2829
+ wheres: this.#wheresForDml(),
2830
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2831
+ };
2832
+ return this.#runDml(spec, returning);
2833
+ }
2834
+ return this.forceDelete(returning);
2835
+ }
2836
+ /** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
2837
+ async forceDelete(returning) {
1403
2838
  const spec = {
1404
2839
  kind: "delete",
1405
2840
  table: this.#tableName,
1406
2841
  wheres: this.#wheresForDml(),
1407
- returning: returning ?? [],
2842
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
1408
2843
  };
1409
- const compiled = compileStatementNative(spec, this.#dialect);
1410
- if (returning && returning.length > 0) {
1411
- return this.#db.query(compiled.statements[0], compiled.params);
1412
- }
1413
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
1414
- return r.rowsAffected ?? 0;
2844
+ return this.#runDml(spec, returning);
2845
+ }
2846
+ /**
2847
+ * Bulk restore: clear `deleted_at` on the trashed rows matching the user's
2848
+ * predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
2849
+ * on a non-soft-delete model. Independent of the current soft-scope — it always
2850
+ * targets trashed rows (`deleted_at IS NOT NULL`).
2851
+ */
2852
+ async restore(returning) {
2853
+ if (!this.#softDeletes)
2854
+ return 0;
2855
+ const wheres = this.#userWheresForDml();
2856
+ wheres.push({
2857
+ column: this.#deletedAtColumn(),
2858
+ operator: "IS NOT NULL",
2859
+ value: null,
2860
+ type: "and",
2861
+ });
2862
+ const spec = {
2863
+ kind: "update",
2864
+ table: this.#tableName,
2865
+ set: [[this.#deletedAtColumn(), null]],
2866
+ wheres,
2867
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2868
+ };
2869
+ return this.#runDml(spec, returning);
1415
2870
  }
1416
2871
  increment(colOrPatch, amount = 1) {
1417
2872
  return this.#runIncDec("increment", colOrPatch, amount);
@@ -1438,20 +2893,73 @@ export class ModelQuery {
1438
2893
  }
1439
2894
  return this;
1440
2895
  }
2896
+ /** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
2897
+ forNoKeyUpdate() {
2898
+ if (this.#dialect === "postgres") {
2899
+ this.#lockMode = "FOR NO KEY UPDATE";
2900
+ }
2901
+ else {
2902
+ console.warn(`[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`);
2903
+ }
2904
+ return this;
2905
+ }
2906
+ /** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
2907
+ forKeyShare() {
2908
+ if (this.#dialect === "postgres") {
2909
+ this.#lockMode = "FOR KEY SHARE";
2910
+ }
2911
+ else {
2912
+ console.warn(`[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`);
2913
+ }
2914
+ return this;
2915
+ }
2916
+ /**
2917
+ * Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
2918
+ * waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
2919
+ */
2920
+ skipLocked() {
2921
+ if (this.#dialect === "sqlite") {
2922
+ console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
2923
+ }
2924
+ else {
2925
+ this.#lockModifier = "SKIP LOCKED";
2926
+ }
2927
+ return this;
2928
+ }
2929
+ /**
2930
+ * Append `NOWAIT` to the lock clause — error immediately instead of waiting on
2931
+ * a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
2932
+ */
2933
+ noWait() {
2934
+ if (this.#dialect === "sqlite") {
2935
+ console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
2936
+ }
2937
+ else {
2938
+ this.#lockModifier = "NOWAIT";
2939
+ }
2940
+ return this;
2941
+ }
1441
2942
  // === Private helpers ==============================================================================
1442
2943
  #quote(name) {
1443
2944
  return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1444
2945
  }
1445
2946
  /** Quote a `table.column` reference on both sides of the dot. */
1446
2947
  #quoteCol(ref) {
1447
- if (ref.includes(".")) {
1448
- const [t, c] = ref.split(".", 2);
1449
- return `${this.#quote(t)}.${this.#quote(c)}`;
2948
+ // Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
2949
+ // identifier smuggling a `"`/backtick would break out of the quoting on the
2950
+ // join path (which the Rust screen doesn't re-validate). Strict
2951
+ // `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
2952
+ // helpers injection-safe. Use joinRaw() for anything more complex.
2953
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
2954
+ throw new Error(`Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`);
1450
2955
  }
1451
- return this.#quote(ref);
2956
+ return ref
2957
+ .split(".")
2958
+ .map((seg) => this.#quote(seg))
2959
+ .join(".");
1452
2960
  }
1453
2961
  #pushJoin(kind, table, leftOrBuild, right) {
1454
- const tq = this.#quote(table);
2962
+ const tq = this.#quoteCol(table);
1455
2963
  if (typeof leftOrBuild === "function") {
1456
2964
  const jb = {
1457
2965
  parts: [],
@@ -1463,24 +2971,56 @@ export class ModelQuery {
1463
2971
  this.parts.push({ kind: "and", left: l, right: r });
1464
2972
  return this;
1465
2973
  },
1466
- andOnVal(l, _v) {
1467
- this.parts.push({ kind: "and", left: l, right: "?" });
2974
+ orOn(l, r) {
2975
+ this.parts.push({ kind: "or", left: l, right: r });
2976
+ return this;
2977
+ },
2978
+ onVal(l, v) {
2979
+ this.parts.push({ kind: "and", left: l, value: { v } });
2980
+ return this;
2981
+ },
2982
+ andOnVal(l, v) {
2983
+ this.parts.push({ kind: "and", left: l, value: { v } });
2984
+ return this;
2985
+ },
2986
+ orOnVal(l, v) {
2987
+ this.parts.push({ kind: "or", left: l, value: { v } });
1468
2988
  return this;
1469
2989
  },
1470
2990
  };
1471
2991
  leftOrBuild(jb);
2992
+ // Collect the bound values in placeholder order as the fragment is built.
2993
+ const params = [];
1472
2994
  const on = jb.parts
1473
2995
  .map((p, i) => {
1474
2996
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
1475
- return `${prefix} ${this.#quoteCol(p.left)} = ${p.right === "?" ? "?" : this.#quoteCol(p.right)}`;
2997
+ if (p.value) {
2998
+ // A BASE-table column runs the full model prepare (DateTime→ISO +
2999
+ // @Column adapters/casts), keyed by its property. A FOREIGN join
3000
+ // column must NOT borrow the root model's adapter for a same-named
3001
+ // column on another table — apply only universal type-lowering
3002
+ // (Date/DateTime→ISO), matching Knex's model-agnostic join binding.
3003
+ const dot = p.left.lastIndexOf(".");
3004
+ const tablePrefix = dot >= 0 ? p.left.slice(0, dot) : "";
3005
+ const leaf = dot >= 0 ? p.left.slice(dot + 1) : p.left;
3006
+ const isBaseColumn = tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
3007
+ params.push(isBaseColumn
3008
+ ? this.#prepareValue(leaf, p.value.v)
3009
+ : lowerJoinValue(p.value.v));
3010
+ return `${prefix} ${this.#quoteCol(p.left)} = ?`;
3011
+ }
3012
+ return `${prefix} ${this.#quoteCol(p.left)} = ${this.#quoteCol(p.right ?? "")}`;
1476
3013
  })
1477
3014
  .join(" ");
1478
- this.#joins.push(`${kind} JOIN ${tq} ${on}`);
3015
+ this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
1479
3016
  return this;
1480
3017
  }
1481
3018
  if (right === undefined)
1482
3019
  throw new Error("join() with string form requires both left and right operands");
1483
- this.#joins.push(`${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`);
3020
+ this.#joins.push({
3021
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
3022
+ params: [],
3023
+ });
1484
3024
  return this;
1485
3025
  }
1486
3026
  async #runScalar(expr) {
@@ -1512,7 +3052,8 @@ export class ModelQuery {
1512
3052
  * still rejected because the DML compiler's WHERE lowering does not yet
1513
3053
  * handle nested sub-queries or correlated EXISTS.
1514
3054
  */
1515
- #wheresForDml() {
3055
+ /** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
3056
+ #userWheresForDml() {
1516
3057
  const out = [];
1517
3058
  for (const w of this.#wheres) {
1518
3059
  if ("kind" in w) {
@@ -1537,6 +3078,43 @@ export class ModelQuery {
1537
3078
  }
1538
3079
  return out;
1539
3080
  }
3081
+ #wheresForDml() {
3082
+ const out = this.#userWheresForDml();
3083
+ // Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
3084
+ // update/delete/increment/decrement must NOT touch trashed rows under the
3085
+ // default scope — otherwise `query().where(x)` would denote a different row
3086
+ // set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
3087
+ // `.onlyTrashed()` restricts to trashed (mirrors reads).
3088
+ if (this.#softDeletes) {
3089
+ const deletedAtCol = this.#deletedAtColumn();
3090
+ if (this.#softScope === "default") {
3091
+ out.push({
3092
+ column: deletedAtCol,
3093
+ operator: "IS NULL",
3094
+ value: null,
3095
+ type: "and",
3096
+ });
3097
+ }
3098
+ else if (this.#softScope === "only-trashed") {
3099
+ out.push({
3100
+ column: deletedAtCol,
3101
+ operator: "IS NOT NULL",
3102
+ value: null,
3103
+ type: "and",
3104
+ });
3105
+ }
3106
+ }
3107
+ return out;
3108
+ }
3109
+ /** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
3110
+ async #runDml(spec, returning) {
3111
+ const compiled = compileStatementNative(spec, this.#dialect);
3112
+ if (returning && returning.length > 0) {
3113
+ return this.#db.query(compiled.statements[0], compiled.params);
3114
+ }
3115
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
3116
+ return r.rowsAffected ?? 0;
3117
+ }
1540
3118
  /**
1541
3119
  * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
1542
3120
  *
@@ -1569,7 +3147,7 @@ export class ModelQuery {
1569
3147
  * groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
1570
3148
  */
1571
3149
  #buildGroup(type, callback) {
1572
- const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect);
3150
+ const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect, this.#prepareValue);
1573
3151
  callback(scratch);
1574
3152
  return { type, kind: "group", conditions: scratch.#wheres };
1575
3153
  }
@@ -1609,7 +3187,7 @@ export class ModelQuery {
1609
3187
  type,
1610
3188
  column: resolved,
1611
3189
  operator: "=",
1612
- value: operatorOrValue,
3190
+ value: this.#prep(column, operatorOrValue),
1613
3191
  });
1614
3192
  }
1615
3193
  else {
@@ -1617,11 +3195,21 @@ export class ModelQuery {
1617
3195
  type,
1618
3196
  column: resolved,
1619
3197
  operator: operatorOrValue,
1620
- value,
3198
+ value: this.#prep(column, value),
1621
3199
  });
1622
3200
  }
1623
3201
  return this;
1624
3202
  }
3203
+ /**
3204
+ * Lower a WHERE/search value (or each element of an array) to its DB form via
3205
+ * the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
3206
+ * adapter column used as a predicate binds the same shape the write path stores.
3207
+ */
3208
+ #prep(column, value) {
3209
+ return Array.isArray(value)
3210
+ ? value.map((v) => this.#prepareValue(column, v))
3211
+ : this.#prepareValue(column, value);
3212
+ }
1625
3213
  /**
1626
3214
  * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
1627
3215
  * Used by the nested-preload machinery to recurse without re-running the root select.