@c9up/atlas 0.1.19 → 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 (121) 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 +164 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +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 +71 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1355 -384
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +341 -18
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1406 -177
  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/TableBuilder.d.ts +247 -8
  70. package/dist/schema/TableBuilder.d.ts.map +1 -1
  71. package/dist/schema/TableBuilder.js +607 -41
  72. package/dist/schema/TableBuilder.js.map +1 -1
  73. package/dist/schema/catalog.d.ts +47 -0
  74. package/dist/schema/catalog.d.ts.map +1 -0
  75. package/dist/schema/catalog.js +111 -0
  76. package/dist/schema/catalog.js.map +1 -0
  77. package/dist/schema/types.d.ts +150 -1
  78. package/dist/schema/types.d.ts.map +1 -1
  79. package/dist/schema/types.js +11 -0
  80. package/dist/schema/types.js.map +1 -1
  81. package/dist/services/db.d.ts +6 -0
  82. package/dist/services/db.d.ts.map +1 -1
  83. package/dist/services/db.js +17 -0
  84. package/dist/services/db.js.map +1 -1
  85. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  86. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  87. package/dist/testing/DatabaseCleanup.js +21 -18
  88. package/dist/testing/DatabaseCleanup.js.map +1 -1
  89. package/dist/testing/Factory.d.ts +70 -5
  90. package/dist/testing/Factory.d.ts.map +1 -1
  91. package/dist/testing/Factory.js +209 -10
  92. package/dist/testing/Factory.js.map +1 -1
  93. package/index.darwin-arm64.node +0 -0
  94. package/index.darwin-x64.node +0 -0
  95. package/index.linux-arm64-gnu.node +0 -0
  96. package/index.linux-x64-gnu.node +0 -0
  97. package/index.win32-x64-msvc.node +0 -0
  98. package/package.json +3 -1
  99. package/scripts/guard-publish.mjs +15 -0
  100. package/src/AtlasProvider.ts +8 -1
  101. package/src/BaseEntity.ts +431 -41
  102. package/src/BaseModel.ts +324 -0
  103. package/src/BaseRepository.ts +1589 -419
  104. package/src/ModelQuery.ts +1817 -203
  105. package/src/Transaction.ts +68 -5
  106. package/src/adapters/NapiDbAdapter.ts +159 -10
  107. package/src/console/migrationCommands.ts +258 -0
  108. package/src/decorators/entity.ts +53 -6
  109. package/src/events.ts +112 -0
  110. package/src/index.ts +19 -0
  111. package/src/metadata-keys.ts +3 -2
  112. package/src/naming/NamingStrategy.ts +23 -0
  113. package/src/schema/Migration.ts +42 -3
  114. package/src/schema/MigrationRunner.ts +270 -27
  115. package/src/schema/Schema.ts +210 -3
  116. package/src/schema/TableBuilder.ts +735 -41
  117. package/src/schema/catalog.ts +166 -0
  118. package/src/schema/types.ts +137 -2
  119. package/src/services/db.ts +28 -0
  120. package/src/testing/DatabaseCleanup.ts +23 -22
  121. 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,38 @@ 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
+ }
131
297
  /** True when there is more than one page of results (AdonisJS `hasPages`). */
132
298
  get hasPages() {
133
299
  return this.meta.lastPage > 1;
@@ -145,7 +311,9 @@ export class Paginator {
145
311
  picked[f] = item[f];
146
312
  return picked;
147
313
  });
148
- 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() };
149
317
  }
150
318
  baseUrl(url) {
151
319
  this.#baseUrl = url;
@@ -155,26 +323,70 @@ export class Paginator {
155
323
  this.#queryString = qs;
156
324
  return this;
157
325
  }
158
- toJSON() {
159
- const meta = {
160
- ...this.meta,
161
- };
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 };
162
370
  if (this.#baseUrl) {
163
- const build = (page) => {
164
- const params = new URLSearchParams();
165
- for (const [k, v] of Object.entries(this.#queryString))
166
- params.set(k, String(v));
167
- params.set("page", String(page));
168
- return `${this.#baseUrl}?${params.toString()}`;
169
- };
170
- meta.firstPageUrl = build(1);
171
- meta.lastPageUrl = build(this.meta.lastPage);
172
- if (this.meta.currentPage < this.meta.lastPage)
173
- meta.nextPageUrl = build(this.meta.currentPage + 1);
174
- if (this.meta.currentPage > 1)
175
- meta.previousPageUrl = build(this.meta.currentPage - 1);
176
- }
177
- return { data: this.items, meta };
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;
379
+ }
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() };
178
390
  }
179
391
  }
180
392
  /** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
@@ -201,12 +413,17 @@ export class ModelQuery {
201
413
  #subqueryAlias;
202
414
  /** Raw JOIN fragments — Story 29.4. */
203
415
  #joins = [];
204
- /** Row lock mode — Story 30.8. */
416
+ /** Row lock base mode — Story 30.8. */
205
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;
206
422
  /** Per-query debug flag — Story 29.11. */
207
423
  #debugFlag = false;
208
424
  /** Distinct flag — Story 29.5. */
209
425
  #distinct = false;
426
+ #distinctOn = [];
210
427
  /** GROUP BY columns (Lucid parity). */
211
428
  #groupBy = [];
212
429
  /** HAVING clauses — structured + raw (Lucid parity). */
