@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
package/src/ModelQuery.ts CHANGED
@@ -7,17 +7,28 @@
7
7
  * Builds SQL fluently and executes against the database connection.
8
8
  */
9
9
 
10
- import type { BaseEntity } from "./BaseEntity.js";
11
- import type { DatabaseConnection } from "./BaseRepository.js";
10
+ import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
11
+ import type { QueryMeta } from "./adapters/NapiDbAdapter.js";
12
+ import { type BaseEntity, type DomainEvent, REPO_REF } from "./BaseEntity.js";
13
+ // Value import used only inside method bodies (preload hydration) — the
14
+ // BaseRepository ↔ ModelQuery cycle resolves at runtime, after both are defined.
12
15
  import {
16
+ assertNotPromise,
17
+ BaseRepository,
18
+ type DatabaseConnection,
19
+ wrapAdapterError,
20
+ } from "./BaseRepository.js";
21
+ import {
22
+ ensureEntityMetadata,
13
23
  getColumnMetadata,
14
- getEntityMetadata,
24
+ getDateColumnConfig,
15
25
  getPrimaryKey,
16
26
  getRelationMetadata,
17
27
  hasSoftDeletes,
18
28
  type RelationMetadata,
19
29
  } from "./decorators/entity.js";
20
30
  import { fireHooks } from "./decorators/hooks.js";