@@ -217,9 +434,17 @@ export class ModelQuery {
217
434
  #unions = [];
218
435
  /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
219
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;
220
445
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
221
446
  #dialect;
222
- 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) {
223
448
  this.#tableName = tableName;
224
449
  this.#db = db;
225
450
  this.#hydrateFn = hydrateFn;
@@ -227,7 +452,13 @@ export class ModelQuery {
227
452
  this.#resolveColumn = resolveColumn;
228
453
  this.#softDeletes = softDeletes;
229
454
  this.#dialect = dialect;
455
+ this.#prepareValue = prepareValue;
456
+ this.#onDomainEvents = onDomainEvents;
230
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;
231
462
  /** Include soft-deleted rows in the result (default behavior excludes them). */
232
463
  withTrashed() {
233
464
  this.#softScope = "with-trashed";
@@ -252,11 +483,29 @@ export class ModelQuery {
252
483
  }
253
484
  /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
254
485
  select(columns) {
255
- this.#select = Array.isArray(columns)
486
+ const list = Array.isArray(columns)
256
487
  ? columns
257
488
  : columns.split(",").map((c) => c.trim());
489
+ this.#select = list.map((c) => this.#resolveSelect(c));
258
490
  return this;
259
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
+ }
260
509
  where(columnOrCb, operatorOrValue, value) {
261
510
  if (typeof columnOrCb === "function") {
262
511
  this.#wheres.push(this.#buildGroup("and", columnOrCb));
@@ -289,13 +538,63 @@ export class ModelQuery {
289
538
  });
290
539
  return this;
291
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
+ }
292
591
  /** `WHERE col != ?` — negation of `where`. */
293
592
  whereNot(column, value) {
294
593
  this.#wheres.push({
295
594
  type: "and",
296
595
  column: this.#resolveColumn(column),
297
596
  operator: "!=",
298
- value,
597
+ value: this.#prep(column, value),
299
598
  });
300
599
  return this;
301
600
  }
@@ -315,7 +614,7 @@ export class ModelQuery {
315
614
  type: "and",
316
615
  column: this.#resolveColumn(column),
317
616
  operator: "IN",
318
- value: [...source],
617
+ value: this.#prep(column, [...source]),
319
618
  });
320
619
  return this;
321
620
  }
@@ -335,7 +634,7 @@ export class ModelQuery {
335
634
  type: "and",
336
635
  column: this.#resolveColumn(column),
337
636
  operator: "NOT IN",
338
- value: [...source],
637
+ value: this.#prep(column, [...source]),
339
638
  });
340
639
  return this;
341
640
  }
@@ -345,7 +644,7 @@ export class ModelQuery {
345
644
  type: "and",
346
645
  column: this.#resolveColumn(column),
347
646
  operator: "BETWEEN",
348
- value: [...range],
647
+ value: this.#prep(column, [...range]),
349
648
  });
350
649
  return this;
351
650
  }
@@ -355,7 +654,7 @@ export class ModelQuery {
355
654
  type: "and",
356
655
  column: this.#resolveColumn(column),
357
656
  operator: "NOT BETWEEN",
358
- value: [...range],
657
+ value: this.#prep(column, [...range]),
359
658
  });
360
659
  return this;
361
660
  }
@@ -412,7 +711,7 @@ export class ModelQuery {
412
711
  type: "or",
413
712
  column: this.#resolveColumn(column),
414
713
  operator: "!=",
415
- value,
714
+ value: this.#prep(column, value),
416
715
  });
417
716
  return this;
418
717
  }
@@ -432,7 +731,7 @@ export class ModelQuery {
432
731
  type: "or",
433
732
  column: this.#resolveColumn(column),
434
733
  operator: "IN",
435
- value: [...source],
734
+ value: this.#prep(column, [...source]),
436
735
  });
437
736
  return this;
438
737
  }
@@ -452,7 +751,7 @@ export class ModelQuery {
452
751
  type: "or",
453
752
  column: this.#resolveColumn(column),
454
753
  operator: "NOT IN",
455
- value: [...source],
754
+ value: this.#prep(column, [...source]),
456
755
  });
457
756
  return this;
458
757
  }
@@ -462,7 +761,7 @@ export class ModelQuery {
462
761
  type: "or",
463
762
  column: this.#resolveColumn(column),
464
763
  operator: "BETWEEN",
465
- value: [...range],
764
+ value: this.#prep(column, [...range]),
466
765
  });
467
766
  return this;
468
767
  }
@@ -472,7 +771,7 @@ export class ModelQuery {
472
771
  type: "or",
473
772
  column: this.#resolveColumn(column),
474
773
  operator: "NOT BETWEEN",
475
- value: [...range],
774
+ value: this.#prep(column, [...range]),
476
775
  });
477
776
  return this;
478
777
  }
@@ -535,15 +834,47 @@ export class ModelQuery {
535
834
  * Not exported from the package barrel — only accessible inside the Atlas
536
835
  * codebase via direct ModelQuery instance access.
537
836
  */
538
- #pushWhereRaw(sql, bindings = []) {
837
+ #pushWhereRaw(sql, bindings = [], type = "and") {
539
838
  this.#wheres.push({
540
- type: "and",
839
+ type,
541
840
  kind: "raw",
542
841
  sql,
543
842
  bindings: [...bindings],
544
843
  });
545
844
  return this;
546
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
+ }
547
878
  whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
548
879
  // 3-arg form: whereExpr(col, op, value)
549
880
  // 4-arg form: whereExpr(col, extraExpr, op, value)