31
+ import { getNamingStrategy } from "./naming/NamingStrategy.js";
21
32
  import {
22
33
  type AtlasDialect,
23
34
  compileStatementNative,
@@ -43,6 +54,50 @@ const WHEREEXPR_OPERATORS = new Set<string>([
43
54
  "NOT LIKE",
44
55
  ]);
45
56
 
57
+ /**
58
+ * SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
59
+ * They are just letters (pass the charset guard) but would let the fragment alter
60
+ * the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
61
+ * alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
62
+ */
63
+ const WHEREEXPR_FORBIDDEN_WORDS = new Set<string>([
64
+ "OR",
65
+ "AND",
66
+ "NOT",
67
+ "IS",
68
+ "NULL",
69
+ "IN",
70
+ "LIKE",
71
+ "ILIKE",
72
+ "BETWEEN",
73
+ "EXISTS",
74
+ "ANY",
75
+ "ALL",
76
+ "SOME",
77
+ "CASE",
78
+ "WHEN",
79
+ "THEN",
80
+ "ELSE",
81
+ "END",
82
+ "SELECT",
83
+ "FROM",
84
+ "WHERE",
85
+ "JOIN",
86
+ "UNION",
87
+ "INTERSECT",
88
+ "EXCEPT",
89
+ "HAVING",
90
+ "GROUP",
91
+ "ORDER",
92
+ "BY",
93
+ "LIMIT",
94
+ "OFFSET",
95
+ "AS",
96
+ "DISTINCT",
97
+ "TRUE",
98
+ "FALSE",
99
+ ]);
100
+
46
101
  /** True when every `(` in `s` has a matching `)` and none closes early. */
47
102
  function hasBalancedParens(s: string): boolean {
48
103
  let depth = 0;
@@ -59,6 +114,112 @@ function hasBalancedParens(s: string): boolean {
59
114
  type PreloadCallback = (query: ModelQuery<BaseEntity>) => void;
60
115
 
61
116
  type ColumnResolver = (column: string) => string;
117
+ /**
118
+ * Lower a value bound for a given property to its DB form — mirrors
119
+ * `BaseRepository.#applyPrepare` (a `@column.dateTime` DateTime → ISO string, a
120
+ * `@Column({ prepare })` adapter runs). Threaded into ModelQuery so the fluent
121
+ * `update()` / WHERE paths don't bypass prepare the way direct repo writes don't.
122
+ */
123
+ type ValuePreparer = (column: string, value: unknown) => unknown;
124
+
125
+ /**
126
+ * Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
127
+ * and the snake_case convention. Used to build correlated/preload subqueries on a
128
+ * RELATED model so their WHERE/join columns resolve like a direct query would.
129
+ */
130
+ function buildColumnResolver(
131
+ entityClass: new () => BaseEntity,
132
+ ): ColumnResolver {
133
+ const map = new Map<string, string>();
134
+ for (const col of getColumnMetadata(entityClass)) {
135
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
136
+ map.set(col.propertyKey, db);
137
+ map.set(db, db);
138
+ }
139
+ return (col) => map.get(col) ?? camelToSnake(col);
140
+ }
141
+
142
+ /**
143
+ * Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
144
+ * (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
145
+ * preload/whereHas constraint on a RELATED model prepares its values like a direct query.
146
+ */
147
+ function buildValuePreparer(entityClass: new () => BaseEntity): ValuePreparer {
148
+ const prepares = new Map<
149
+ string,
150
+ (v: unknown, attribute?: string, model?: unknown) => unknown
151
+ >();
152
+ // Reverse map (db column → property) so a caller passing a DB name or an
153
+ // explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
154
+ // still routes through the property-keyed prepare/date maps — mirrors
155
+ // BaseRepository.#applyPrepare.
156
+ const byDbName = new Map<string, string>();
157
+ for (const col of getColumnMetadata(entityClass)) {
158
+ if (col.prepare) prepares.set(col.propertyKey, col.prepare);
159
+ byDbName.set(
160
+ col.columnName ?? camelToSnake(col.propertyKey),
161
+ col.propertyKey,
162
+ );
163
+ }
164
+ const dateCols = getDateColumnConfig(entityClass);
165
+ return (key, value) => {
166
+ const prop = byDbName.get(key) ?? key;
167
+ const p = prepares.get(prop);
168
+ // Query-builder value transform — no model instance, but the attribute is
169
+ // known (Adonis Lucid signature: value, attribute, model).
170
+ if (p) return p(value, prop, undefined);
171
+ if (dateCols[prop] && value != null) {
172
+ if (value instanceof Date) return value.toISOString();
173
+ return dateTimeAtlasAdapter.prepare(value);
174
+ }
175
+ return value;
176
+ };
177
+ }
178
+
179
+ /** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
180
+ function joinValueHasToISO(v: unknown): v is { toISO(): string } {
181
+ return (
182
+ typeof v === "object" &&
183
+ v !== null &&
184
+ "toISO" in v &&
185
+ typeof v.toISO === "function"
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
191
+ * `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
192
+ * column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
193
+ * borrow the root model's adapter for a same-named column on a different table
194
+ * (Knex binds join values model-agnostically; we add only safe universal
195
+ * serialization so a DateTime still lowers to ISO like `where()`).
196
+ */
197
+ function lowerJoinValue(value: unknown): unknown {
198
+ if (value instanceof Date) return value.toISOString();
199
+ if (joinValueHasToISO(value)) return value.toISO();
200
+ return value;
201
+ }
202
+
203
+ /**
204
+ * Does a join column's table reference (`ref`) denote the root model's own table
205
+ * (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
206
+ * model declares (default schema) — `orders` matches a `public.orders` model — but
207
+ * it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
208
+ * accepts `orders.col`, while an unqualified `orders` model rejects
209
+ * `archive.orders.col` (a different schema the model never named) — keeping it
210
+ * foreign so the root model's `@Column` adapters aren't misapplied to it.
211
+ */
212
+ function sameTableRef(ref: string, modelTable: string): boolean {
213
+ const rs = ref.split(".");
214
+ const ms = modelTable.split(".");
215
+ // The reference cannot be MORE qualified than the model (it can only drop the
216
+ // schema, never assert a new one) — otherwise treat it as a foreign table.
217
+ if (rs.length > ms.length) return false;
218
+ for (let i = 1; i <= rs.length; i++) {
219
+ if (rs[rs.length - i] !== ms[ms.length - i]) return false;
220
+ }
221
+ return true;
222
+ }
62
223
 
63
224
  /** Per-preload-relation locals shared by the resolver helpers. Built once per relation, then passed by ref. */
64
225
  interface PreloadContext {
@@ -148,6 +309,22 @@ interface RawWhere {
148
309
  bindings: unknown[];
149
310
  }
150
311
 
312
+ /**
313
+ * A JSON predicate — path comparison or containment. The column is a quoted
314
+ * identifier; the path and value cross the boundary as bound params. Mirrors
315
+ * the Rust `json` WHERE kind.
316
+ */
317
+ interface JsonWhere {
318
+ type: "and" | "or";
319
+ kind: "json";
320
+ jsonOp: "path" | "superset" | "subset";
321
+ column: string;
322
+ negated: boolean;
323
+ path?: string;
324
+ operator?: string;
325
+ value: unknown;
326
+ }
327
+
151
328
  /** An EXISTS / NOT EXISTS correlated subquery — used by whereHas / doesntHave. */
152
329
  interface ExistsWhere {
153
330
  type: "and" | "or";
@@ -195,13 +372,27 @@ interface CteSpec {
195
372
  name: string;
196
373
  sql: string;
197
374
  params: unknown[];
375
+ /** One recursive CTE makes the whole WITH clause recursive — see `withRecursive`. */
376
+ recursive?: boolean;
377
+ /**
378
+ * `true` → AS MATERIALIZED, `false` → AS NOT MATERIALIZED, `null` →
379
+ * planner's choice. Null rather than absent because this crosses the NAPI
380
+ * boundary, where serde reads a missing key and an explicit null alike.
381
+ */
382
+ materialized?: boolean | null;
198
383
  }
199
384
 
200
- /** A compiled UNION / UNION ALL branch — pre-compiled to SQL + params. */
385
+ /**
386
+ * A compiled set-operation branch — pre-compiled to SQL + params. Still named
387
+ * `UnionSpec` (and sent under `unions`) because that wire field predates
388
+ * INTERSECT/EXCEPT; renaming it would break the contract for no gain.
389
+ */
201
390
  interface UnionSpec {
202
391
  sql: string;
203
392
  params: unknown[];
204
393
  all: boolean;
394
+ /** Defaults to `union` when absent/null, keeping the pre-existing wire format. */
395
+ op?: "union" | "intersect" | "except" | null;
205
396
  }
206
397
 
207
398
  interface SubqueryProjection {
@@ -209,22 +400,41 @@ interface SubqueryProjection {
209
400
  subquery: SelectSpec;
210
401
  }
211
402
 
403
+ /**
404
+ * One ORDER BY term: a resolved column + direction, or a verbatim fragment
405
+ * (`orderByRaw`). Both share one list so a raw term keeps its position among
406
+ * the plain ones. Mirrors the Rust `OrderByClause`.
407
+ */
408
+ type OrderByEntry =
409
+ | { column: string; direction: "asc" | "desc" }
410
+ | { raw: string };
411
+
412
+ /**
413
+ * One GROUP BY term: a resolved column, or a verbatim fragment (`groupByRaw`).
414
+ * Mirrors the Rust `GroupByItem` — untagged, so a bare string stays a column
415
+ * and the pre-existing wire format is unchanged.
416
+ */
417
+ type GroupByEntry = string | { raw: string };
418
+
212
419
  interface SelectSpec {
213
420
  kind: "select";
214
421
  table: string;
215
422
  select: string[];
216
423
  selectSubqueries: SubqueryProjection[];
217
424
  wheres: WhereClause[];
218
- orderBy: Array<{ column: string; direction: "asc" | "desc" }>;
219
- groupBy: string[];
425
+ orderBy: OrderByEntry[];
426
+ groupBy: GroupByEntry[];
220
427
  having: HavingEntry[];
221
428
  limit: number | null;
222
429
  offset: number | null;
223
430
  distinct: boolean;
431
+ distinctOn: string[];
224
432
  ctes: CteSpec[];
225
433
  unions: UnionSpec[];
226
- joins: string[];
227
- lockMode: "FOR UPDATE" | "FOR SHARE" | null;
434
+ /** JOIN fragments; each carries its own `?`-style bound params (e.g. `onVal`). */
435
+ joins: Array<{ sql: string; params: unknown[] }>;
436
+ /** Composite lock clause, e.g. `FOR UPDATE`, `FOR NO KEY UPDATE SKIP LOCKED`. */
437
+ lockMode: string | null;
228
438
  }
229
439
 
230
440
  type WhereClause =
@@ -232,15 +442,18 @@ type WhereClause =
232
442
  | RawWhere
233
443
  | ExistsWhere
234
444
  | GroupWhere
235
- | InSubWhere;
445
+ | InSubWhere
446
+ | JsonWhere;
236
447
 
237
448
  type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
238
449
 
239
450
  /**
240
- * Process-wide strict mode flag. When enabled, `whereRaw()` and `joinRaw()`
241
- * throw unconditionally — forcing every call site to use the typed
242
- * `whereExpr()` / `joinOn()` / structured builder paths. Intended for prod
243
- * hardening on apps that can't audit every call site manually.
451
+ * Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
452
+ * `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
453
+ * call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
454
+ * builder paths. The connection-level `db.query()` / `db.execute()` stay available
455
+ * as the explicit, parameterised break-glass. Intended for prod hardening on apps
456
+ * that can't audit every call site manually.
244
457
  *
245
458
  * Enable via:
246
459
  * - `setAtlasStrictMode(true)` at app bootstrap
@@ -252,7 +465,7 @@ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
252
465
  */
253
466
  let atlasStrictMode: boolean | undefined;
254
467
 
255
- /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
468
+ /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
256
469
  export function setAtlasStrictMode(enabled: boolean): void {
257
470
  atlasStrictMode = enabled;
258
471
  }
@@ -286,12 +499,26 @@ function isInternalBypass(): boolean {
286
499
  return atlasInternalBypass;
287
500
  }
288
501
 
289
- /** Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks. */
502
+ /**
503
+ * Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks.
504
+ * `on`/`andOn`/`orOn` join two COLUMNS; `onVal`/`andOnVal`/`orOnVal` join a column
505
+ * to a bound VALUE (AdonisJS/Knex parity) — the value flows through the join-params
506
+ * channel into the compiled parameter list.
507
+ */
290
508
  interface JoinBuilder {
291
- parts: Array<{ kind: "and" | "or"; left: string; right: string }>;
509
+ /** A column-to-column part (`value` absent) or a column-to-value part (`value` set). */
510
+ parts: Array<{
511
+ kind: "and" | "or";
512
+ left: string;
513
+ right?: string;
514
+ value?: { v: unknown };
515
+ }>;
292
516
  on(left: string, right: string): JoinBuilder;
293
517
  andOn(left: string, right: string): JoinBuilder;
518
+ orOn(left: string, right: string): JoinBuilder;
519
+ onVal(left: string, value: unknown): JoinBuilder;
294
520
  andOnVal(left: string, value: unknown): JoinBuilder;
521
+ orOnVal(left: string, value: unknown): JoinBuilder;
295
522
  }
296
523
 
297
524
  /** Offset-based paginator (Story 29.10). */
@@ -306,20 +533,46 @@ export class Paginator<T> {
306
533
  };
307
534
  #baseUrl?: string;
308
535
  #queryString: Record<string, unknown> = {};
536
+ #metaKeys?: Record<string, string>;
309
537
 
310
538
  constructor(
311
539
  items: T[],
312
540
  base: { total: number; perPage: number; currentPage: number },
541
+ metaKeys?: Record<string, string>,
313
542
  ) {
314
543
  this.items = items;
315
544
  const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
316
545
  this.meta = { ...base, lastPage, firstPage: 1 };
546
+ this.#metaKeys = metaKeys;
317
547
  }
318
548
 
319
549
  all(): T[] {
320
550
  return this.items;
321
551
  }
322
552
 
553
+ // Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
554
+ // carried in `.meta`, exposed directly on the instance for convenience.
555
+ /** Total row count across all pages. */
556
+ get total(): number {
557
+ return this.meta.total;
558
+ }
559
+ /** Rows per page. */
560
+ get perPage(): number {
561
+ return this.meta.perPage;
562
+ }
563
+ /** The current page number. */
564
+ get currentPage(): number {
565
+ return this.meta.currentPage;
566
+ }
567
+ /** The last page number. */
568
+ get lastPage(): number {
569
+ return this.meta.lastPage;
570
+ }
571
+ /** The first page number (always 1). */
572
+ get firstPage(): number {
573
+ return this.meta.firstPage;
574
+ }
575
+
323
576
  /** True when there is more than one page of results (AdonisJS `hasPages`). */
324
577
  get hasPages(): boolean {
325
578
  return this.meta.lastPage > 1;
@@ -332,7 +585,7 @@ export class Paginator<T> {
332
585
 
333
586
  serialize(opts?: { fields?: string[] }): {
334
587
  data: unknown[];
335
- meta: Paginator<T>["meta"];
588
+ meta: Record<string, unknown>;
336
589
  } {
337
590
  const data = this.items.map((item) => {
338
591
  if (!opts?.fields) return item;
@@ -341,7 +594,9 @@ export class Paginator<T> {
341
594
  picked[f] = (item as Record<string, unknown>)[f];
342
595
  return picked;
343
596
  });
344
- return { data, meta: this.meta };
597
+ // Same meta shape as toJSON — snake_case keys via the naming strategy's
598
+ // paginationMetaKeys, plus page URLs when a baseUrl is set.
599
+ return { data, meta: this.#buildMeta() };
345
600
  }
346
601
 
347
602
  baseUrl(url: string): this {
@@ -353,29 +608,76 @@ export class Paginator<T> {
353
608
  return this;
354
609
  }
355
610
 
611
+ /**
612
+ * Build the URL for a page number, honouring `baseUrl` + `queryString`.
613
+ * Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
614
+ */
615
+ getUrl(page: number): string {
616
+ if (!this.#baseUrl) return "";
617
+ const params = new URLSearchParams();
618
+ for (const [k, v] of Object.entries(this.#queryString))
619
+ params.set(k, String(v));
620
+ params.set("page", String(page));
621
+ return `${this.#baseUrl}?${params.toString()}`;
622
+ }
623
+
624
+ /** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
625
+ getNextPageUrl(): string | null {
626
+ return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
627
+ }
628
+
629
+ /** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
630
+ getPreviousPageUrl(): string | null {
631
+ return this.meta.currentPage > 1
632
+ ? this.getUrl(this.meta.currentPage - 1)
633
+ : null;
634
+ }
635
+
636
+ /** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
637
+ getUrlsForRange(
638
+ start: number,
639
+ end: number,
640
+ ): Array<{ page: number; url: string; isActive: boolean }> {
641
+ const lo = Math.max(1, start);
642
+ const hi = Math.min(this.meta.lastPage, end);
643
+ const range: Array<{ page: number; url: string; isActive: boolean }> = [];
644
+ for (let page = lo; page <= hi; page++)
645
+ range.push({
646
+ page,
647
+ url: this.getUrl(page),
648
+ isActive: page === this.meta.currentPage,
649
+ });
650
+ return range;
651
+ }
652
+
653
+ /**
654
+ * Build the serialized `meta` object: the raw camelCase fields plus page URLs
655
+ * (when a baseUrl is set), remapped through the naming strategy's
656
+ * `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
657
+ * Shared by {@link toJSON} and {@link serialize} so they never diverge.
658
+ */
659
+ #buildMeta(): Record<string, unknown> {
660
+ const raw: Record<string, unknown> = { ...this.meta };
661
+ if (this.#baseUrl) {
662
+ raw.firstPageUrl = this.getUrl(1);
663
+ raw.lastPageUrl = this.getUrl(this.meta.lastPage);
664
+ const next = this.getNextPageUrl();
665
+ const prev = this.getPreviousPageUrl();
666
+ if (next) raw.nextPageUrl = next;
667
+ if (prev) raw.previousPageUrl = prev;
668
+ }
669
+ const keys = this.#metaKeys;
670
+ if (!keys) return raw;
671
+ const meta: Record<string, unknown> = {};
672
+ for (const [k, v] of Object.entries(raw)) meta[keys[k] ?? k] = v;
673
+ return meta;
674
+ }
675
+
356
676
  toJSON(): {
357
677
  data: unknown[];
358
- meta: Paginator<T>["meta"] & Record<string, unknown>;
678
+ meta: Record<string, unknown>;
359
679
  } {
360
- const meta: Paginator<T>["meta"] & Record<string, unknown> = {
361
- ...this.meta,
362
- };
363
- if (this.#baseUrl) {
364
- const build = (page: number) => {
365
- const params = new URLSearchParams();
366
- for (const [k, v] of Object.entries(this.#queryString))
367
- params.set(k, String(v));
368
- params.set("page", String(page));
369
- return `${this.#baseUrl}?${params.toString()}`;
370
- };
371
- meta.firstPageUrl = build(1);
372
- meta.lastPageUrl = build(this.meta.lastPage);
373
- if (this.meta.currentPage < this.meta.lastPage)
374
- meta.nextPageUrl = build(this.meta.currentPage + 1);
375
- if (this.meta.currentPage > 1)
376
- meta.previousPageUrl = build(this.meta.currentPage - 1);
377
- }
378
- return { data: this.items as unknown[], meta };
680
+ return { data: this.items as unknown[], meta: this.#buildMeta() };
379
681
  }
380
682
  }
381
683
 
@@ -393,7 +695,7 @@ export class ModelQuery<T extends BaseEntity> {
393
695
  #softDeletes: boolean;
394
696
  #softScope: SoftDeleteScope = "default";
395
697
  #wheres: WhereClause[] = [];
396
- #orderBys: Array<{ column: string; direction: "asc" | "desc" }> = [];
698
+ #orderBys: OrderByEntry[] = [];
397
699
  #select: string[] = ["*"];
398
700
  #limit?: number;
399
701
  #offset?: number;
@@ -403,24 +705,62 @@ export class ModelQuery<T extends BaseEntity> {
403
705
  /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
404
706
  #subqueryAlias?: string;
405
707
  /** Raw JOIN fragments — Story 29.4. */
406
- #joins: string[] = [];
407
- /** Row lock mode — Story 30.8. */
408
- #lockMode: "FOR UPDATE" | "FOR SHARE" | null = null;
708
+ #joins: Array<{ sql: string; params: unknown[] }> = [];
709
+ /** Row lock base mode — Story 30.8. */
710
+ #lockMode:
711
+ | "FOR UPDATE"
712
+ | "FOR SHARE"
713
+ | "FOR NO KEY UPDATE"
714
+ | "FOR KEY SHARE"
715
+ | null = null;
716
+ /** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
717
+ #lockModifier: "SKIP LOCKED" | "NOWAIT" | null = null;
718
+ /** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
719
+ #sideloaded: Record<string, unknown> | null = null;
409
720
  /** Per-query debug flag — Story 29.11. */
410
721
  #debugFlag = false;
411
722
  /** Distinct flag — Story 29.5. */
412
723
  #distinct = false;
724
+ #distinctOn: string[] = [];
413
725
  /** GROUP BY columns (Lucid parity). */
414
- #groupBy: string[] = [];
726
+ #groupBy: GroupByEntry[] = [];
415
727
  /** HAVING clauses — structured + raw (Lucid parity). */
416
728
  #having: HavingEntry[] = [];
417
729
  /** CTEs registered via `.with()` (Lucid parity). */
418
- #ctes: Array<{ name: string; query: ModelQuery<BaseEntity> }> = [];
730
+ #ctes: Array<{
731
+ name: string;
732
+ query: ModelQuery<BaseEntity>;
733
+ recursive?: boolean;
734
+ materialized?: boolean;
735
+ }> = [];
419
736
  /** UNION / UNION ALL branches (Lucid parity). */
420
- #unions: Array<{ query: ModelQuery<BaseEntity>; all: boolean }> = [];
737
+ #unions: Array<{
738
+ query: ModelQuery<BaseEntity>;
739
+ all: boolean;
740
+ op?: "union" | "intersect" | "except";
741
+ }> = [];
421
742
  /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
422
- #pivotWheres: Array<{ column: string; operator: string; value: unknown }> =
423
- [];
743
+ #pivotWheres: Array<{
744
+ column: string;
745
+ operator: string;
746
+ value: unknown;
747
+ /** AND/OR within the parenthesised pivot-filter group — see `#runInQuery`. */
748
+ type: "and" | "or";
749
+ }> = [];
750
+ /**
751
+ * Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
752
+ * the relation proxy's scoped query; invoked at `#buildSpec()` time with the
753
+ * CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
754
+ * back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
755
+ * time would freeze the predicate before those calls and silently drop them).
756
+ */
757
+ #pivotExists?: (
758
+ pivotWheres: ReadonlyArray<{
759
+ column: string;
760
+ operator: string;
761
+ value: unknown;
762
+ }>,
763
+ ) => { sql: string; bindings: unknown[] };
424
764
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
425
765
  #dialect: AtlasDialect;
426
766
 
@@ -432,6 +772,8 @@ export class ModelQuery<T extends BaseEntity> {
432
772
  resolveColumn: ColumnResolver = (c) => c,
433
773
  softDeletes = false,
434
774
  dialect: AtlasDialect = getAtlasDialect(),
775
+ prepareValue: ValuePreparer = (_c, v) => v,
776
+ onDomainEvents?: (events: DomainEvent[]) => Promise<void>,
435
777
  ) {
436
778
  this.#tableName = tableName;
437
779
  this.#db = db;
@@ -440,8 +782,15 @@ export class ModelQuery<T extends BaseEntity> {
440
782
  this.#resolveColumn = resolveColumn;
441
783
  this.#softDeletes = softDeletes;
442
784
  this.#dialect = dialect;
785
+ this.#prepareValue = prepareValue;
786
+ this.#onDomainEvents = onDomainEvents;
443
787
  }
444
788
 
789
+ /** @see ValuePreparer — identity unless the owning repository wires prepare in. */
790
+ #prepareValue: ValuePreparer;
791
+ /** Domain-event bus threaded from the owning repository — propagated to preload repos. */
792
+ #onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
793
+
445
794
  /** Include soft-deleted rows in the result (default behavior excludes them). */
446
795
  withTrashed(): this {
447
796
  this.#softScope = "with-trashed";
@@ -469,12 +818,31 @@ export class ModelQuery<T extends BaseEntity> {
469
818
 
470
819
  /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
471
820
  select(columns: string | string[]): this {
472
- this.#select = Array.isArray(columns)
821
+ const list = Array.isArray(columns)
473
822
  ? columns
474
823
  : columns.split(",").map((c) => c.trim());
824
+ this.#select = list.map((c) => this.#resolveSelect(c));
475
825
  return this;
476
826
  }
477
827
 
828
+ /**
829
+ * Resolve a bare model-property select/returning target to its DB column
830
+ * (honouring `@Column({ columnName })`), leaving expressions / aliases /
831
+ * qualified names / `*` untouched. A bare identifier IS validated through the
832
+ * column resolver — so a typo like `select('lable')` raises the same Atlas
833
+ * error as `where`/`orderBy`, rather than reaching the DB.
834
+ */
835
+ #resolveSelect(col: string): string {
836
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col)) return this.#resolveColumn(col);
837
+ // `col as alias` — resolve the (bare) column part to its DB name, keep the
838
+ // alias verbatim, so `select('label as name')` honours a columnName override.
839
+ const aliased = col.match(
840
+ /^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i,
841
+ );
842
+ if (aliased) return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
843
+ return col;
844
+ }
845
+
478
846
  where(callback: WhereCallback): this;
479
847
  where(column: string, value: unknown): this;
480
848
  where(column: string, operator: string, value: unknown): this;
@@ -525,13 +893,88 @@ export class ModelQuery<T extends BaseEntity> {
525
893
  return this;
526
894
  }
527
895
 
896
+ // ─── AND aliases ──────────────────────────────────────────
897
+ //
898
+ // Lucid documents an `and*` spelling alongside every `where*`. They are
899
+ // exact synonyms — the base methods already default to AND — and exist so a
900
+ // chain can say so out loud: `.where(a).andWhere(b)`. Kept as thin
901
+ // delegations rather than duplicated bodies, so they cannot drift.
902
+
903
+ andWhere(callback: WhereCallback): this;
904
+ andWhere(column: string, value: unknown): this;
905
+ andWhere(column: string, operator: string, value: unknown): this;
906
+ andWhere(
907
+ columnOrCb: string | WhereCallback,
908
+ operatorOrValue?: unknown,
909
+ value?: unknown,
910
+ ): this {
911
+ // The 2-arg overload must not forward a phantom third argument: `where`
912
+ // switches on `value === undefined` to tell `(col, value)` from
913
+ // `(col, operator, value)`.
914
+ return typeof columnOrCb === "function"
915
+ ? this.where(columnOrCb)
916
+ : value === undefined
917
+ ? this.where(columnOrCb, operatorOrValue)
918
+ : this.where(columnOrCb, operatorOrValue as string, value);
919
+ }
920
+
921
+ /** Alias of {@link whereNot} (Lucid parity). */
922
+ andWhereNot(column: string, value: unknown): this {
923
+ return this.whereNot(column, value);
924
+ }
925
+
926
+ /** Alias of {@link whereIn} (Lucid parity). */
927
+ andWhereIn(column: string, values: readonly unknown[]): this {
928
+ return this.whereIn(column, values);
929
+ }
930
+
931
+ /** Alias of {@link whereNotIn} (Lucid parity). */
932
+ andWhereNotIn(column: string, values: readonly unknown[]): this {
933
+ return this.whereNotIn(column, values);
934
+ }
935
+
936
+ /** Alias of {@link whereNull} (Lucid parity). */
937
+ andWhereNull(column: string): this {
938
+ return this.whereNull(column);
939
+ }
940
+
941
+ /** Alias of {@link whereNotNull} (Lucid parity). */
942
+ andWhereNotNull(column: string): this {
943
+ return this.whereNotNull(column);
944
+ }
945
+
946
+ /** Alias of {@link whereBetween} (Lucid parity). */
947
+ andWhereBetween(column: string, range: readonly [unknown, unknown]): this {
948
+ return this.whereBetween(column, range);
949
+ }
950
+
951
+ /** Alias of {@link whereNotBetween} (Lucid parity). */
952
+ andWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
953
+ return this.whereNotBetween(column, range);
954
+ }
955
+
956
+ /** Alias of {@link whereLike} (Lucid parity). */
957
+ andWhereLike(column: string, pattern: string): this {
958
+ return this.whereLike(column, pattern);
959
+ }
960
+
961
+ /** Alias of {@link whereILike} (Lucid parity). */
962
+ andWhereILike(column: string, pattern: string): this {
963
+ return this.whereILike(column, pattern);
964
+ }
965
+
966
+ /** Alias of {@link whereColumn} (Lucid parity). */
967
+ andWhereColumn(left: string, operator: string, right: string): this {
968
+ return this.whereColumn(left, operator, right);
969
+ }
970
+
528
971
  /** `WHERE col != ?` — negation of `where`. */
529
972
  whereNot(column: string, value: unknown): this {
530
973
  this.#wheres.push({
531
974
  type: "and",
532
975
  column: this.#resolveColumn(column),
533
976
  operator: "!=",
534
- value,
977
+ value: this.#prep(column, value),
535
978
  });
536
979
  return this;
537
980
  }
@@ -555,7 +998,7 @@ export class ModelQuery<T extends BaseEntity> {
555
998
  type: "and",
556
999
  column: this.#resolveColumn(column),
557
1000
  operator: "IN",
558
- value: [...source],
1001
+ value: this.#prep(column, [...source]),
559
1002
  });
560
1003
  return this;
561
1004
  }
@@ -579,7 +1022,7 @@ export class ModelQuery<T extends BaseEntity> {
579
1022
  type: "and",
580
1023
  column: this.#resolveColumn(column),
581
1024
  operator: "NOT IN",
582
- value: [...source],
1025
+ value: this.#prep(column, [...source]),
583
1026
  });
584
1027
  return this;
585
1028
  }
@@ -590,7 +1033,7 @@ export class ModelQuery<T extends BaseEntity> {
590
1033
  type: "and",
591
1034
  column: this.#resolveColumn(column),
592
1035
  operator: "BETWEEN",
593
- value: [...range],
1036
+ value: this.#prep(column, [...range]),
594
1037
  });
595
1038
  return this;
596
1039
  }
@@ -601,7 +1044,7 @@ export class ModelQuery<T extends BaseEntity> {
601
1044
  type: "and",
602
1045
  column: this.#resolveColumn(column),
603
1046
  operator: "NOT BETWEEN",
604
- value: [...range],
1047
+ value: this.#prep(column, [...range]),
605
1048
  });
606
1049
  return this;
607
1050
  }
@@ -664,7 +1107,7 @@ export class ModelQuery<T extends BaseEntity> {
664
1107
  type: "or",
665
1108
  column: this.#resolveColumn(column),
666
1109
  operator: "!=",
667
- value,
1110
+ value: this.#prep(column, value),
668
1111
  });
669
1112
  return this;
670
1113
  }
@@ -688,7 +1131,7 @@ export class ModelQuery<T extends BaseEntity> {
688
1131
  type: "or",
689
1132
  column: this.#resolveColumn(column),
690
1133
  operator: "IN",
691
- value: [...source],
1134
+ value: this.#prep(column, [...source]),
692
1135
  });
693
1136
  return this;
694
1137
  }
@@ -712,7 +1155,7 @@ export class ModelQuery<T extends BaseEntity> {
712
1155
  type: "or",
713
1156
  column: this.#resolveColumn(column),
714
1157
  operator: "NOT IN",
715
- value: [...source],
1158
+ value: this.#prep(column, [...source]),
716
1159
  });
717
1160
  return this;
718
1161
  }
@@ -723,7 +1166,7 @@ export class ModelQuery<T extends BaseEntity> {
723
1166
  type: "or",
724
1167
  column: this.#resolveColumn(column),
725
1168
  operator: "BETWEEN",
726
- value: [...range],
1169
+ value: this.#prep(column, [...range]),
727
1170
  });
728
1171
  return this;
729
1172
  }
@@ -734,7 +1177,7 @@ export class ModelQuery<T extends BaseEntity> {
734
1177
  type: "or",
735
1178
  column: this.#resolveColumn(column),
736
1179
  operator: "NOT BETWEEN",
737
- value: [...range],
1180
+ value: this.#prep(column, [...range]),
738
1181
  });
739
1182
  return this;
740
1183
  }
@@ -803,9 +1246,13 @@ export class ModelQuery<T extends BaseEntity> {
803
1246
  * Not exported from the package barrel — only accessible inside the Atlas
804
1247
  * codebase via direct ModelQuery instance access.
805
1248
  */
806
- #pushWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1249
+ #pushWhereRaw(
1250
+ sql: string,
1251
+ bindings: readonly unknown[] = [],
1252
+ type: "and" | "or" = "and",
1253
+ ): this {
807
1254
  this.#wheres.push({
808
- type: "and",
1255
+ type,
809
1256
  kind: "raw",
810
1257
  sql,
811
1258
  bindings: [...bindings],
@@ -813,6 +1260,52 @@ export class ModelQuery<T extends BaseEntity> {
813
1260
  return this;
814
1261
  }
815
1262
 
1263
+ /** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
1264
+ andWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1265
+ return this.whereRaw(sql, bindings);
1266
+ }
1267
+
1268
+ /**
1269
+ * `OR <raw fragment>` (Lucid parity).
1270
+ *
1271
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1272
+ * Subject to the same strict-mode gate as {@link whereRaw}.
1273
+ */
1274
+ orWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1275
+ this.#assertRawAllowed("orWhereRaw");
1276
+ return this.#pushWhereRaw(sql, bindings, "or");
1277
+ }
1278
+
1279
+ /** Shared strict-mode gate for the raw WHERE entry points. */
1280
+ #assertRawAllowed(method: string): void {
1281
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1282
+ throw new Error(
1283
+ `${method}() is disabled in Atlas strict mode. ` +
1284
+ "Use whereExpr() or a structured builder method instead. " +
1285
+ "Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.",
1286
+ );
1287
+ }
1288
+ }
1289
+
1290
+ /**
1291
+ * Framework-internal: register the deferred m2m EXISTS predicate for a lazy
1292
+ * `related().query()`. The builder is re-invoked on every `#buildSpec()` with
1293
+ * the pivot constraints known at that moment, so `.wherePivot()` added after
1294
+ * the proxy returned still applies. Not exported from the barrel.
1295
+ */
1296
+ setPivotExistsBuilder(
1297
+ builder: (
1298
+ pivotWheres: ReadonlyArray<{
1299
+ column: string;
1300
+ operator: string;
1301
+ value: unknown;
1302
+ }>,
1303
+ ) => { sql: string; bindings: unknown[] },
1304
+ ): this {
1305
+ this.#pivotExists = builder;
1306
+ return this;
1307
+ }
1308
+
816
1309
  /**
817
1310
  * **SAFE** alternative to `whereRaw` for the common case of a single
818
1311
  * SQL expression built from a validated column + operator + bound value.
@@ -863,6 +1356,20 @@ export class ModelQuery<T extends BaseEntity> {
863
1356
  `whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
864
1357
  );
865
1358
  }
1359
+ // The charset blocks comparison/quote symbols, but bare SQL keywords
1360
+ // (OR / AND / IS / NOT / SELECT …) are just letters and would slip
1361
+ // through, letting `extra` alter the predicate's logical structure
1362
+ // (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
1363
+ // SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
1364
+ // token — arithmetic on columns/numbers/functions only.
1365
+ for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
1366
+ if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
1367
+ throw new Error(
1368
+ `whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
1369
+ "whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.",
1370
+ );
1371
+ }
1372
+ }
866
1373
  // `op` is interpolated raw into the fragment below, so it MUST be
867
1374
  // allow-listed — the 3-arg path gets this from the Rust operator
868
1375
  // validation, but the raw 4-arg path bypasses Rust and would
@@ -881,9 +1388,16 @@ export class ModelQuery<T extends BaseEntity> {
881
1388
  // the operator against the allow-list above.
882
1389
  if (hasExtra) {
883
1390
  const q = this.#quote(resolved);
884
- return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
1391
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
1392
+ this.#prep(column, value),
1393
+ ]);
885
1394
  }
886
- this.#wheres.push({ type: "and", column: resolved, operator: op, value });
1395
+ this.#wheres.push({
1396
+ type: "and",
1397
+ column: resolved,
1398
+ operator: op,
1399
+ value: this.#prep(column, value),
1400
+ });
887
1401
  return this;
888
1402
  }
889
1403
 
@@ -893,6 +1407,206 @@ export class ModelQuery<T extends BaseEntity> {
893
1407
  * operator is allow-listed; nothing is bound (it's a column reference, not a
894
1408
  * value), which the standard `where`/`whereExpr` value-binding path can't do.
895
1409
  */
1410
+ // ─── EXISTS ───────────────────────────────────────────────
1411
+ //
1412
+ // `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
1413
+ // builder `repo.query()` actually hands back, so it was unreachable from
1414
+ // normal use. The subquery is another `ModelQuery`; correlate it to the
1415
+ // outer table with `whereColumn`:
1416
+ //
1417
+ // userRepo.query().whereExists(
1418
+ // postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
1419
+ // )
1420
+ //
1421
+ // For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
1422
+ // join predicate from the relation metadata.
1423
+
1424
+ /** `WHERE EXISTS (subquery)` (Lucid parity). */
1425
+ whereExists(subquery: ModelQuery<BaseEntity>): this {
1426
+ return this.#pushExists("and", false, subquery);
1427
+ }
1428
+
1429
+ /** Alias of {@link whereExists} (Lucid parity). */
1430
+ andWhereExists(subquery: ModelQuery<BaseEntity>): this {
1431
+ return this.#pushExists("and", false, subquery);
1432
+ }
1433
+
1434
+ /** `OR EXISTS (subquery)` (Lucid parity). */
1435
+ orWhereExists(subquery: ModelQuery<BaseEntity>): this {
1436
+ return this.#pushExists("or", false, subquery);
1437
+ }
1438
+
1439
+ /** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
1440
+ whereNotExists(subquery: ModelQuery<BaseEntity>): this {
1441
+ return this.#pushExists("and", true, subquery);
1442
+ }
1443
+
1444
+ /** Alias of {@link whereNotExists} (Lucid parity). */
1445
+ andWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
1446
+ return this.#pushExists("and", true, subquery);
1447
+ }
1448
+
1449
+ /** `OR NOT EXISTS (subquery)` (Lucid parity). */
1450
+ orWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
1451
+ return this.#pushExists("or", true, subquery);
1452
+ }
1453
+
1454
+ // ─── JSON ─────────────────────────────────────────────────
1455
+ //
1456
+ // Every value crosses the boundary as a bound param — the path and the
1457
+ // compared value both. Only the column is a quoted identifier. Path access
1458
+ // and containment are each spelled per dialect, and SQLite has no
1459
+ // containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
1460
+
1461
+ /**
1462
+ * `WHERE <col at path> <op> ?` — compare a value inside a JSON column
1463
+ * (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
1464
+ *
1465
+ * query.whereJsonPath('data', '$.address.city', '=', 'Paris')
1466
+ */
1467
+ whereJsonPath(
1468
+ column: string,
1469
+ path: string,
1470
+ operator: string,
1471
+ value: unknown,
1472
+ ): this {
1473
+ return this.#pushJson("and", false, "path", column, value, path, operator);
1474
+ }
1475
+
1476
+ /** Alias of {@link whereJsonPath} (Lucid parity). */
1477
+ andWhereJsonPath(
1478
+ column: string,
1479
+ path: string,
1480
+ operator: string,
1481
+ value: unknown,
1482
+ ): this {
1483
+ return this.#pushJson("and", false, "path", column, value, path, operator);
1484
+ }
1485
+
1486
+ /** `OR <col at path> <op> ?` (Lucid parity). */
1487
+ orWhereJsonPath(
1488
+ column: string,
1489
+ path: string,
1490
+ operator: string,
1491
+ value: unknown,
1492
+ ): this {
1493
+ return this.#pushJson("or", false, "path", column, value, path, operator);
1494
+ }
1495
+
1496
+ /**
1497
+ * `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
1498
+ * `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
1499
+ *
1500
+ * Postgres and MySQL only — SQLite has no JSON containment operator and the
1501
+ * compiler raises `E_UNSUPPORTED` there.
1502
+ */
1503
+ whereJsonSupersetOf(column: string, value: unknown): this {
1504
+ return this.#pushJson("and", false, "superset", column, value);
1505
+ }
1506
+
1507
+ /** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
1508
+ andWhereJsonSupersetOf(column: string, value: unknown): this {
1509
+ return this.#pushJson("and", false, "superset", column, value);
1510
+ }
1511
+
1512
+ /** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
1513
+ orWhereJsonSupersetOf(column: string, value: unknown): this {
1514
+ return this.#pushJson("or", false, "superset", column, value);
1515
+ }
1516
+
1517
+ /** `WHERE NOT (<col> @> ?)` (Lucid parity). */
1518
+ whereNotJsonSupersetOf(column: string, value: unknown): this {
1519
+ return this.#pushJson("and", true, "superset", column, value);
1520
+ }
1521
+
1522
+ /** `OR NOT (<col> @> ?)` (Lucid parity). */
1523
+ orWhereNotJsonSupersetOf(column: string, value: unknown): this {
1524
+ return this.#pushJson("or", true, "superset", column, value);
1525
+ }
1526
+
1527
+ /**
1528
+ * `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
1529
+ * `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
1530
+ */
1531
+ whereJsonSubsetOf(column: string, value: unknown): this {
1532
+ return this.#pushJson("and", false, "subset", column, value);
1533
+ }
1534
+
1535
+ /** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
1536
+ andWhereJsonSubsetOf(column: string, value: unknown): this {
1537
+ return this.#pushJson("and", false, "subset", column, value);
1538
+ }
1539
+
1540
+ /** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
1541
+ orWhereJsonSubsetOf(column: string, value: unknown): this {
1542
+ return this.#pushJson("or", false, "subset", column, value);
1543
+ }
1544
+
1545
+ /** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
1546
+ whereNotJsonSubsetOf(column: string, value: unknown): this {
1547
+ return this.#pushJson("and", true, "subset", column, value);
1548
+ }
1549
+
1550
+ /** `OR NOT (<col> <@ ?)` (Lucid parity). */
1551
+ orWhereNotJsonSubsetOf(column: string, value: unknown): this {
1552
+ return this.#pushJson("or", true, "subset", column, value);
1553
+ }
1554
+
1555
+ #pushJson(
1556
+ type: "and" | "or",
1557
+ negated: boolean,
1558
+ jsonOp: "path" | "superset" | "subset",
1559
+ column: string,
1560
+ value: unknown,
1561
+ path?: string,
1562
+ operator?: string,
1563
+ ): this {
1564
+ // A JSONPath is bound, not interpolated, so injection is not the concern
1565
+ // here — a clear early error for a malformed path is. Lucid/Knex paths
1566
+ // start at the document root.
1567
+ if (path !== undefined && !path.startsWith("$")) {
1568
+ throw new Error(
1569
+ `whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`,
1570
+ );
1571
+ }
1572
+ // Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
1573
+ // and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
1574
+ // as-is would not cast. A path comparison keeps its scalar value.
1575
+ const bound =
1576
+ jsonOp === "path"
1577
+ ? value
1578
+ : typeof value === "string"
1579
+ ? value
1580
+ : JSON.stringify(value);
1581
+ this.#wheres.push({
1582
+ type,
1583
+ kind: "json",
1584
+ jsonOp,
1585
+ column: this.#resolveColumn(column),
1586
+ negated,
1587
+ path,
1588
+ operator,
1589
+ value: bound,
1590
+ });
1591
+ return this;
1592
+ }
1593
+
1594
+ #pushExists(
1595
+ type: "and" | "or",
1596
+ negated: boolean,
1597
+ subquery: ModelQuery<BaseEntity>,
1598
+ ): this {
1599
+ // `#buildSpec` is private, but private access is per-class, not per
1600
+ // instance: another ModelQuery's spec is reachable from here.
1601
+ this.#wheres.push({
1602
+ type,
1603
+ kind: "exists",
1604
+ negated,
1605
+ subquery: subquery.#buildSpec(),
1606
+ });
1607
+ return this;
1608
+ }
1609
+
896
1610
  whereColumn(left: string, operator: string, right: string): this {
897
1611
  return this.#whereColumn("and", left, operator, right);
898
1612
  }