@@ -562,6 +893,18 @@ export class ModelQuery {
562
893
  if (!hasBalancedParens(extra)) {
563
894
  throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
564
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
+ }
565
908
  // `op` is interpolated raw into the fragment below, so it MUST be
566
909
  // allow-listed — the 3-arg path gets this from the Rust operator
567
910
  // validation, but the raw 4-arg path bypasses Rust and would
@@ -578,9 +921,16 @@ export class ModelQuery {
578
921
  // the operator against the allow-list above.
579
922
  if (hasExtra) {
580
923
  const q = this.#quote(resolved);
581
- return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
924
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
925
+ this.#prep(column, value),
926
+ ]);
582
927
  }
583
- 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
+ });
584
934
  return this;
585
935
  }
586
936
  /**
@@ -589,6 +939,153 @@ export class ModelQuery {
589
939
  * operator is allow-listed; nothing is bound (it's a column reference, not a
590
940
  * value), which the standard `where`/`whereExpr` value-binding path can't do.
591
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
+ }
592
1089
  whereColumn(left, operator, right) {
593
1090
  return this.#whereColumn("and", left, operator, right);
594
1091
  }
@@ -596,7 +1093,19 @@ export class ModelQuery {
596
1093
  orWhereColumn(left, operator, right) {
597
1094
  return this.#whereColumn("or", left, operator, right);
598
1095
  }
599
- #whereColumn(type, left, operator, right) {
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) {
600
1109
  if (!WHEREEXPR_OPERATORS.has(operator)) {
601
1110
  throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
602
1111
  }
@@ -606,7 +1115,7 @@ export class ModelQuery {
606
1115
  // `[table.]column` charset. This closes the injection surface regardless of
607
1116
  // what #resolveColumn returns (it can be an identity resolver on sub-queries).
608
1117
  const safe = (name) => {
609
- const resolved = this.#resolveColumn(name);
1118
+ const resolved = this.#resolveColumnReference(name);
610
1119
  if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
611
1120
  throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
612
1121
  }
@@ -617,10 +1126,45 @@ export class ModelQuery {
617
1126
  .map((part) => this.#quote(part))
618
1127
  .join(".");
619
1128
  };
620
- const sql = `${safe(left)} ${operator} ${safe(right)}`;
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;
621
1133
  this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
622
1134
  return this;
623
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
+ }
624
1168
  /**
625
1169
  * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
626
1170
  * by the existence of related rows, optionally constrained by a callback.
@@ -645,6 +1189,14 @@ export class ModelQuery {
645
1189
  this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
646
1190
  return this;
647
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
+ }
648
1200
  /**
649
1201
  * Short form of `whereHas`. With an operator + count, emits a count threshold:
650
1202
  * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
@@ -663,6 +1215,19 @@ export class ModelQuery {
663
1215
  this.#wheres.push(this.#buildExistsClause("and", true, relationName));
664
1216
  return this;
665
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
+ }
666
1231
  /**
667
1232
  * Set this query's projection alias — only meaningful when this ModelQuery
668
1233
  * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
@@ -698,23 +1263,25 @@ export class ModelQuery {
698
1263
  // --- Top-level scalar executors (Story 29.5) ---
699
1264
  /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
700
1265
  async count(column = "*") {
701
- const expr = column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
1266
+ const expr = column === "*"
1267
+ ? "COUNT(*)"
1268
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
702
1269
  return Number((await this.#runScalar(expr)) ?? 0);
703
1270
  }
704
1271
  async sum(column) {
705
- const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
1272
+ const v = await this.#runScalar(`SUM(${this.#quoteCol(this.#resolveColumn(column))})`);
706
1273
  return v === null || v === undefined ? null : Number(v);
707
1274
  }
708
1275
  async avg(column) {
709
- const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
1276
+ const v = await this.#runScalar(`AVG(${this.#quoteCol(this.#resolveColumn(column))})`);
710
1277
  return v === null || v === undefined ? null : Number(v);
711
1278
  }
712
1279
  async min(column) {
713
- const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
1280
+ const v = await this.#runScalar(`MIN(${this.#quoteCol(this.#resolveColumn(column))})`);
714
1281
  return v === null || v === undefined ? null : Number(v);
715
1282
  }
716
1283
  async max(column) {
717
- const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
1284
+ const v = await this.#runScalar(`MAX(${this.#quoteCol(this.#resolveColumn(column))})`);
718
1285
  return v === null || v === undefined ? null : Number(v);
719
1286
  }
720
1287
  /**
@@ -745,31 +1312,91 @@ export class ModelQuery {
745
1312
  this.#orderBys.push({ column: this.#resolveColumn(column), direction });
746
1313
  return this;
747
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
+ }
748
1334
  /**
749
1335
  * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
750
1336
  * through the entity's column map (camelCase → snake_case) like `orderBy`.
751
- * For a raw grouping expression, use a `whereRaw`-style construct via the
752
- * fluent {@link QueryBuilder}.
1337
+ * For a grouping expression with no typed form, see {@link groupByRaw}.
753
1338
  */
754
1339
  groupBy(...columns) {
755
1340
  for (const c of columns)
756
1341
  this.#groupBy.push(this.#resolveColumn(c));
757
1342
  return this;
758
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
+ }
759
1360
  /**
760
1361
  * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
761
- * The column is passed verbatim to the Rust HAVING compiler, which quotes a
762
- * plain identifier or accepts an allow-listed aggregate expression
763
- * (`COUNT(*)`, `SUM(col)`, …) it is NOT run through the entity column map,
764
- * so aggregate expressions and result aliases both work.
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.
765
1366
  */
766
1367
  having(column, operator, value) {
767
- this.#having.push({ column, operator, value, type: "and" });
1368
+ this.#having.push({
1369
+ column: this.#resolveHavingCol(column),
1370
+ operator,
1371
+ value: this.#prep(column, value),
1372
+ type: "and",
1373
+ });
768
1374
  return this;
769
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
+ }
770
1392
  /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
771
1393
  orHaving(column, operator, value) {
772
- this.#having.push({ column, operator, value, type: "or" });
1394
+ this.#having.push({
1395
+ column: this.#resolveHavingCol(column),
1396
+ operator,
1397
+ value: this.#prep(column, value),
1398
+ type: "or",
1399
+ });
773
1400
  return this;
774
1401
  }
775
1402
  /**
@@ -781,6 +1408,12 @@ export class ModelQuery {
781
1408
  * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
782
1409
  */
783
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
+ }
784
1417
  this.#having.push({
785
1418
  kind: "raw",
786
1419
  sql,
@@ -803,34 +1436,160 @@ export class ModelQuery {
803
1436
  this.#unions.push({ query, all: true });
804
1437
  return this;
805
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
+ }
806
1465
  /**
807
1466
  * `WITH <name> AS (<query>)` — register a Common Table Expression
808
1467
  * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
809
1468
  * sub-query is compiled and its bindings are re-indexed into the outer list.
810
1469
  */
811
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) {
812
1506
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
813
- throw new Error(`with(): CTE name '${name}' is not a valid identifier`);
1507
+ throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
814
1508
  }
815
- this.#ctes.push({ name, query });
1509
+ this.#ctes.push({ name, query, ...options });
816
1510
  return this;
817
1511
  }
818
1512
  wherePivot(column, operatorOrValue, value) {
819
- if (value === undefined) {
820
- this.#pivotWheres.push({ column, operator: "=", value: operatorOrValue });
821
- }
822
- else {
823
- this.#pivotWheres.push({
824
- column,
825
- operator: operatorOrValue,
826
- value,
827
- });
828
- }
829
- return this;
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);
830
1520
  }
831
- /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `wherePivotIn`). */
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. */
832
1534
  wherePivotIn(column, values) {
833
- this.#pivotWheres.push({ column, operator: "IN", value: [...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 });
834
1593
  return this;
835
1594
  }
836
1595
  /** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
@@ -903,14 +1662,79 @@ export class ModelQuery {
903
1662
  return this.exec().then(onfulfilled, onrejected);
904
1663
  }
905
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
+ }
906
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
+ }
907
1720
  const wheres = [...this.#wheres];
908
- // 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).
909
1732
  if (this.#softDeletes) {
1733
+ const deletedAtCol = this.#deletedAtColumn();
910
1734
  if (this.#softScope === "default") {
911
1735
  wheres.push({
912
1736
  type: "and",
913
- column: "deleted_at",
1737
+ column: deletedAtCol,
914
1738
  operator: "IS NULL",
915
1739
  value: null,
916
1740
  });
@@ -918,7 +1742,7 @@ export class ModelQuery {
918
1742
  else if (this.#softScope === "only-trashed") {
919
1743
  wheres.push({
920
1744
  type: "and",
921
- column: "deleted_at",
1745
+ column: deletedAtCol,
922
1746
  operator: "IS NOT NULL",
923
1747
  value: null,
924
1748
  });
@@ -928,7 +1752,7 @@ export class ModelQuery {
928
1752
  return {
929
1753
  kind: "select",
930
1754
  table: this.#tableName,
931
- select: this.#select,
1755
+ select: selectCols,
932
1756
  selectSubqueries: this.#selectSubqueries,
933
1757
  wheres,
934
1758
  orderBy: this.#orderBys,
@@ -937,16 +1761,27 @@ export class ModelQuery {
937
1761
  limit: this.#limit ?? null,
938
1762
  offset: this.#offset ?? null,
939
1763
  distinct: this.#distinct,
1764
+ distinctOn: this.#distinctOn,
940
1765
  ctes: this.#ctes.map((c) => {
941
1766
  const { sql, params } = c.query.toSQL();
942
- return { name: c.name, sql, params };
1767
+ return {
1768
+ name: c.name,
1769
+ sql,
1770
+ params,
1771
+ recursive: c.recursive ?? false,
1772
+ materialized: c.materialized ?? null,
1773
+ };
943
1774
  }),
944
1775
  unions: this.#unions.map((u) => {
945
1776
  const { sql, params } = u.query.toSQL();
946
- return { sql, params, all: u.all };
1777
+ return { sql, params, all: u.all, op: u.op ?? null };
947
1778
  }),
948
1779
  joins: this.#joins,
949
- lockMode: this.#lockMode,
1780
+ lockMode: this.#lockMode
1781
+ ? this.#lockModifier
1782
+ ? `${this.#lockMode} ${this.#lockModifier}`
1783
+ : this.#lockMode
1784
+ : null,
950
1785
  };
951
1786
  }
952
1787
  /** Build SQL + params via the Rust query compiler. */
@@ -977,7 +1812,7 @@ export class ModelQuery {
977
1812
  }
978
1813
  async #doExec() {
979
1814
  const { sql, params } = this.toSQL();
980
- const rawRows = await this.#db.query(sql, params);
1815
+ const rawRows = await this.#db.query(sql, params, this.#meta("exec"));
981
1816
  // Peel withCount / withAggregate alias columns off the raw row into $extras