@@ -902,11 +1616,27 @@ export class ModelQuery<T extends BaseEntity> {
902
1616
  return this.#whereColumn("or", left, operator, right);
903
1617
  }
904
1618
 
1619
+ /** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
1620
+ whereNotColumn(left: string, operator: string, right: string): this {
1621
+ return this.#whereColumn("and", left, operator, right, true);
1622
+ }
1623
+
1624
+ /** Alias of {@link whereNotColumn} (Lucid parity). */
1625
+ andWhereNotColumn(left: string, operator: string, right: string): this {
1626
+ return this.#whereColumn("and", left, operator, right, true);
1627
+ }
1628
+
1629
+ /** `OR NOT (left <op> right)` (Lucid parity). */
1630
+ orWhereNotColumn(left: string, operator: string, right: string): this {
1631
+ return this.#whereColumn("or", left, operator, right, true);
1632
+ }
1633
+
905
1634
  #whereColumn(
906
1635
  type: "and" | "or",
907
1636
  left: string,
908
1637
  operator: string,
909
1638
  right: string,
1639
+ negated = false,
910
1640
  ): this {
911
1641
  if (!WHEREEXPR_OPERATORS.has(operator)) {
912
1642
  throw new Error(
@@ -919,7 +1649,7 @@ export class ModelQuery<T extends BaseEntity> {
919
1649
  // `[table.]column` charset. This closes the injection surface regardless of
920
1650
  // what #resolveColumn returns (it can be an identity resolver on sub-queries).
921
1651
  const safe = (name: string): string => {
922
- const resolved = this.#resolveColumn(name);
1652
+ const resolved = this.#resolveColumnReference(name);
923
1653
  if (
924
1654
  !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
925
1655
  ) {
@@ -934,11 +1664,48 @@ export class ModelQuery<T extends BaseEntity> {
934
1664
  .map((part) => this.#quote(part))
935
1665
  .join(".");
936
1666
  };
937
- const sql = `${safe(left)} ${operator} ${safe(right)}`;
1667
+ const predicate = `${safe(left)} ${operator} ${safe(right)}`;
1668
+ // Both operands are already validated identifiers and the operator is
1669
+ // allow-listed, so wrapping in NOT(...) adds no new surface.
1670
+ const sql = negated ? `NOT (${predicate})` : predicate;
938
1671
  this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
939
1672
  return this;
940
1673
  }
941
1674
 
1675
+ /**
1676
+ * Resolve a column reference that may legitimately point at a table other
1677
+ * than this query's own.
1678
+ *
1679
+ * `#resolveColumn` only knows the entity's own columns, so it rejects
1680
+ * anything qualified. That is right for a value predicate, but wrong for a
1681
+ * column-to-column one: a correlated subquery
1682
+ * (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
1683
+ * and a joined query both have to name another table, and atlas cannot know
1684
+ * that table's columns. So: an unqualified name resolves as usual (typos
1685
+ * still get the helpful error), and a `table.column` naming a different
1686
+ * table passes through — validated against the identifier charset here and
1687
+ * quoted segment by segment by the caller, never interpolated loose. A typo
1688
+ * in that case surfaces as a database error rather than an atlas one, which
1689
+ * is the unavoidable cost of referencing a table we have no metadata for.
1690
+ */
1691
+ #resolveColumnReference(name: string): string {
1692
+ const qualified =
1693
+ /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
1694
+ if (!qualified) return this.#resolveColumn(name);
1695
+
1696
+ const [, table, column] = qualified;
1697
+ // Our own table: resolve the column half so `@Column({ columnName })` and
1698
+ // the camel→snake convention still apply.
1699
+ if (table === this.#tableName) {
1700
+ return `${table}.${this.#resolveColumn(column as string)}`;
1701
+ }
1702
+ // Another table in scope (outer query or JOIN). Charset-checked by the
1703
+ // regex above and quoted segment by segment by the caller — strict mode
1704
+ // does not apply, since its concern is unvalidated SQL reaching the
1705
+ // compiler and this identifier is validated.
1706
+ return `${table}.${column}`;
1707
+ }
1708
+
942
1709
  /**
943
1710
  * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
944
1711
  * by the existence of related rows, optionally constrained by a callback.
@@ -987,6 +1754,22 @@ export class ModelQuery<T extends BaseEntity> {
987
1754
  return this;
988
1755
  }
989
1756
 
1757
+ /** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
1758
+ andWhereHas(
1759
+ relationName: string,
1760
+ callback?: (query: ModelQuery<BaseEntity>) => void,
1761
+ ): this {
1762
+ return this.whereHas(relationName, callback);
1763
+ }
1764
+
1765
+ /** Alias of {@link whereDoesntHave} (Lucid parity). */
1766
+ andWhereDoesntHave(
1767
+ relationName: string,
1768
+ callback?: (query: ModelQuery<BaseEntity>) => void,
1769
+ ): this {
1770
+ return this.whereDoesntHave(relationName, callback);
1771
+ }
1772
+
990
1773
  /**
991
1774
  * Short form of `whereHas`. With an operator + count, emits a count threshold:
992
1775
  * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
@@ -1026,6 +1809,26 @@ export class ModelQuery<T extends BaseEntity> {
1026
1809
  return this;
1027
1810
  }
1028
1811
 
1812
+ /** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
1813
+ orDoesntHave(relationName: string): this {
1814
+ this.#wheres.push(this.#buildExistsClause("or", true, relationName));
1815
+ return this;
1816
+ }
1817
+
1818
+ /** Alias of {@link has} (Lucid parity) — `has` is already AND. */
1819
+ andHas(
1820
+ relationName: string,
1821
+ countOp?: string,
1822
+ countThreshold?: number,
1823
+ ): this {
1824
+ return this.has(relationName, countOp, countThreshold);
1825
+ }
1826
+
1827
+ /** Alias of {@link doesntHave} (Lucid parity). */
1828
+ andDoesntHave(relationName: string): this {
1829
+ return this.doesntHave(relationName);
1830
+ }
1831
+
1029
1832
  /**
1030
1833
  * Set this query's projection alias — only meaningful when this ModelQuery
1031
1834
  * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
@@ -1070,27 +1873,37 @@ export class ModelQuery<T extends BaseEntity> {
1070
1873
  /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
1071
1874
  async count(column: string = "*"): Promise<number> {
1072
1875
  const expr =
1073
- column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
1876
+ column === "*"
1877
+ ? "COUNT(*)"
1878
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
1074
1879
  return Number((await this.#runScalar(expr)) ?? 0);
1075
1880
  }
1076
1881
 
1077
1882
  async sum(column: string): Promise<number | null> {
1078
- const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
1883
+ const v = await this.#runScalar(
1884
+ `SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
1885
+ );
1079
1886
  return v === null || v === undefined ? null : Number(v);
1080
1887
  }
1081
1888
 
1082
1889
  async avg(column: string): Promise<number | null> {
1083
- const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
1890
+ const v = await this.#runScalar(
1891
+ `AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
1892
+ );
1084
1893
  return v === null || v === undefined ? null : Number(v);
1085
1894
  }
1086
1895
 
1087
1896
  async min(column: string): Promise<number | null> {
1088
- const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
1897
+ const v = await this.#runScalar(
1898
+ `MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
1899
+ );
1089
1900
  return v === null || v === undefined ? null : Number(v);
1090
1901
  }
1091
1902
 
1092
1903
  async max(column: string): Promise<number | null> {
1093
- const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
1904
+ const v = await this.#runScalar(
1905
+ `MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
1906
+ );
1094
1907
  return v === null || v === undefined ? null : Number(v);
1095
1908
  }
1096
1909
 
@@ -1145,32 +1958,93 @@ export class ModelQuery<T extends BaseEntity> {
1145
1958
  return this;
1146
1959
  }
1147
1960
 
1961
+ /**
1962
+ * `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
1963
+ * no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
1964
+ * alias.
1965
+ *
1966
+ * query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
1967
+ *
1968
+ * The fragment keeps its position among the plain `orderBy` terms.
1969
+ *
1970
+ * **Strict mode**: like {@link whereRaw}, this throws when
1971
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
1972
+ *
1973
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1974
+ */
1975
+ orderByRaw(sql: string): this {
1976
+ this.#assertRawAllowed("orderByRaw");
1977
+ this.#orderBys.push({ raw: sql });
1978
+ return this;
1979
+ }
1980
+
1148
1981
  /**
1149
1982
  * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
1150
1983
  * through the entity's column map (camelCase → snake_case) like `orderBy`.
1151
- * For a raw grouping expression, use a `whereRaw`-style construct via the
1152
- * fluent {@link QueryBuilder}.
1984
+ * For a grouping expression with no typed form, see {@link groupByRaw}.
1153
1985
  */
1154
1986
  groupBy(...columns: string[]): this {
1155
1987
  for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
1156
1988
  return this;
1157
1989
  }
1158
1990
 
1991
+ /**
1992
+ * `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
1993
+ * no typed form, e.g. `DATE_TRUNC('day', created_at)`.
1994
+ *
1995
+ * The fragment keeps its position among the plain `groupBy` terms.
1996
+ *
1997
+ * **Strict mode**: like {@link whereRaw}, this throws when
1998
+ * `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
1999
+ *
2000
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
2001
+ */
2002
+ groupByRaw(sql: string): this {
2003
+ this.#assertRawAllowed("groupByRaw");
2004
+ this.#groupBy.push({ raw: sql });
2005
+ return this;
2006
+ }
2007
+
1159
2008
  /**
1160
2009
  * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
1161
- * The column is passed verbatim to the Rust HAVING compiler, which quotes a
1162
- * plain identifier or accepts an allow-listed aggregate expression
1163
- * (`COUNT(*)`, `SUM(col)`, …) it is NOT run through the entity column map,
1164
- * so aggregate expressions and result aliases both work.
2010
+ * A bare model property is resolved through the entity column map (honouring
2011
+ * `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
2012
+ * expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
2013
+ * `having` can still reference `withCount`/`withAggregate` aliases.
1165
2014
  */
1166
2015
  having(column: string, operator: string, value: unknown): this {
1167
- this.#having.push({ column, operator, value, type: "and" });
2016
+ this.#having.push({
2017
+ column: this.#resolveHavingCol(column),
2018
+ operator,
2019
+ value: this.#prep(column, value),
2020
+ type: "and",
2021
+ });
1168
2022
  return this;
1169
2023
  }
1170
2024
 
2025
+ /**
2026
+ * Resolve a HAVING column: a bare model property maps to its DB column
2027
+ * (honouring `@Column({ columnName })`), but an aggregate expression
2028
+ * (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
2029
+ * so `having` can still reference `withCount`/`withAggregate` aliases.
2030
+ */
2031
+ #resolveHavingCol(column: string): string {
2032
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) return column;
2033
+ try {
2034
+ return this.#resolveColumn(column);
2035
+ } catch {
2036
+ return column;
2037
+ }
2038
+ }
2039
+
1171
2040
  /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
1172
2041
  orHaving(column: string, operator: string, value: unknown): this {
1173
- this.#having.push({ column, operator, value, type: "or" });
2042
+ this.#having.push({
2043
+ column: this.#resolveHavingCol(column),
2044
+ operator,
2045
+ value: this.#prep(column, value),
2046
+ type: "or",
2047
+ });
1174
2048
  return this;
1175
2049
  }
1176
2050
 