982
1817
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
983
1818
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -992,6 +1827,9 @@ export class ModelQuery {
992
1827
  const entity = this.#hydrateFn(row);
993
1828
  for (const [k, v] of Object.entries(picked))
994
1829
  entity.setExtra(k, v);
1830
+ // Thread query-level sideloaded context onto each hydrated instance.
1831
+ if (this.#sideloaded)
1832
+ entity.$sideloaded = { ...this.#sideloaded };
995
1833
  return entity;
996
1834
  });
997
1835
  // Resolve preloads (eager loading)
@@ -1000,6 +1838,25 @@ export class ModelQuery {
1000
1838
  }
1001
1839
  return entities;
1002
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
+ }
1003
1860
  /** Resolve preloaded relations via batched subqueries (no N+1). */
1004
1861
  async #resolvePreloads(entities) {
1005
1862
  if (!this.#entityClass)
@@ -1019,33 +1876,84 @@ export class ModelQuery {
1019
1876
  /** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
1020
1877
  #buildPreloadContext(relation, relationName) {
1021
1878
  const relatedClass = relation.target();
1022
- const relatedMeta = getEntityMetadata(relatedClass);
1023
- if (!relatedMeta)
1024
- 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);
1025
1884
  // Resolve row keys against declared column metadata, NOT `in entity` —
1026
1885
  // entities using Adonis' `declare field: T` pattern have no own-properties
1027
1886
  // on a freshly constructed instance, so `key in entity` is always false and
1028
1887
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
1029
1888
  const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
1030
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);
1031
1900
  for (const col of getColumnMetadata(relatedClass)) {
1901
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
1032
1902
  validColumns.add(col.propertyKey);
1033
- 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;
1034
1911
  }
1035
1912
  validColumns.add(relatedPkName);
1036
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;
1037
1935
  const hydrate = (row) => {
1038
1936
  const entity = new relatedClass();
1039
1937
  for (const [key, value] of Object.entries(row)) {
1040
1938
  const camelKey = snakeToCamel(key);
1041
- const targetKey = validColumns.has(camelKey)
1042
- ? camelKey
1043
- : validColumns.has(key)
1044
- ? key
1045
- : null;
1939
+ const targetKey = byDbName.get(key) ??
1940
+ (validColumns.has(camelKey)
1941
+ ? camelKey
1942
+ : validColumns.has(key)
1943
+ ? key
1944
+ : null);
1046
1945
  if (targetKey !== null)
1047
- entity.setProp(targetKey, value);
1946
+ entity.setProp(targetKey, consumeValue(targetKey, value, entity));
1048
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
+ });
1049
1957
  return entity;
1050
1958
  };
1051
1959
  return {
@@ -1053,7 +1961,9 @@ export class ModelQuery {
1053
1961
  relationName,
1054
1962
  relatedClass,
1055
1963
  relatedTable: relatedMeta.tableName,
1056
- 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,
1057
1967
  hydrate,
1058
1968
  runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
1059
1969
  runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
@@ -1087,15 +1997,17 @@ export class ModelQuery {
1087
1997
  throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
1088
1998
  }
1089
1999
  const throughClass = relation.through();
1090
- const throughMeta = getEntityMetadata(throughClass);
1091
- if (!throughMeta)
1092
- throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
2000
+ const throughMeta = ensureEntityMetadata(throughClass);
1093
2001
  const throughTable = throughMeta.tableName;
1094
2002
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1095
2003
  const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1096
2004
  const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1097
2005
  const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1098
- 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);
1099
2011
  const parentIds = entities
1100
2012
  .map((e) => e[parentLocal])
1101
2013
  .filter((v) => v != null);
@@ -1224,7 +2136,10 @@ export class ModelQuery {
1224
2136
  // on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
1225
2137
  const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1226
2138
  const otherKey = pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
1227
- 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";
1228
2143
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1229
2144
  if (ids.length === 0)
1230
2145
  return [];
@@ -1234,10 +2149,37 @@ export class ModelQuery {
1234
2149
  // runs are pure builder mutations, and pivot constraints are inert there.
1235
2150
  const pivotWheres = [];
1236
2151
  if (ctx.nestedCallback) {
1237
- const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, (c) => c, false, this.#dialect);
2152
+ const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
1238
2153
  ctx.nestedCallback(scratch);
1239
- for (const c of scratch.pivotConstraints)
1240
- pivotWheres.push({ ...c });
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
+ }
1241
2183
  }
1242
2184
  // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
1243
2185
  const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
@@ -1282,7 +2224,9 @@ export class ModelQuery {
1282
2224
  for (const col of pivotCols) {
1283
2225
  const rawVal = pivotRow[col];
1284
2226
  const adapter = pivotAdapters[col];
1285
- related.setExtra(`pivot_${col}`, adapter?.consume ? adapter.consume(rawVal) : rawVal);
2227
+ related.setExtra(`pivot_${col}`,
2228
+ // Adonis Lucid signature: (value, attribute, model).
2229
+ adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal);
1286
2230
  }
1287
2231
  allRelated.push(related);
1288
2232
  }
@@ -1305,7 +2249,7 @@ export class ModelQuery {
1305
2249
  async #applyNestedPreloads(relatedEntities, ctx) {
1306
2250
  if (!ctx.nestedCallback || relatedEntities.length === 0)
1307
2251
  return;
1308
- 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));
1309
2253
  ctx.nestedCallback(sub);
1310
2254
  if (sub.#preloads.size > 0) {
1311
2255
  await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
@@ -1316,12 +2260,22 @@ export class ModelQuery {
1316
2260
  const wheres = [
1317
2261
  { column, operator: "IN", value: values, type: "and" },
1318
2262
  ];
1319
- for (const w of extraWheres) {
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) {
1320
2270
  wheres.push({
1321
- column: w.column,
1322
- operator: w.operator,
1323
- value: w.value,
2271
+ kind: "group",
1324
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
+ })),
1325
2279
  });
1326
2280
  }
1327
2281
  const spec = {
@@ -1356,7 +2310,11 @@ export class ModelQuery {
1356
2310
  * inside the callback are re-collected later by `#applyNestedPreloads`.
1357
2311
  */
1358
2312
  async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
1359
- 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),
1360
2318
  // Propagate the RELATED entity's soft-delete flag — hardcoding
1361
2319
  // false here meant `preload('posts')` returned soft-deleted
1362
2320
  // posts even when Post is @SoftDeletes (a data leak). The
@@ -1364,7 +2322,7 @@ export class ModelQuery {
1364
2322
  // matching a direct query on that entity. (with-trashed on the
1365
2323
  // related set, if ever needed, would be opted-in via the
1366
2324
  // preload callback.)
1367
- hasSoftDeletes(relatedClass), this.#dialect);
2325
+ hasSoftDeletes(relatedClass), this.#dialect, buildValuePreparer(relatedClass));
1368
2326
  sub.whereIn(column, values);
1369
2327
  if (relation.onQuery)
1370
2328
  relation.onQuery(sub);
@@ -1403,31 +2361,50 @@ export class ModelQuery {
1403
2361
  throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
1404
2362
  }
1405
2363
  const relatedClass = relation.target();
1406
- const relatedMeta = getEntityMetadata(relatedClass);
1407
- if (!relatedMeta) {
1408
- throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
1409
- }
2364
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1410
2365
  const relatedTable = relatedMeta.tableName;
1411
2366
  const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
1412
2367
  const parentTable = this.#tableName;
1413
- const q = this.#dialect === "mysql"
1414
- ? (name) => `\`${name}\``
1415
- : (name) => `"${name}"`;
1416
- const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c, false, this.#dialect);
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);
1417
2394
  switch (relation.type) {
1418
2395
  case "hasOne":
1419
2396
  case "hasMany": {
1420
2397
  // Honour custom foreignKey/localKey exactly like the eager loader —
1421
2398
  // hard-coding them here produced silently-wrong whereHas/withCount SQL.
1422
2399
  const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1423
- const localKey = relation.localKey ?? parentPk;
1424
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`);
2400
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2401
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`);
1425
2402
  break;
1426
2403
  }
1427
2404
  case "belongsTo": {
1428
2405
  const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
1429
- const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1430
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(ownerKey)} = ${q(parentTable)}.${q(fk)}`);
2406
+ const ownerKey = buildColumnResolver(relatedClass)(relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id");
2407
+ sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`);
1431
2408
  break;
1432
2409
  }
1433
2410
  case "manyToMany": {
@@ -1439,11 +2416,12 @@ export class ModelQuery {
1439
2416
  // name stripped of a trailing `s` — see the eager loader above.
1440
2417
  const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1441
2418
  const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1442
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1443
- const localKey = relation.localKey ?? parentPk;
1444
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} IN ` +
1445
- `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1446
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`);
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)})`);
1447
2425
  break;
1448
2426
  }
1449
2427
  case "hasOneThrough":
@@ -1455,19 +2433,16 @@ export class ModelQuery {
1455
2433
  throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
1456
2434
  }
1457
2435
  const throughClass = relation.through();
1458
- const throughMeta = getEntityMetadata(throughClass);
1459
- if (!throughMeta) {
1460
- throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
1461
- }
2436
+ const throughMeta = ensureEntityMetadata(throughClass);
1462
2437
  const throughTable = throughMeta.tableName;
1463
2438
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1464
- const parentLocal = relation.localKey ?? parentPk;
2439
+ const parentLocal = resolveParent(relation.localKey ?? parentPk);
1465
2440
  const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1466
2441
  const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1467
- const secondLocal = relation.secondLocalKey ?? throughPk;
1468
- sub.#pushWhereRaw(`${q(relatedTable)}.${q(secondKey)} IN ` +
1469
- `(SELECT ${q(secondLocal)} FROM ${q(throughTable)} ` +
1470
- `WHERE ${q(throughTable)}.${q(firstKey)} = ${q(parentTable)}.${q(parentLocal)})`);
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)})`);
1471
2446
  break;
1472
2447
  }
1473
2448
  }
@@ -1483,8 +2458,8 @@ export class ModelQuery {
1483
2458
  return this.#pushJoin("RIGHT", table, leftOrBuild, right);
1484
2459
  }
1485
2460
  crossJoin(table) {
1486
- const tq = this.#quote(table);
1487
- this.#joins.push(`CROSS JOIN ${tq}`);
2461
+ const tq = this.#quoteCol(table);
2462
+ this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
1488
2463
  return this;
1489
2464
  }
1490
2465
  /**
@@ -1503,12 +2478,12 @@ export class ModelQuery {
1503
2478
  *
1504
2479
  * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
1505
2480
  */
1506
- joinRaw(fragment) {
2481
+ joinRaw(fragment, bindings = []) {
1507
2482
  if (isAtlasStrictMode() && !isInternalBypass()) {
1508
2483
  throw new Error("joinRaw() is disabled in Atlas strict mode. " +
1509
2484
  "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
1510
2485
  }
1511
- this.#joins.push(fragment);
2486
+ this.#joins.push({ sql: fragment, params: [...bindings] });
1512
2487
  return this;
1513
2488
  }
1514
2489
  /**
@@ -1530,9 +2505,37 @@ export class ModelQuery {
1530
2505
  this.#distinct = true;
1531
2506
  return this;
1532
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
+ }
1533
2526
  /** `SELECT COUNT(DISTINCT col)`. */
1534
2527
  async countDistinct(column) {
1535
- 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);
1536
2539
  }
1537
2540
  /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
1538
2541
  async exists() {
@@ -1540,7 +2543,7 @@ export class ModelQuery {
1540
2543
  clone.#select = ["1"];
1541
2544
  clone.#limit = 1;
1542
2545
  const { sql, params } = clone.toSQL();
1543
- const rows = await this.#db.query(sql, params);
2546
+ const rows = await this.#db.query(sql, params, this.#meta("exists"));
1544
2547
  return rows.length > 0;
1545
2548
  }
1546
2549
  async doesntExist() {
@@ -1609,14 +2612,29 @@ export class ModelQuery {
1609
2612
  // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
1610
2613
  // tenant scope) propagates into both the COUNT and the data fetch.
1611
2614
  await fireHooks(this.#entityClass, "beforePaginate", this);
1612
- // Parallel COUNT(*) + data fetch
2615
+ // COUNT(*) + data fetch
1613
2616
  const countQ = this.clone();
1614
- countQ.#select = ["COUNT(*) AS count"];
1615
2617
  countQ.#limit = undefined;
1616
2618
  countQ.#offset = undefined;
1617
2619
  countQ.#orderBys = [];
1618
- const { sql: cSql, params: cParams } = countQ.toSQL();
1619
- 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"));
1620
2638
  const total = Number(cRows[0]?.count ?? 0);
1621
2639
  const dataQ = this.clone();
1622
2640
  dataQ.#limit = pp;
@@ -1625,7 +2643,10 @@ export class ModelQuery {
1625
2643
  // top of the paginate hooks — paginate is its own terminal.
1626
2644
  const items = await dataQ.#doExec();
1627
2645
  await fireHooks(this.#entityClass, "afterPaginate", items);
1628
- 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);
1629
2650
  }
1630
2651
  /**
1631
2652
  * Cursor-based pagination — base64 opaque keyset, multi-column aware.
@@ -1641,7 +2662,13 @@ export class ModelQuery {
1641
2662
  * accepts row-value comparisons.
1642
2663
  */
1643
2664
  async cursorPaginate(opts) {
1644
- 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));
1645
2672
  if (cols.length === 0)
1646
2673
  throw new Error("cursorPaginate requires at least one orderBy column");
1647
2674
  const lim = Math.max(1, Math.floor(opts.limit));
@@ -1685,7 +2712,7 @@ export class ModelQuery {
1685
2712
  const items = hasMore ? rows.slice(0, lim) : rows;
1686
2713
  const last = items[items.length - 1];
1687
2714
  const nextCursor = hasMore && last
1688
- ? 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")
1689
2716
  : null;
1690
2717
  return { items, nextCursor, hasMore };
1691
2718
  }
@@ -1702,6 +2729,21 @@ export class ModelQuery {
1702
2729
  this.#debugFlag = flag;
1703
2730
  return this;
1704
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
+ }
1705
2747
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
1706
2748
  toQuery() {
1707
2749
  const { sql, params } = this.toSQL();
@@ -1713,7 +2755,7 @@ export class ModelQuery {
1713
2755
  }
1714
2756
  /** Deep clone of this query — mutations on the clone never affect the original. */
1715
2757
  clone() {
1716
- 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);
1717
2759
  c.#softScope = this.#softScope;
1718
2760
  c.#wheres = structuredCloneSafe(this.#wheres);
1719
2761
  c.#orderBys = [...this.#orderBys];
@@ -1722,17 +2764,29 @@ export class ModelQuery {
1722
2764
  c.#offset = this.#offset;
1723
2765
  c.#preloads = new Map(this.#preloads);
1724
2766
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
1725
- c.#joins = [...this.#joins];
2767
+ c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
1726
2768
  c.#lockMode = this.#lockMode;
2769
+ c.#lockModifier = this.#lockModifier;
2770
+ c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
1727
2771
  c.#distinct = this.#distinct;
2772
+ c.#distinctOn = [...this.#distinctOn];
1728
2773
  c.#groupBy = [...this.#groupBy];
1729
2774
  c.#having = structuredCloneSafe(this.#having);
1730
- c.#ctes = this.#ctes.map((e) => ({ name: e.name, query: e.query.clone() }));
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
+ }));
1731
2781
  c.#unions = this.#unions.map((u) => ({
1732
2782
  query: u.query.clone(),
1733
2783
  all: u.all,
2784
+ op: u.op,
1734
2785
  }));
1735
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;
1736
2790
  c.#debugFlag = this.#debugFlag;
1737
2791
  return c;
1738
2792
  }
@@ -1742,13 +2796,15 @@ export class ModelQuery {
1742
2796
  if (!patch || Object.keys(patch).length === 0) {
1743
2797
  throw new Error("update() requires a non-empty payload");
1744
2798
  }
1745
- 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)]);
1746
2802
  const spec = {
1747
2803
  kind: "update",
1748
2804
  table: this.#tableName,
1749
2805
  set: setPairs,
1750
2806
  wheres: this.#wheresForDml(),
1751
- returning: returning ?? [],
2807
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
1752
2808
  };
1753
2809
  const compiled = compileStatementNative(spec, this.#dialect);
1754
2810
  if (returning && returning.length > 0) {
@@ -1757,20 +2813,60 @@ export class ModelQuery {
1757
2813
  const r = await this.#db.execute(compiled.statements[0], compiled.params);
1758
2814
  return r.rowsAffected ?? 0;
1759
2815
  }
1760
- /** 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
+ */
1761
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) {
1762
2838
  const spec = {
1763
2839
  kind: "delete",
1764
2840
  table: this.#tableName,
1765
2841
  wheres: this.#wheresForDml(),
1766
- returning: returning ?? [],
2842
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
1767
2843
  };
1768
- const compiled = compileStatementNative(spec, this.#dialect);
1769
- if (returning && returning.length > 0) {
1770
- return this.#db.query(compiled.statements[0], compiled.params);
1771
- }
1772
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
1773
- 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);
1774
2870
  }
1775
2871
  increment(colOrPatch, amount = 1) {
1776
2872
  return this.#runIncDec("increment", colOrPatch, amount);
@@ -1797,20 +2893,73 @@ export class ModelQuery {
1797
2893
  }
1798
2894
  return this;
1799
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
+ }
1800
2942
  // === Private helpers ==============================================================================
1801
2943
  #quote(name) {
1802
2944
  return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1803
2945
  }
1804
2946
  /** Quote a `table.column` reference on both sides of the dot. */
1805
2947
  #quoteCol(ref) {
1806
- if (ref.includes(".")) {
1807
- const [t, c] = ref.split(".", 2);
1808
- return `${this.#quote(t)}.${this.#quote(c)}`;
1809
- }
1810
- return this.#quote(ref);
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.`);
2955
+ }
2956
+ return ref
2957
+ .split(".")
2958
+ .map((seg) => this.#quote(seg))
2959
+ .join(".");
1811
2960
  }
1812
2961
  #pushJoin(kind, table, leftOrBuild, right) {
1813
- const tq = this.#quote(table);
2962
+ const tq = this.#quoteCol(table);
1814
2963
  if (typeof leftOrBuild === "function") {
1815
2964
  const jb = {
1816
2965
  parts: [],
@@ -1822,24 +2971,56 @@ export class ModelQuery {
1822
2971
  this.parts.push({ kind: "and", left: l, right: r });
1823
2972
  return this;
1824
2973
  },
1825
- andOnVal(l, _v) {
1826
- 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 } });
1827
2988
  return this;
1828
2989
  },
1829
2990
  };
1830
2991
  leftOrBuild(jb);
2992
+ // Collect the bound values in placeholder order as the fragment is built.
2993
+ const params = [];
1831
2994
  const on = jb.parts
1832
2995
  .map((p, i) => {
1833
2996
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
1834
- 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 ?? "")}`;
1835
3013
  })