@@ -1183,6 +2057,14 @@ export class ModelQuery<T extends BaseEntity> {
1183
2057
  * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
1184
2058
  */
1185
2059
  havingRaw(sql: string, bindings: readonly unknown[] = []): this {
2060
+ // Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
2061
+ // surface, so prod hardening must be able to neutralise it too.
2062
+ if (isAtlasStrictMode() && !isInternalBypass()) {
2063
+ throw new Error(
2064
+ "havingRaw() is disabled in Atlas strict mode. " +
2065
+ "Use having(column, operator, value) instead.",
2066
+ );
2067
+ }
1186
2068
  this.#having.push({
1187
2069
  kind: "raw",
1188
2070
  sql,
@@ -1208,16 +2090,92 @@ export class ModelQuery<T extends BaseEntity> {
1208
2090
  return this;
1209
2091
  }
1210
2092
 
2093
+ /** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
2094
+ intersect(query: ModelQuery<BaseEntity>): this {
2095
+ this.#unions.push({ query, all: false, op: "intersect" });
2096
+ return this;
2097
+ }
2098
+
2099
+ /**
2100
+ * `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
2101
+ *
2102
+ * Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
2103
+ * INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
2104
+ * `E_UNSUPPORTED` there rather than emitting a syntax error.
2105
+ */
2106
+ intersectAll(query: ModelQuery<BaseEntity>): this {
2107
+ this.#unions.push({ query, all: true, op: "intersect" });
2108
+ return this;
2109
+ }
2110
+
2111
+ /** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
2112
+ except(query: ModelQuery<BaseEntity>): this {
2113
+ this.#unions.push({ query, all: false, op: "except" });
2114
+ return this;
2115
+ }
2116
+
2117
+ /** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
2118
+ exceptAll(query: ModelQuery<BaseEntity>): this {
2119
+ this.#unions.push({ query, all: true, op: "except" });
2120
+ return this;
2121
+ }
2122
+
1211
2123
  /**
1212
2124
  * `WITH <name> AS (<query>)` — register a Common Table Expression
1213
2125
  * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
1214
2126
  * sub-query is compiled and its bindings are re-indexed into the outer list.
1215
2127
  */
1216
2128
  with(name: string, query: ModelQuery<BaseEntity>): this {
2129
+ return this.#pushCte("with", name, query, {});
2130
+ }
2131
+
2132
+ /**
2133
+ * `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
2134
+ * (Lucid/Knex `withRecursive`), for trees and graph walks.
2135
+ *
2136
+ * RECURSIVE is a property of the WITH clause rather than of one CTE, so a
2137
+ * single recursive entry makes the whole clause recursive — which is what
2138
+ * all three dialects require. Mixing `with()` and `withRecursive()` is fine.
2139
+ *
2140
+ * The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
2141
+ * an anchor `SELECT` unioned with a select that references `<name>`.
2142
+ */
2143
+ withRecursive(name: string, query: ModelQuery<BaseEntity>): this {
2144
+ return this.#pushCte("withRecursive", name, query, { recursive: true });
2145
+ }
2146
+
2147
+ /**
2148
+ * `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
2149
+ * once and stashed (Lucid/Knex `withMaterialized`).
2150
+ *
2151
+ * Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
2152
+ * compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
2153
+ */
2154
+ withMaterialized(name: string, query: ModelQuery<BaseEntity>): this {
2155
+ return this.#pushCte("withMaterialized", name, query, {
2156
+ materialized: true,
2157
+ });
2158
+ }
2159
+
2160
+ /** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
2161
+ withNotMaterialized(name: string, query: ModelQuery<BaseEntity>): this {
2162
+ return this.#pushCte("withNotMaterialized", name, query, {
2163
+ materialized: false,
2164
+ });
2165
+ }
2166
+
2167
+ #pushCte(
2168
+ method: string,
2169
+ name: string,
2170
+ query: ModelQuery<BaseEntity>,
2171
+ options: { recursive?: boolean; materialized?: boolean },
2172
+ ): this {
1217
2173
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1218
- throw new Error(`with(): CTE name '${name}' is not a valid identifier`);
2174
+ throw new Error(
2175
+ `${method}(): CTE name '${name}' is not a valid identifier`,
2176
+ );
1219
2177
  }
1220
- this.#ctes.push({ name, query });
2178
+ this.#ctes.push({ name, query, ...options });
1221
2179
  return this;
1222
2180
  }
1223
2181
 
@@ -1232,21 +2190,136 @@ export class ModelQuery<T extends BaseEntity> {
1232
2190
  wherePivot(column: string, value: unknown): this;
1233
2191
  wherePivot(column: string, operator: string, value: unknown): this;
1234
2192
  wherePivot(column: string, operatorOrValue: unknown, value?: unknown): this {
1235
- if (value === undefined) {
1236
- this.#pivotWheres.push({ column, operator: "=", value: operatorOrValue });
1237
- } else {
1238
- this.#pivotWheres.push({
1239
- column,
1240
- operator: operatorOrValue as string,
1241
- value,
1242
- });
1243
- }
1244
- return this;
2193
+ return this.#pushPivot("and", column, operatorOrValue, value);
2194
+ }
2195
+
2196
+ /** Alias of {@link wherePivot} (Lucid parity) — pivot filters already AND together. */
2197
+ andWherePivot(column: string, value: unknown): this;
2198
+ andWherePivot(column: string, operator: string, value: unknown): this;
2199
+ andWherePivot(
2200
+ column: string,
2201
+ operatorOrValue: unknown,
2202
+ value?: unknown,
2203
+ ): this {
2204
+ return this.#pushPivot("and", column, operatorOrValue, value);
2205
+ }
2206
+
2207
+ /**
2208
+ * `@ManyToMany` only — OR form of {@link wherePivot} (Lucid parity).
2209
+ *
2210
+ * The pivot filters are compiled as a parenthesised group, so an OR joins
2211
+ * the other pivot filters and cannot escape the `pivot_fk IN (parents)`
2212
+ * scoping that makes the preload correct.
2213
+ */
2214
+ orWherePivot(column: string, value: unknown): this;
2215
+ orWherePivot(column: string, operator: string, value: unknown): this;
2216
+ orWherePivot(
2217
+ column: string,
2218
+ operatorOrValue: unknown,
2219
+ value?: unknown,
2220
+ ): this {
2221
+ return this.#pushPivot("or", column, operatorOrValue, value);
2222
+ }
2223
+
2224
+ /** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
2225
+ whereInPivot(column: string, values: readonly unknown[]): this {
2226
+ return this.#pushPivotOp("and", column, "IN", [...values]);
2227
+ }
2228
+
2229
+ /** Alias of {@link whereInPivot} (Lucid parity). */
2230
+ andWhereInPivot(column: string, values: readonly unknown[]): this {
2231
+ return this.#pushPivotOp("and", column, "IN", [...values]);
2232
+ }
2233
+
2234
+ /** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
2235
+ orWhereInPivot(column: string, values: readonly unknown[]): this {
2236
+ return this.#pushPivotOp("or", column, "IN", [...values]);
1245
2237
  }
1246
2238
 
1247
- /** `@ManyToMany` only `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `wherePivotIn`). */
2239
+ /** Alias of {@link whereInPivot} kept for the earlier atlas name. */
1248
2240
  wherePivotIn(column: string, values: readonly unknown[]): this {
1249
- this.#pivotWheres.push({ column, operator: "IN", value: [...values] });
2241
+ return this.whereInPivot(column, values);
2242
+ }
2243
+
2244
+ /** `@ManyToMany` only — `WHERE <pivotCol> != <value>` on the pivot table (AdonisJS Lucid `whereNotPivot`). */
2245
+ whereNotPivot(column: string, value: unknown): this {
2246
+ return this.#pushPivotOp("and", column, "!=", value);
2247
+ }
2248
+
2249
+ /** Alias of {@link whereNotPivot} (Lucid parity). */
2250
+ andWhereNotPivot(column: string, value: unknown): this {
2251
+ return this.#pushPivotOp("and", column, "!=", value);
2252
+ }
2253
+
2254
+ /** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
2255
+ orWhereNotPivot(column: string, value: unknown): this {
2256
+ return this.#pushPivotOp("or", column, "!=", value);
2257
+ }
2258
+
2259
+ /** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
2260
+ whereNotInPivot(column: string, values: readonly unknown[]): this {
2261
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
2262
+ }
2263
+
2264
+ /** Alias of {@link whereNotInPivot} (Lucid parity). */
2265
+ andWhereNotInPivot(column: string, values: readonly unknown[]): this {
2266
+ return this.#pushPivotOp("and", column, "NOT IN", [...values]);
2267
+ }
2268
+
2269
+ /** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
2270
+ orWhereNotInPivot(column: string, values: readonly unknown[]): this {
2271
+ return this.#pushPivotOp("or", column, "NOT IN", [...values]);
2272
+ }
2273
+
2274
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
2275
+ whereNullPivot(column: string): this {
2276
+ return this.#pushPivotOp("and", column, "IS NULL", null);
2277
+ }
2278
+
2279
+ /** Alias of {@link whereNullPivot} (Lucid parity). */
2280
+ andWhereNullPivot(column: string): this {
2281
+ return this.#pushPivotOp("and", column, "IS NULL", null);
2282
+ }
2283
+
2284
+ /** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
2285
+ orWhereNullPivot(column: string): this {
2286
+ return this.#pushPivotOp("or", column, "IS NULL", null);
2287
+ }
2288
+
2289
+ /** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
2290
+ whereNotNullPivot(column: string): this {
2291
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
2292
+ }
2293
+
2294
+ /** Alias of {@link whereNotNullPivot} (Lucid parity). */
2295
+ andWhereNotNullPivot(column: string): this {
2296
+ return this.#pushPivotOp("and", column, "IS NOT NULL", null);
2297
+ }
2298
+
2299
+ /** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
2300
+ orWhereNotNullPivot(column: string): this {
2301
+ return this.#pushPivotOp("or", column, "IS NOT NULL", null);
2302
+ }
2303
+
2304
+ /** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
2305
+ #pushPivot(
2306
+ type: "and" | "or",
2307
+ column: string,
2308
+ operatorOrValue: unknown,
2309
+ value?: unknown,
2310
+ ): this {
2311
+ return value === undefined
2312
+ ? this.#pushPivotOp(type, column, "=", operatorOrValue)
2313
+ : this.#pushPivotOp(type, column, operatorOrValue as string, value);
2314
+ }
2315
+
2316
+ #pushPivotOp(
2317
+ type: "and" | "or",
2318
+ column: string,
2319
+ operator: string,
2320
+ value: unknown,
2321
+ ): this {
2322
+ this.#pivotWheres.push({ column, operator, value, type });
1250
2323
  return this;
1251
2324
  }
1252
2325
 
@@ -1255,6 +2328,7 @@ export class ModelQuery<T extends BaseEntity> {
1255
2328
  column: string;
1256
2329
  operator: string;
1257
2330
  value: unknown;
2331
+ type: "and" | "or";
1258
2332
  }> {
1259
2333
  return this.#pivotWheres;
1260
2334
  }
@@ -1341,21 +2415,99 @@ export class ModelQuery<T extends BaseEntity> {
1341
2415
  }
1342
2416
 
1343
2417
  /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
2418
+ /**
2419
+ * DB column backing the soft-delete `deletedAt` property — honours a
2420
+ * `@Column({ columnName })` override, read straight from the entity metadata
2421
+ * (not the resolver callback, which is identity for subqueries/preloads).
2422
+ */
2423
+ #deletedAtColumn(): string {
2424
+ const col = this.#entityClass
2425
+ ? getColumnMetadata(this.#entityClass).find(
2426
+ (c) => c.propertyKey === "deletedAt",
2427
+ )
2428
+ : undefined;
2429
+ return col?.columnName ?? "deleted_at";
2430
+ }
2431
+
1344
2432
  #buildSpec(): SelectSpec {
2433
+ // `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
2434
+ // compiler emits the lock clause only when a base mode is set, so a lone
2435
+ // modifier would be a SILENT no-op (dangerous for job-queue polling that
2436
+ // believes it skips locked rows). Fail loud instead. Order-independent: this
2437
+ // fires whether the modifier was chained before or after the base lock.
2438
+ if (this.#lockModifier && !this.#lockMode) {
2439
+ throw new Error(
2440
+ `${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`,
2441
+ );
2442
+ }
2443
+ // With a JOIN and the default `SELECT *`, scope the projection to the base
2444
+ // table's declared columns so joined columns can't clobber the model's fields
2445
+ // (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
2446
+ // AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
2447
+ let selectCols = this.#select;
2448
+ if (
2449
+ this.#joins.length > 0 &&
2450
+ this.#select.length === 1 &&
2451
+ this.#select[0] === "*"
2452
+ ) {
2453
+ const cols = getColumnMetadata(this.#entityClass).map(
2454
+ (c) =>
2455
+ `${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`,
2456
+ );
2457
+ if (cols.length > 0) selectCols = cols;
2458
+ } else if (
2459
+ !(selectCols.length === 1 && selectCols[0] === "*") &&
2460
+ selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))
2461
+ ) {
2462
+ // A partial `select()` of PLAIN columns that omits the primary key would
2463
+ // hydrate a persisted entity with no PK — a later save() would then INSERT
2464
+ // instead of UPDATE (double-write / unique violation / spurious
2465
+ // beforeCreate). Auto-include the (base-table-qualified) PK so model
2466
+ // entities stay saveable. Aggregate/alias/expression selects are left
2467
+ // untouched — use `.pojo()` for those.
2468
+ const pkProp = getPrimaryKey(this.#entityClass);
2469
+ if (pkProp) {
2470
+ const pkCol =
2471
+ getColumnMetadata(this.#entityClass).find(
2472
+ (c) => c.propertyKey === pkProp,
2473
+ )?.columnName ?? camelToSnake(pkProp);
2474
+ // The PK counts as present ONLY as the bare column or the BASE-table-
2475
+ // qualified column. A joined `other.id` must NOT satisfy it (its leaf
2476
+ // collides with the PK name but it's a different table's row) — otherwise
2477
+ // we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
2478
+ // save(). Appended last, `base.id` also wins the duplicate result key
2479
+ // (rows collect in column order, last-wins) so the base row's PK hydrates.
2480
+ const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
2481
+ if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
2482
+ selectCols = [...selectCols, baseQualifiedPk];
2483
+ }
2484
+ }
2485
+ }
1345
2486
  const wheres: WhereClause[] = [...this.#wheres];
1346
- // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes
2487
+ // Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
2488
+ // `.wherePivot()` recorded since the proxy handed back this query (pushed to
2489
+ // the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
2490
+ if (this.#pivotExists) {
2491
+ const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
2492
+ wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
2493
+ }
2494
+ // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
2495
+ // Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
2496
+ // override on the soft-delete column is honoured on the read side too — matching
2497
+ // the write side (delete/restore go through #dbColumn).
1347
2498
  if (this.#softDeletes) {
2499
+ const deletedAtCol = this.#deletedAtColumn();
1348
2500
  if (this.#softScope === "default") {
1349
2501
  wheres.push({
1350
2502
  type: "and",
1351
- column: "deleted_at",
2503
+ column: deletedAtCol,
1352
2504
  operator: "IS NULL",
1353
2505
  value: null,
1354
2506
  });
1355
2507
  } else if (this.#softScope === "only-trashed") {
1356
2508
  wheres.push({
1357
2509
  type: "and",
1358
- column: "deleted_at",
2510
+ column: deletedAtCol,
1359
2511
  operator: "IS NOT NULL",
1360
2512
  value: null,
1361
2513
  });
@@ -1366,7 +2518,7 @@ export class ModelQuery<T extends BaseEntity> {
1366
2518
  return {
1367
2519
  kind: "select",
1368
2520
  table: this.#tableName,
1369
- select: this.#select,
2521
+ select: selectCols,
1370
2522
  selectSubqueries: this.#selectSubqueries,
1371
2523
  wheres,
1372
2524
  orderBy: this.#orderBys,
@@ -1375,16 +2527,27 @@ export class ModelQuery<T extends BaseEntity> {
1375
2527
  limit: this.#limit ?? null,
1376
2528
  offset: this.#offset ?? null,
1377
2529
  distinct: this.#distinct,
2530
+ distinctOn: this.#distinctOn,
1378
2531
  ctes: this.#ctes.map((c) => {
1379
2532
  const { sql, params } = c.query.toSQL();
1380
- return { name: c.name, sql, params };
2533
+ return {
2534
+ name: c.name,
2535
+ sql,
2536
+ params,
2537
+ recursive: c.recursive ?? false,
2538
+ materialized: c.materialized ?? null,
2539
+ };
1381
2540
  }),
1382
2541
  unions: this.#unions.map((u) => {
1383
2542
  const { sql, params } = u.query.toSQL();
1384
- return { sql, params, all: u.all };
2543
+ return { sql, params, all: u.all, op: u.op ?? null };
1385
2544
  }),
1386
2545
  joins: this.#joins,
1387
- lockMode: this.#lockMode,
2546
+ lockMode: this.#lockMode
2547
+ ? this.#lockModifier
2548
+ ? `${this.#lockMode} ${this.#lockModifier}`
2549
+ : this.#lockMode
2550
+ : null,
1388
2551
  };
1389
2552
  }
1390
2553
 
@@ -1420,7 +2583,11 @@ export class ModelQuery<T extends BaseEntity> {
1420
2583
 
1421
2584
  async #doExec(): Promise<T[]> {
1422
2585
  const { sql, params } = this.toSQL();
1423
- const rawRows = await this.#db.query<Record<string, unknown>>(sql, params);
2586
+ const rawRows = await this.#db.query<Record<string, unknown>>(
2587
+ sql,
2588
+ params,
2589
+ this.#meta("exec"),
2590
+ );
1424
2591
  // Peel withCount / withAggregate alias columns off the raw row into $extras
1425
2592
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
1426
2593
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -1434,6 +2601,8 @@ export class ModelQuery<T extends BaseEntity> {
1434
2601
  }
1435
2602
  const entity = this.#hydrateFn(row);
1436
2603
  for (const [k, v] of Object.entries(picked)) entity.setExtra(k, v);
2604
+ // Thread query-level sideloaded context onto each hydrated instance.
2605
+ if (this.#sideloaded) entity.$sideloaded = { ...this.#sideloaded };
1437
2606
  return entity;
1438
2607
  });
1439
2608
 