1836
3014
  .join(" ");
1837
- this.#joins.push(`${kind} JOIN ${tq} ${on}`);
3015
+ this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
1838
3016
  return this;
1839
3017
  }
1840
3018
  if (right === undefined)
1841
3019
  throw new Error("join() with string form requires both left and right operands");
1842
- this.#joins.push(`${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`);
3020
+ this.#joins.push({
3021
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
3022
+ params: [],
3023
+ });
1843
3024
  return this;
1844
3025
  }
1845
3026
  async #runScalar(expr) {
@@ -1871,7 +3052,8 @@ export class ModelQuery {
1871
3052
  * still rejected because the DML compiler's WHERE lowering does not yet
1872
3053
  * handle nested sub-queries or correlated EXISTS.
1873
3054
  */
1874
- #wheresForDml() {
3055
+ /** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
3056
+ #userWheresForDml() {
1875
3057
  const out = [];
1876
3058
  for (const w of this.#wheres) {
1877
3059
  if ("kind" in w) {
@@ -1896,6 +3078,43 @@ export class ModelQuery {
1896
3078
  }
1897
3079
  return out;
1898
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
+ }
1899
3118
  /**
1900
3119
  * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
1901
3120
  *
@@ -1928,7 +3147,7 @@ export class ModelQuery {
1928
3147
  * groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
1929
3148
  */
1930
3149
  #buildGroup(type, callback) {
1931
- 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);
1932
3151
  callback(scratch);
1933
3152
  return { type, kind: "group", conditions: scratch.#wheres };
1934
3153
  }
@@ -1968,7 +3187,7 @@ export class ModelQuery {
1968
3187
  type,
1969
3188
  column: resolved,
1970
3189
  operator: "=",
1971
- value: operatorOrValue,
3190
+ value: this.#prep(column, operatorOrValue),
1972
3191
  });
1973
3192
  }
1974
3193
  else {
@@ -1976,11 +3195,21 @@ export class ModelQuery {
1976
3195
  type,
1977
3196
  column: resolved,
1978
3197
  operator: operatorOrValue,
1979
- value,
3198
+ value: this.#prep(column, value),
1980
3199
  });
1981
3200
  }
1982
3201
  return this;
1983
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
+ }
1984
3213
  /**
1985
3214
  * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
1986
3215
  * Used by the nested-preload machinery to recurse without re-running the root select.