@@ -1445,6 +2614,27 @@ export class ModelQuery<T extends BaseEntity> {
1445
2614
  return entities;
1446
2615
  }
1447
2616
 
2617
+ /**
2618
+ * Execute and return PLAIN row objects (raw snake_case DB columns), skipping
2619
+ * model hydration, `@column({ consume })`, dirty-tracking and preloads —
2620
+ * AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
2621
+ * instances aren't needed.
2622
+ */
2623
+ async pojo<R = Record<string, unknown>>(): Promise<R[]> {
2624
+ const { sql, params } = this.toSQL();
2625
+ return this.#db.query<R>(sql, params);
2626
+ }
2627
+
2628
+ /**
2629
+ * Thread arbitrary context onto every instance this query hydrates, exposed as
2630
+ * `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
2631
+ * user, so hooks/computed can read it. Merges across calls. Chainable.
2632
+ */
2633
+ sideload(values: Record<string, unknown>): this {
2634
+ this.#sideloaded = { ...this.#sideloaded, ...values };
2635
+ return this;
2636
+ }
2637
+
1448
2638
  /** Resolve preloaded relations via batched subqueries (no N+1). */
1449
2639
  async #resolvePreloads(entities: T[]): Promise<void> {
1450
2640
  if (!this.#entityClass) return;
@@ -1473,8 +2663,11 @@ export class ModelQuery<T extends BaseEntity> {
1473
2663
  relationName: string,
1474
2664
  ): PreloadContext | null {
1475
2665
  const relatedClass = relation.target() as new () => BaseEntity;
1476
- const relatedMeta = getEntityMetadata(relatedClass);
1477
- if (!relatedMeta) return null;
2666
+ // Boot the related model's metadata on demand (Lucid parity): a preload
2667
+ // must not silently no-op just because the related class hasn't been
2668
+ // touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
2669
+ // static table / naming strategy when the decorator hasn't run.
2670
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1478
2671
 
1479
2672
  // Resolve row keys against declared column metadata, NOT `in entity` —
1480
2673
  // entities using Adonis' `declare field: T` pattern have no own-properties
@@ -1482,24 +2675,79 @@ export class ModelQuery<T extends BaseEntity> {
1482
2675
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
1483
2676
  const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
1484
2677
  const validColumns = new Set<string>();
2678
+ // Reverse map (db column → property) so an explicit `@Column({ columnName })`
2679
+ // on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
2680
+ const byDbName = new Map<string, string>();
2681
+ // Capture the related model's `@Column({ consume })` adapters + its date
2682
+ // columns so preloaded rows hydrate identically to a direct query — dates
2683
+ // become Chronos DateTime, decimal/etc adapters run. Without this, a
2684
+ // preloaded relation left column values raw (Lucid parity bug + a runtime
2685
+ // footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
2686
+ const consumes = new Map<
2687
+ string,
2688
+ (v: unknown, attribute?: string, model?: unknown) => unknown
2689
+ >();
2690
+ let relatedPkDb = camelToSnake(relatedPkName);
1485
2691
  for (const col of getColumnMetadata(relatedClass)) {
2692
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
1486
2693
  validColumns.add(col.propertyKey);
1487
- validColumns.add(camelToSnake(col.propertyKey));
2694
+ validColumns.add(db);
2695
+ byDbName.set(db, col.propertyKey);
2696
+ if (col.consume) consumes.set(col.propertyKey, col.consume);
2697
+ // The related PK may be multi-word (postId→post_id) or columnName-mapped;
2698
+ // its DB column name is what the WHERE + row indexing must use.
2699
+ if (col.propertyKey === relatedPkName) relatedPkDb = db;
1488
2700
  }
1489
2701
  validColumns.add(relatedPkName);
1490
2702
  validColumns.add(camelToSnake(relatedPkName));
2703
+ const dateCols = getDateColumnConfig(relatedClass);
2704
+ const consumeValue = (
2705
+ prop: string,
2706
+ value: unknown,
2707
+ model?: unknown,
2708
+ ): unknown => {
2709
+ const c = consumes.get(prop);
2710
+ // Adonis Lucid signature: (value, attribute, model).
2711
+ if (c) return c(value, prop, model);
2712
+ if (dateCols[prop] && value != null)
2713
+ return dateTimeAtlasAdapter.consume(value);
2714
+ return value;
2715
+ };
1491
2716
 
2717
+ // A repository for the related model so preloaded instances are hydrated with
2718
+ // the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
2719
+ // not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
2720
+ // refresh()/fresh()/load()/related(). Without this a preloaded relation
2721
+ // looked $isNew/$isLocal/$dirty and a later save() over-updated it.
2722
+ const relatedRepo = new BaseRepository(relatedClass, this.#db, {
2723
+ dialect: this.#dialect,
2724
+ });
2725
+ // Propagate the domain-event bus so save()/create() from a preloaded relation
2726
+ // still dispatch events (a fresh repo has none by default).
2727
+ relatedRepo.onDomainEvents = this.#onDomainEvents;
1492
2728
  const hydrate = (row: Record<string, unknown>): BaseEntity => {
1493
2729
  const entity = new relatedClass();
1494
2730
  for (const [key, value] of Object.entries(row)) {
1495
2731
  const camelKey = snakeToCamel(key);
1496
- const targetKey = validColumns.has(camelKey)
1497
- ? camelKey
1498
- : validColumns.has(key)
1499
- ? key
1500
- : null;
1501
- if (targetKey !== null) entity.setProp(targetKey, value);
2732
+ const targetKey =
2733
+ byDbName.get(key) ??
2734
+ (validColumns.has(camelKey)
2735
+ ? camelKey
2736
+ : validColumns.has(key)
2737
+ ? key
2738
+ : null);
2739
+ if (targetKey !== null)
2740
+ entity.setProp(targetKey, consumeValue(targetKey, value, entity));
1502
2741
  }
2742
+ // Freeze the clean snapshot + mark persisted/from-DB, and back-reference
2743
+ // the related repo (mirrors BaseRepository.#hydrate).
2744
+ entity.markAsPersisted();
2745
+ entity.markAsFromDatabase();
2746
+ Object.defineProperty(entity, REPO_REF, {
2747
+ value: relatedRepo,
2748
+ enumerable: false,
2749
+ configurable: true,
2750
+ });
1503
2751
  return entity;
1504
2752
  };
1505
2753
 
@@ -1508,7 +2756,9 @@ export class ModelQuery<T extends BaseEntity> {
1508
2756
  relationName,
1509
2757
  relatedClass,
1510
2758
  relatedTable: relatedMeta.tableName,
1511
- relatedPk: getPrimaryKey(relatedClass) ?? "id",
2759
+ // DB column name (not property) used as the WHERE column in the related
2760
+ // query AND to index the returned DB rows by their PK value.
2761
+ relatedPk: relatedPkDb,
1512
2762
  hydrate,
1513
2763
  runInQuery: (table, column, values) =>
1514
2764
  this.#runInQuery(table, column, values),
@@ -1570,11 +2820,7 @@ export class ModelQuery<T extends BaseEntity> {
1570
2820
  );
1571
2821
  }
1572
2822
  const throughClass = relation.through() as new () => BaseEntity;
1573
- const throughMeta = getEntityMetadata(throughClass);
1574
- if (!throughMeta)
1575
- throw new Error(
1576
- `Entity metadata missing on through class ${throughClass.name}`,
1577
- );
2823
+ const throughMeta = ensureEntityMetadata(throughClass);
1578
2824
  const throughTable = throughMeta.tableName;
1579
2825
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1580
2826
  const parentLocal =
@@ -1583,7 +2829,13 @@ export class ModelQuery<T extends BaseEntity> {
1583
2829
  relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1584
2830
  const secondKey =
1585
2831
  relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1586
- const secondLocal = relation.secondLocalKey ?? throughPk;
2832
+ // secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
2833
+ // DB column — resolve the through model's key (default: its PK), honouring a
2834
+ // multi-word / columnName PK. (parentLocal stays a property: it's read off
2835
+ // the parent ENTITY, not a row.)
2836
+ const secondLocal = buildColumnResolver(throughClass)(
2837
+ relation.secondLocalKey ?? throughPk,
2838
+ );
1587
2839
 
1588
2840
  const parentIds = entities
1589
2841
  .map((e) => e[parentLocal])
@@ -1752,7 +3004,11 @@ export class ModelQuery<T extends BaseEntity> {
1752
3004
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1753
3005
  const otherKey =
1754
3006
  pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
1755
- const pk = getPrimaryKey(this.#entityClass) ?? "id";
3007
+ // The pivot FK stores `parent[localKey]` (default PK) attach() writes it,
3008
+ // so preload MUST read back with the SAME key, else a custom-localKey m2m
3009
+ // writes `user_code = code` but reads `user_code IN (id)` and never matches.
3010
+ const pk =
3011
+ ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1756
3012
 
1757
3013
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1758
3014
  if (ids.length === 0) return [];
@@ -1772,12 +3028,39 @@ export class ModelQuery<T extends BaseEntity> {
1772
3028
  this.#db,
1773
3029
  (r) => r as BaseEntity,
1774
3030
  ctx.relatedClass,
1775
- (c) => c,
3031
+ buildColumnResolver(ctx.relatedClass),
1776
3032
  false,
1777
3033
  this.#dialect,
3034
+ buildValuePreparer(ctx.relatedClass),
1778
3035
  );
1779
3036
  ctx.nestedCallback(scratch);
1780
- for (const c of scratch.pivotConstraints) pivotWheres.push({ ...c });
3037
+ // Apply the pivot column adapters' `prepare` to wherePivot values, so a
3038
+ // filter like wherePivot('amount', new Money(1)) matches what attach()/
3039
+ // sync() stored (they prepare the same extras on write).
3040
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
3041
+ for (const c of scratch.pivotConstraints) {
3042
+ const prep = pivotAdapters[c.column]?.prepare;
3043
+ // Same guards as the attach()/sync() write path: wrap a throwing
3044
+ // adapter with a column-annotated error and reject async adapters,
3045
+ // so filter and write agree on the adapter contract.
3046
+ const apply = (v: unknown): unknown => {
3047
+ if (!prep) return v;
3048
+ let out: unknown;
3049
+ try {
3050
+ // Adonis Lucid signature: (value, attribute, model). wherePivot is
3051
+ // a query filter — attribute known, no model instance.
3052
+ out = prep(v, c.column, undefined);
3053
+ } catch (err) {
3054
+ throw wrapAdapterError("prepare", c.column, err);
3055
+ }
3056
+ assertNotPromise("prepare", c.column, out);
3057
+ return out;
3058
+ };
3059
+ const value = Array.isArray(c.value)
3060
+ ? c.value.map(apply)
3061
+ : apply(c.value);
3062
+ pivotWheres.push({ ...c, value });
3063
+ }
1781
3064
  }
1782
3065
 
1783
3066
  // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
@@ -1830,7 +3113,8 @@ export class ModelQuery<T extends BaseEntity> {
1830
3113
  const adapter = pivotAdapters[col];
1831
3114
  related.setExtra(
1832
3115
  `pivot_${col}`,
1833
- adapter?.consume ? adapter.consume(rawVal) : rawVal,
3116
+ // Adonis Lucid signature: (value, attribute, model).
3117
+ adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal,
1834
3118
  );
1835
3119
  }
1836
3120
  allRelated.push(related);
@@ -1860,6 +3144,10 @@ export class ModelQuery<T extends BaseEntity> {
1860
3144
  this.#db,
1861
3145
  (r) => ctx.hydrate(r),
1862
3146
  ctx.relatedClass,
3147
+ buildColumnResolver(ctx.relatedClass),
3148
+ hasSoftDeletes(ctx.relatedClass),
3149
+ this.#dialect,
3150
+ buildValuePreparer(ctx.relatedClass),
1863
3151
  );
1864
3152
  ctx.nestedCallback(sub);
1865
3153
  if (sub.#preloads.size > 0) {
@@ -1876,17 +3164,28 @@ export class ModelQuery<T extends BaseEntity> {
1876
3164
  column: string;
1877
3165
  operator: string;
1878
3166
  value: unknown;
3167
+ type?: "and" | "or";
1879
3168
  }> = [],
1880
3169
  ): Promise<Record<string, unknown>[]> {
1881
3170
  const wheres: Array<Record<string, unknown>> = [
1882
3171
  { column, operator: "IN", value: values, type: "and" },
1883
3172
  ];
1884
- for (const w of extraWheres) {
3173
+ // The caller's filters go in a parenthesised group, never flat beside the
3174
+ // `IN`. Flat, an `orWherePivot` would read as
3175
+ // `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
3176
+ // other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
3177
+ // With every filter ANDed the two forms are equivalent, so this changes
3178
+ // no existing query.
3179
+ if (extraWheres.length > 0) {
1885
3180
  wheres.push({
1886
- column: w.column,
1887
- operator: w.operator,
1888
- value: w.value,
3181
+ kind: "group",
1889
3182
  type: "and",
3183
+ conditions: extraWheres.map((w) => ({
3184
+ column: w.column,
3185
+ operator: w.operator,
3186
+ value: w.value,
3187
+ type: w.type ?? "and",
3188
+ })),
1890
3189
  });
1891
3190
  }
1892
3191
  const spec = {
@@ -1937,7 +3236,10 @@ export class ModelQuery<T extends BaseEntity> {
1937
3236
  this.#db,
1938
3237
  (row) => row as BaseEntity,
1939
3238
  relatedClass,
1940
- (c) => c,
3239
+ // Resolve columns + prepare values against the RELATED model so a preload
3240
+ // constraint (onQuery / callback) targeting a columnName-mapped or date
3241
+ // column compiles/binds like a direct query on that model.
3242
+ buildColumnResolver(relatedClass),
1941
3243
  // Propagate the RELATED entity's soft-delete flag — hardcoding
1942
3244
  // false here meant `preload('posts')` returned soft-deleted
1943
3245
  // posts even when Post is @SoftDeletes (a data leak). The
@@ -1947,6 +3249,7 @@ export class ModelQuery<T extends BaseEntity> {
1947
3249
  // preload callback.)
1948
3250
  hasSoftDeletes(relatedClass),
1949
3251
  this.#dialect,
3252
+ buildValuePreparer(relatedClass),
1950
3253
  );
1951
3254
  sub.whereIn(column, values);
1952
3255
  if (relation.onQuery) relation.onQuery(sub as unknown);
@@ -1995,30 +3298,46 @@ export class ModelQuery<T extends BaseEntity> {
1995
3298
  );
1996
3299
  }
1997
3300
  const relatedClass = relation.target() as new () => BaseEntity;
1998
- const relatedMeta = getEntityMetadata(relatedClass);
1999
- if (!relatedMeta) {
2000
- throw new Error(
2001
- `Entity metadata missing on related class ${relatedClass.name}`,
2002
- );
2003
- }
3301
+ const relatedMeta = ensureEntityMetadata(relatedClass);
2004
3302
  const relatedTable = relatedMeta.tableName;
2005
3303
  const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
2006
3304
  const parentTable = this.#tableName;
2007
- const q =
2008
- this.#dialect === "mysql"
2009
- ? (name: string) => `\`${name}\``
2010
- : (name: string) => `"${name}"`;
3305
+ // Strict single-segment identifier quote. This builds a RAW correlated
3306
+ // subquery fragment (no bind params for identifiers), so every segment must
3307
+ // be validated a table/key from relation metadata carrying a quote/backtick
3308
+ // would otherwise emit invalid or injectable SQL. Same policy as
3309
+ // BaseRepository's lazy m2m path.
3310
+ const q = (name: string): string => {
3311
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
3312
+ throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
3313
+ }
3314
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
3315
+ };
3316
+ // Table identifiers may be schema-qualified (`schema.table`) — quote each
3317
+ // dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
3318
+ // `public.users_roles` gets wrapped as ONE identifier and silently targets a
3319
+ // table literally named with a dot. Each segment still passes the strict
3320
+ // guard above. Columns stay single-segment via `q`.
3321
+ const qTable = (name: string): string => name.split(".").map(q).join(".");
2011
3322
 
2012
3323
  const sub = new ModelQuery<BaseEntity>(
2013
3324
  relatedTable,
2014
3325
  this.#db,
2015
3326
  (row) => row as BaseEntity,
2016
3327
  relatedClass,
2017
- (c) => c,
3328
+ // whereHas/withCount constraints run against the RELATED model — resolve
3329
+ // its columns (columnName/multi-word) and prepare its values like a direct query.
3330
+ buildColumnResolver(relatedClass),
2018
3331
  false,
2019
3332
  this.#dialect,
3333
+ buildValuePreparer(relatedClass),
2020
3334
  );
2021
3335
 
3336
+ // `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
3337
+ // PK); resolve each to its DB column via the owning model so a multi-word or
3338
+ // `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
3339
+ // `firstKey`/`secondKey` are DB column names already — left as-is.
3340
+ const resolveParent = buildColumnResolver(this.#entityClass);
2022
3341
  switch (relation.type) {
2023
3342
  case "hasOne":
2024
3343
  case "hasMany": {
@@ -2026,19 +3345,20 @@ export class ModelQuery<T extends BaseEntity> {
2026
3345
  // hard-coding them here produced silently-wrong whereHas/withCount SQL.
2027
3346
  const fk =
2028
3347
  relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2029
- const localKey = relation.localKey ?? parentPk;
3348
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2030
3349
  sub.#pushWhereRaw(
2031
- `${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(localKey)}`,
3350
+ `${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
2032
3351
  );
2033
3352
  break;
2034
3353
  }
2035
3354
  case "belongsTo": {
2036
3355
  const fk =
2037
3356
  relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
2038
- const ownerKey =
2039
- relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
3357
+ const ownerKey = buildColumnResolver(relatedClass)(
3358
+ relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
3359
+ );
2040
3360
  sub.#pushWhereRaw(
2041
- `${q(relatedTable)}.${q(ownerKey)} = ${q(parentTable)}.${q(fk)}`,
3361
+ `${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`,
2042
3362
  );
2043
3363
  break;
2044
3364
  }
@@ -2055,12 +3375,16 @@ export class ModelQuery<T extends BaseEntity> {
2055
3375
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2056
3376
  const otherKey =
2057
3377
  pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
2058
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
2059
- const localKey = relation.localKey ?? parentPk;
3378
+ const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
3379
+ const relatedPk =
3380
+ getColumnMetadata(relatedClass).find(
3381
+ (c) => c.propertyKey === relatedPkProp,
3382
+ )?.columnName ?? camelToSnake(relatedPkProp);
3383
+ const localKey = resolveParent(relation.localKey ?? parentPk);
2060
3384
  sub.#pushWhereRaw(
2061
- `${q(relatedTable)}.${q(relatedPk)} IN ` +
2062
- `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
2063
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(localKey)})`,
3385
+ `${qTable(relatedTable)}.${q(relatedPk)} IN ` +
3386
+ `(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
3387
+ `WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`,
2064
3388
  );
2065
3389
  break;
2066
3390
  }
@@ -2075,24 +3399,21 @@ export class ModelQuery<T extends BaseEntity> {
2075
3399
  );
2076
3400
  }
2077
3401
  const throughClass = relation.through() as new () => BaseEntity;
2078
- const throughMeta = getEntityMetadata(throughClass);
2079
- if (!throughMeta) {
2080
- throw new Error(
2081
- `Entity metadata missing on through class ${throughClass.name}`,
2082
- );
2083
- }
3402
+ const throughMeta = ensureEntityMetadata(throughClass);
2084
3403
  const throughTable = throughMeta.tableName;
2085
3404
  const throughPk = getPrimaryKey(throughClass) ?? "id";
2086
- const parentLocal = relation.localKey ?? parentPk;
3405
+ const parentLocal = resolveParent(relation.localKey ?? parentPk);
2087
3406
  const firstKey =
2088
3407
  relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2089
3408
  const secondKey =
2090
3409
  relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
2091
- const secondLocal = relation.secondLocalKey ?? throughPk;
3410
+ const secondLocal = buildColumnResolver(throughClass)(
3411
+ relation.secondLocalKey ?? throughPk,
3412
+ );
2092
3413
  sub.#pushWhereRaw(
2093
- `${q(relatedTable)}.${q(secondKey)} IN ` +
2094
- `(SELECT ${q(secondLocal)} FROM ${q(throughTable)} ` +
2095
- `WHERE ${q(throughTable)}.${q(firstKey)} = ${q(parentTable)}.${q(parentLocal)})`,
3414
+ `${qTable(relatedTable)}.${q(secondKey)} IN ` +
3415
+ `(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
3416
+ `WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`,
2096
3417
  );
2097
3418
  break;
2098
3419
  }
@@ -2146,8 +3467,8 @@ export class ModelQuery<T extends BaseEntity> {
2146
3467
  }
2147
3468
 
2148
3469
  crossJoin(table: string): this {
2149
- const tq = this.#quote(table);
2150
- this.#joins.push(`CROSS JOIN ${tq}`);
3470
+ const tq = this.#quoteCol(table);
3471
+ this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
2151
3472
  return this;
2152
3473
  }
2153
3474
 
@@ -2167,14 +3488,14 @@ export class ModelQuery<T extends BaseEntity> {
2167
3488
  *
2168
3489
  * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
2169
3490
  */
2170
- joinRaw(fragment: string): this {
3491
+ joinRaw(fragment: string, bindings: readonly unknown[] = []): this {
2171
3492
  if (isAtlasStrictMode() && !isInternalBypass()) {
2172
3493
  throw new Error(
2173
3494
  "joinRaw() is disabled in Atlas strict mode. " +
2174
3495
  "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
2175
3496
  );
2176
3497
  }
2177
- this.#joins.push(fragment);
3498
+ this.#joins.push({ sql: fragment, params: [...bindings] });
2178
3499
  return this;
2179
3500
  }
2180
3501
 
@@ -2200,11 +3521,47 @@ export class ModelQuery<T extends BaseEntity> {
2200
3521
  return this;
2201
3522
  }
2202
3523
 
3524
+ /**
3525
+ * `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
3526
+ * `columns` (Lucid/Knex `distinctOn`). Takes precedence over
3527
+ * {@link distinct}.
3528
+ *
3529
+ * Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
3530
+ * would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
3531
+ * return a *different* result set rather than fail — a silent wrong answer
3532
+ * is worse than an error.
3533
+ *
3534
+ * Postgres also requires the leading `ORDER BY` terms to match `columns`;
3535
+ * that is left to the database to enforce.
3536
+ */
3537
+ distinctOn(...columns: string[]): this {
3538
+ for (const c of columns) this.#distinctOn.push(this.#resolveColumn(c));
3539
+ return this;
3540
+ }
3541
+
2203
3542
  /** `SELECT COUNT(DISTINCT col)`. */
2204
3543
  async countDistinct(column: string): Promise<number> {
2205
3544
  return Number(
2206
- (await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0,
3545
+ (await this.#runScalar(
3546
+ `COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
3547
+ )) ?? 0,
3548
+ );
3549
+ }
3550
+
3551
+ /** `SUM(DISTINCT col)` (Lucid parity). */
3552
+ async sumDistinct(column: string): Promise<number | null> {
3553
+ const v = await this.#runScalar(
3554
+ `SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
3555
+ );
3556
+ return v === null || v === undefined ? null : Number(v);
3557
+ }
3558
+
3559
+ /** `AVG(DISTINCT col)` (Lucid parity). */
3560
+ async avgDistinct(column: string): Promise<number | null> {
3561
+ const v = await this.#runScalar(
3562
+ `AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
2207
3563
  );
3564
+ return v === null || v === undefined ? null : Number(v);
2208
3565
  }
2209
3566
 
2210
3567
  /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
@@ -2213,7 +3570,11 @@ export class ModelQuery<T extends BaseEntity> {
2213
3570
  clone.#select = ["1"];
2214
3571
  clone.#limit = 1;
2215
3572
  const { sql, params } = clone.toSQL();
2216
- const rows = await this.#db.query<Record<string, unknown>>(sql, params);
3573
+ const rows = await this.#db.query<Record<string, unknown>>(
3574
+ sql,
3575
+ params,
3576
+ this.#meta("exists"),
3577
+ );
2217
3578
  return rows.length > 0;
2218
3579
  }
2219
3580
 
@@ -2311,14 +3672,32 @@ export class ModelQuery<T extends BaseEntity> {
2311
3672
  // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
2312
3673
  // tenant scope) propagates into both the COUNT and the data fetch.
2313
3674
  await fireHooks(this.#entityClass, "beforePaginate", this);
2314
- // Parallel COUNT(*) + data fetch
3675
+ // COUNT(*) + data fetch
2315
3676
  const countQ = this.clone();
2316
- countQ.#select = ["COUNT(*) AS count"];
2317
3677
  countQ.#limit = undefined;
2318
3678
  countQ.#offset = undefined;
2319
3679
  countQ.#orderBys = [];
2320
- const { sql: cSql, params: cParams } = countQ.toSQL();
2321
- const cRows = await this.#db.query<Record<string, unknown>>(cSql, cParams);
3680
+ let cSql: string;
3681
+ let cParams: unknown[];
3682
+ if (countQ.#groupBy.length > 0) {
3683
+ // A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
3684
+ // group's own size), so `rows[0].count` would be the first group's size, not
3685
+ // the number of pages. Lucid counts via a subquery: wrap the grouped query
3686
+ // (select + groupBy + having preserved) and count its rows = group count.
3687
+ const inner = countQ.toSQL();
3688
+ cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
3689
+ cParams = inner.params;
3690
+ } else {
3691
+ countQ.#select = ["COUNT(*) AS count"];
3692
+ const flat = countQ.toSQL();
3693
+ cSql = flat.sql;
3694
+ cParams = flat.params;
3695
+ }
3696
+ const cRows = await this.#db.query<Record<string, unknown>>(
3697
+ cSql,
3698
+ cParams,
3699
+ this.#meta("paginate"),
3700
+ );
2322
3701
  const total = Number(cRows[0]?.count ?? 0);
2323
3702
 
2324
3703
  const dataQ = this.clone();
@@ -2328,7 +3707,14 @@ export class ModelQuery<T extends BaseEntity> {
2328
3707
  // top of the paginate hooks — paginate is its own terminal.
2329
3708
  const items = await dataQ.#doExec();
2330
3709
  await fireHooks(this.#entityClass, "afterPaginate", items);
2331
- return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
3710
+ const metaKeys = this.#entityClass
3711
+ ? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
3712
+ : undefined;
3713
+ return new Paginator<T>(
3714
+ items,
3715
+ { total, perPage: pp, currentPage: p },
3716
+ metaKeys,
3717
+ );
2332
3718
  }
2333
3719
 
2334
3720
  /**
@@ -2349,9 +3735,13 @@ export class ModelQuery<T extends BaseEntity> {
2349
3735
  limit: number;
2350
3736
  orderBy: string | string[];
2351
3737
  }): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
2352
- const cols = (
2353
- Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]
2354
- ).map((c) => this.#resolveColumn(c));
3738
+ // Keep BOTH forms: `props` (model property names) to read the cursor value
3739
+ // off the hydrated entity, and `cols` (resolved DB columns) for the SQL
3740
+ // ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
3741
+ // encode `undefined` into the cursor (entity exposes the property, not the
3742
+ // DB column) — an unstable / stuck cursor.
3743
+ const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
3744
+ const cols = props.map((c) => this.#resolveColumn(c));
2355
3745
  if (cols.length === 0)
2356
3746
  throw new Error("cursorPaginate requires at least one orderBy column");
2357
3747
  const lim = Math.max(1, Math.floor(opts.limit));
@@ -2400,9 +3790,9 @@ export class ModelQuery<T extends BaseEntity> {
2400
3790
  const last = items[items.length - 1] as Record<string, unknown> | undefined;
2401
3791
  const nextCursor =
2402
3792
  hasMore && last
2403
- ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString(
2404
- "base64",
2405
- )
3793
+ ? Buffer.from(
3794
+ JSON.stringify({ v: props.map((p) => last[p]) }),
3795
+ ).toString("base64")
2406
3796
  : null;
2407
3797
  return { items, nextCursor, hasMore };
2408
3798
  }
@@ -2423,6 +3813,22 @@ export class ModelQuery<T extends BaseEntity> {
2423
3813
  return this;
2424
3814
  }
2425
3815
 
3816
+ /**
3817
+ * Context attached to each statement this query runs, so a `db:query`
3818
+ * listener can say which model and which call produced it — and so
3819
+ * {@link debug} can force emission for this query alone.
3820
+ *
3821
+ * Note the connection's own `debug: true` emits every statement regardless;
3822
+ * `meta` only enriches the event and opens the per-query override.
3823
+ */
3824
+ #meta(method: string): QueryMeta {
3825
+ return {
3826
+ model: this.#entityClass.name,
3827
+ method,
3828
+ debug: this.#debugFlag,
3829
+ };
3830
+ }
3831
+
2426
3832
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
2427
3833
  toQuery(): string {
2428
3834
  const { sql, params } = this.toSQL();
@@ -2443,6 +3849,8 @@ export class ModelQuery<T extends BaseEntity> {
2443
3849
  this.#resolveColumn,
2444
3850
  this.#softDeletes,
2445
3851
  this.#dialect,
3852
+ this.#prepareValue,
3853
+ this.#onDomainEvents,
2446
3854
  );
2447
3855
  c.#softScope = this.#softScope;
2448
3856
  c.#wheres = structuredCloneSafe(this.#wheres);
@@ -2452,17 +3860,29 @@ export class ModelQuery<T extends BaseEntity> {
2452
3860
  c.#offset = this.#offset;
2453
3861
  c.#preloads = new Map(this.#preloads);
2454
3862
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
2455
- c.#joins = [...this.#joins];
3863
+ c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
2456
3864
  c.#lockMode = this.#lockMode;
3865
+ c.#lockModifier = this.#lockModifier;
3866
+ c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
2457
3867
  c.#distinct = this.#distinct;
3868
+ c.#distinctOn = [...this.#distinctOn];
2458
3869
  c.#groupBy = [...this.#groupBy];
2459
3870
  c.#having = structuredCloneSafe(this.#having);
2460
- c.#ctes = this.#ctes.map((e) => ({ name: e.name, query: e.query.clone() }));
3871
+ c.#ctes = this.#ctes.map((e) => ({
3872
+ name: e.name,
3873
+ query: e.query.clone(),
3874
+ recursive: e.recursive,
3875
+ materialized: e.materialized,
3876
+ }));
2461
3877
  c.#unions = this.#unions.map((u) => ({
2462
3878
  query: u.query.clone(),
2463
3879
  all: u.all,
3880
+ op: u.op,
2464
3881
  }));
2465
3882
  c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
3883
+ // Pure closure over pivot metadata — safe to share by reference; it reads the
3884
+ // clone's own #pivotWheres at build time (passed in), holding no query state.
3885
+ c.#pivotExists = this.#pivotExists;
2466
3886
  c.#debugFlag = this.#debugFlag;
2467
3887
  return c;
2468
3888
  }
@@ -2477,15 +3897,18 @@ export class ModelQuery<T extends BaseEntity> {
2477
3897
  if (!patch || Object.keys(patch).length === 0) {
2478
3898
  throw new Error("update() requires a non-empty payload");
2479
3899
  }
3900
+ // Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
3901
+ // like BaseRepository's write paths — the fluent update() must not bypass it.
2480
3902
  const setPairs = Object.entries(patch).map(
2481
- ([k, v]) => [this.#resolveColumn(k), v] as [string, unknown],
3903
+ ([k, v]) =>
3904
+ [this.#resolveColumn(k), this.#prepareValue(k, v)] as [string, unknown],
2482
3905
  );
2483
3906
  const spec = {
2484
3907
  kind: "update",
2485
3908
  table: this.#tableName,
2486
3909
  set: setPairs,
2487
3910
  wheres: this.#wheresForDml(),
2488
- returning: returning ?? [],
3911
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2489
3912
  };
2490
3913
  const compiled = compileStatementNative(spec, this.#dialect);
2491
3914
  if (returning && returning.length > 0) {
@@ -2498,25 +3921,67 @@ export class ModelQuery<T extends BaseEntity> {
2498
3921
  return r.rowsAffected ?? 0;
2499
3922
  }
2500
3923
 
2501
- /** Execute a fluent DELETE. Returns affected rows (or rows when `returning` is set). */
3924
+ /**
3925
+ * Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
3926
+ * scoped rows (stamps `deleted_at`) — consistent with the entity-level
3927
+ * `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
3928
+ * model it issues a hard `DELETE`. Returns affected rows (or rows when
3929
+ * `returning` is set).
3930
+ */
2502
3931
  async delete(
2503
3932
  returning?: string[],
3933
+ ): Promise<number | Record<string, unknown>[]> {
3934
+ if (this.#softDeletes) {
3935
+ const spec = {
3936
+ kind: "update",
3937
+ table: this.#tableName,
3938
+ set: [[this.#deletedAtColumn(), new Date().toISOString()]],
3939
+ wheres: this.#wheresForDml(),
3940
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
3941
+ };
3942
+ return this.#runDml(spec, returning);
3943
+ }
3944
+ return this.forceDelete(returning);
3945
+ }
3946
+
3947
+ /** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
3948
+ async forceDelete(
3949
+ returning?: string[],
2504
3950
  ): Promise<number | Record<string, unknown>[]> {
2505
3951
  const spec = {
2506
3952
  kind: "delete",
2507
3953
  table: this.#tableName,
2508
3954
  wheres: this.#wheresForDml(),
2509
- returning: returning ?? [],
3955
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2510
3956
  };
2511
- const compiled = compileStatementNative(spec, this.#dialect);
2512
- if (returning && returning.length > 0) {
2513
- return this.#db.query<Record<string, unknown>>(
2514
- compiled.statements[0],
2515
- compiled.params,
2516
- );
2517
- }
2518
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
2519
- return r.rowsAffected ?? 0;
3957
+ return this.#runDml(spec, returning);
3958
+ }
3959
+
3960
+ /**
3961
+ * Bulk restore: clear `deleted_at` on the trashed rows matching the user's
3962
+ * predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
3963
+ * on a non-soft-delete model. Independent of the current soft-scope — it always
3964
+ * targets trashed rows (`deleted_at IS NOT NULL`).
3965
+ */
3966
+ async restore(
3967
+ returning?: string[],
3968
+ ): Promise<number | Record<string, unknown>[]> {
3969
+ if (!this.#softDeletes) return 0;
3970
+ const wheres = this.#userWheresForDml();
3971
+ wheres.push({
3972
+ column: this.#deletedAtColumn(),
3973
+ operator: "IS NOT NULL",
3974
+ value: null,
3975
+ type: "and",
3976
+ });
3977
+ const spec = {
3978
+ kind: "update",
3979
+ table: this.#tableName,
3980
+ set: [[this.#deletedAtColumn(), null]],
3981
+ wheres,
3982
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
3983
+ };
3984
+ return this.#runDml(spec, returning);
2520
3985
  }
2521
3986
 
2522
3987
  // === Story 30.3 — increment / decrement already implemented? check ================================
@@ -2563,6 +4028,56 @@ export class ModelQuery<T extends BaseEntity> {
2563
4028
  return this;
2564
4029
  }
2565
4030
 
4031
+ /** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
4032
+ forNoKeyUpdate(): this {
4033
+ if (this.#dialect === "postgres") {
4034
+ this.#lockMode = "FOR NO KEY UPDATE";
4035
+ } else {
4036
+ console.warn(
4037
+ `[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`,
4038
+ );
4039
+ }
4040
+ return this;
4041
+ }
4042
+
4043
+ /** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
4044
+ forKeyShare(): this {
4045
+ if (this.#dialect === "postgres") {
4046
+ this.#lockMode = "FOR KEY SHARE";
4047
+ } else {
4048
+ console.warn(
4049
+ `[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`,
4050
+ );
4051
+ }
4052
+ return this;
4053
+ }
4054
+
4055
+ /**
4056
+ * Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
4057
+ * waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
4058
+ */
4059
+ skipLocked(): this {
4060
+ if (this.#dialect === "sqlite") {
4061
+ console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
4062
+ } else {
4063
+ this.#lockModifier = "SKIP LOCKED";
4064
+ }
4065
+ return this;
4066
+ }
4067
+
4068
+ /**
4069
+ * Append `NOWAIT` to the lock clause — error immediately instead of waiting on
4070
+ * a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
4071
+ */
4072
+ noWait(): this {
4073
+ if (this.#dialect === "sqlite") {
4074
+ console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
4075
+ } else {
4076
+ this.#lockModifier = "NOWAIT";
4077
+ }
4078
+ return this;
4079
+ }
4080
+
2566
4081
  // === Private helpers ==============================================================================
2567
4082
 
2568
4083
  #quote(name: string): string {
@@ -2571,11 +4086,20 @@ export class ModelQuery<T extends BaseEntity> {
2571
4086
 
2572
4087
  /** Quote a `table.column` reference on both sides of the dot. */
2573
4088
  #quoteCol(ref: string): string {
2574
- if (ref.includes(".")) {
2575
- const [t, c] = ref.split(".", 2);
2576
- return `${this.#quote(t)}.${this.#quote(c)}`;
4089
+ // Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
4090
+ // identifier smuggling a `"`/backtick would break out of the quoting on the
4091
+ // join path (which the Rust screen doesn't re-validate). Strict
4092
+ // `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
4093
+ // helpers injection-safe. Use joinRaw() for anything more complex.
4094
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
4095
+ throw new Error(
4096
+ `Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`,
4097
+ );
2577
4098
  }
2578
- return this.#quote(ref);
4099
+ return ref
4100
+ .split(".")
4101
+ .map((seg) => this.#quote(seg))
4102
+ .join(".");
2579
4103
  }
2580
4104
 
2581
4105
  #pushJoin(
@@ -2584,7 +4108,7 @@ export class ModelQuery<T extends BaseEntity> {
2584
4108
  leftOrBuild: string | ((j: JoinBuilder) => void),
2585
4109
  right?: string,
2586
4110
  ): this {
2587
- const tq = this.#quote(table);
4111
+ const tq = this.#quoteCol(table);
2588
4112
  if (typeof leftOrBuild === "function") {
2589
4113
  const jb: JoinBuilder = {
2590
4114
  parts: [],
@@ -2596,28 +4120,61 @@ export class ModelQuery<T extends BaseEntity> {
2596
4120
  this.parts.push({ kind: "and", left: l, right: r });
2597
4121
  return this;
2598
4122
  },
2599
- andOnVal(l: string, _v: unknown) {
2600
- this.parts.push({ kind: "and", left: l, right: "?" });
4123
+ orOn(l: string, r: string) {
4124
+ this.parts.push({ kind: "or", left: l, right: r });
4125
+ return this;
4126
+ },
4127
+ onVal(l: string, v: unknown) {
4128
+ this.parts.push({ kind: "and", left: l, value: { v } });
4129
+ return this;
4130
+ },
4131
+ andOnVal(l: string, v: unknown) {
4132
+ this.parts.push({ kind: "and", left: l, value: { v } });
4133
+ return this;
4134
+ },
4135
+ orOnVal(l: string, v: unknown) {
4136
+ this.parts.push({ kind: "or", left: l, value: { v } });
2601
4137
  return this;
2602
4138
  },
2603
4139
  };
2604
4140
  leftOrBuild(jb);
4141
+ // Collect the bound values in placeholder order as the fragment is built.
4142
+ const params: unknown[] = [];
2605
4143
  const on = jb.parts
2606
4144
  .map((p, i) => {
2607
4145
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
2608
- return `${prefix} ${this.#quoteCol(p.left)} = ${p.right === "?" ? "?" : this.#quoteCol(p.right)}`;
4146
+ if (p.value) {
4147
+ // A BASE-table column runs the full model prepare (DateTime→ISO +
4148
+ // @Column adapters/casts), keyed by its property. A FOREIGN join
4149
+ // column must NOT borrow the root model's adapter for a same-named
4150
+ // column on another table — apply only universal type-lowering
4151
+ // (Date/DateTime→ISO), matching Knex's model-agnostic join binding.
4152
+ const dot = p.left.lastIndexOf(".");
4153
+ const tablePrefix = dot >= 0 ? p.left.slice(0, dot) : "";
4154
+ const leaf = dot >= 0 ? p.left.slice(dot + 1) : p.left;
4155
+ const isBaseColumn =
4156
+ tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
4157
+ params.push(
4158
+ isBaseColumn
4159
+ ? this.#prepareValue(leaf, p.value.v)
4160
+ : lowerJoinValue(p.value.v),
4161
+ );
4162
+ return `${prefix} ${this.#quoteCol(p.left)} = ?`;
4163
+ }
4164
+ return `${prefix} ${this.#quoteCol(p.left)} = ${this.#quoteCol(p.right ?? "")}`;
2609
4165
  })
2610
4166
  .join(" ");
2611
- this.#joins.push(`${kind} JOIN ${tq} ${on}`);
4167
+ this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
2612
4168
  return this;
2613
4169
  }
2614
4170
  if (right === undefined)
2615
4171
  throw new Error(
2616
4172
  "join() with string form requires both left and right operands",
2617
4173
  );
2618
- this.#joins.push(
2619
- `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
2620
- );
4174
+ this.#joins.push({
4175
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
4176
+ params: [],
4177
+ });
2621
4178
  return this;
2622
4179
  }
2623
4180
 
@@ -2663,7 +4220,8 @@ export class ModelQuery<T extends BaseEntity> {
2663
4220
  * still rejected because the DML compiler's WHERE lowering does not yet
2664
4221
  * handle nested sub-queries or correlated EXISTS.
2665
4222
  */
2666
- #wheresForDml(): Array<Record<string, unknown>> {
4223
+ /** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
4224
+ #userWheresForDml(): Array<Record<string, unknown>> {
2667
4225
  const out: Array<Record<string, unknown>> = [];
2668
4226
  for (const w of this.#wheres) {
2669
4227
  if ("kind" in w) {
@@ -2691,6 +4249,50 @@ export class ModelQuery<T extends BaseEntity> {
2691
4249
  return out;
2692
4250
  }
2693
4251
 
4252
+ #wheresForDml(): Array<Record<string, unknown>> {
4253
+ const out = this.#userWheresForDml();
4254
+ // Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
4255
+ // update/delete/increment/decrement must NOT touch trashed rows under the
4256
+ // default scope — otherwise `query().where(x)` would denote a different row
4257
+ // set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
4258
+ // `.onlyTrashed()` restricts to trashed (mirrors reads).
4259
+ if (this.#softDeletes) {
4260
+ const deletedAtCol = this.#deletedAtColumn();
4261
+ if (this.#softScope === "default") {
4262
+ out.push({
4263
+ column: deletedAtCol,
4264
+ operator: "IS NULL",
4265
+ value: null,
4266
+ type: "and",
4267
+ });
4268
+ } else if (this.#softScope === "only-trashed") {
4269
+ out.push({
4270
+ column: deletedAtCol,
4271
+ operator: "IS NOT NULL",
4272
+ value: null,
4273
+ type: "and",
4274
+ });
4275
+ }
4276
+ }
4277
+ return out;
4278
+ }
4279
+
4280
+ /** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
4281
+ async #runDml(
4282
+ spec: Record<string, unknown>,
4283
+ returning?: string[],
4284
+ ): Promise<number | Record<string, unknown>[]> {
4285
+ const compiled = compileStatementNative(spec, this.#dialect);
4286
+ if (returning && returning.length > 0) {
4287
+ return this.#db.query<Record<string, unknown>>(
4288
+ compiled.statements[0],
4289
+ compiled.params,
4290
+ );
4291
+ }
4292
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
4293
+ return r.rowsAffected ?? 0;
4294
+ }
4295
+
2694
4296
  /**
2695
4297
  * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
2696
4298
  *
@@ -2728,6 +4330,7 @@ export class ModelQuery<T extends BaseEntity> {
2728
4330
  this.#resolveColumn,
2729
4331
  false,
2730
4332
  this.#dialect,
4333
+ this.#prepareValue,
2731
4334
  );
2732
4335
  callback(scratch);
2733
4336
  return { type, kind: "group", conditions: scratch.#wheres };
@@ -2781,19 +4384,30 @@ export class ModelQuery<T extends BaseEntity> {
2781
4384
  type,
2782
4385
  column: resolved,
2783
4386
  operator: "=",
2784
- value: operatorOrValue,
4387
+ value: this.#prep(column, operatorOrValue),
2785
4388
  });
2786
4389
  } else {
2787
4390
  this.#wheres.push({
2788
4391
  type,
2789
4392
  column: resolved,
2790
4393
  operator: operatorOrValue as string,
2791
- value,
4394
+ value: this.#prep(column, value),
2792
4395
  });
2793
4396
  }
2794
4397
  return this;
2795
4398
  }
2796
4399
 
4400
+ /**
4401
+ * Lower a WHERE/search value (or each element of an array) to its DB form via
4402
+ * the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
4403
+ * adapter column used as a predicate binds the same shape the write path stores.
4404
+ */
4405
+ #prep(column: string, value: unknown): unknown {
4406
+ return Array.isArray(value)
4407
+ ? value.map((v) => this.#prepareValue(column, v))
4408
+ : this.#prepareValue(column, value);
4409
+ }
4410
+
2797
4411
  /**
2798
4412
  * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
2799
4413
  * Used by the nested-preload machinery to recurse without re-running the root select.