@c9up/atlas 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +55 -14
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +6 -0
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +2 -2
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +171 -7
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -31
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +91 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +193 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +77 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1423 -354
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +429 -11
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1733 -145
  26. package/dist/ModelQuery.js.map +1 -1
  27. package/dist/Transaction.d.ts +17 -0
  28. package/dist/Transaction.d.ts.map +1 -1
  29. package/dist/Transaction.js +57 -5
  30. package/dist/Transaction.js.map +1 -1
  31. package/dist/adapters/NapiDbAdapter.d.ts +33 -4
  32. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  33. package/dist/adapters/NapiDbAdapter.js +101 -11
  34. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  35. package/dist/console/migrationCommands.d.ts +48 -0
  36. package/dist/console/migrationCommands.d.ts.map +1 -0
  37. package/dist/console/migrationCommands.js +220 -0
  38. package/dist/console/migrationCommands.js.map +1 -0
  39. package/dist/decorators/entity.d.ts +37 -6
  40. package/dist/decorators/entity.d.ts.map +1 -1
  41. package/dist/decorators/entity.js +32 -2
  42. package/dist/decorators/entity.js.map +1 -1
  43. package/dist/events.d.ts +64 -0
  44. package/dist/events.d.ts.map +1 -0
  45. package/dist/events.js +82 -0
  46. package/dist/events.js.map +1 -0
  47. package/dist/index.d.ts +5 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/metadata-keys.d.ts +3 -2
  52. package/dist/metadata-keys.d.ts.map +1 -1
  53. package/dist/naming/NamingStrategy.d.ts +7 -0
  54. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  55. package/dist/naming/NamingStrategy.js +16 -0
  56. package/dist/naming/NamingStrategy.js.map +1 -1
  57. package/dist/schema/Migration.d.ts +26 -3
  58. package/dist/schema/Migration.d.ts.map +1 -1
  59. package/dist/schema/Migration.js +33 -24
  60. package/dist/schema/Migration.js.map +1 -1
  61. package/dist/schema/MigrationRunner.d.ts +43 -32
  62. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  63. package/dist/schema/MigrationRunner.js +211 -26
  64. package/dist/schema/MigrationRunner.js.map +1 -1
  65. package/dist/schema/Schema.d.ts +57 -0
  66. package/dist/schema/Schema.d.ts.map +1 -1
  67. package/dist/schema/Schema.js +138 -3
  68. package/dist/schema/Schema.js.map +1 -1
  69. package/dist/schema/SchemaCheck.d.ts.map +1 -1
  70. package/dist/schema/SchemaCheck.js +3 -1
  71. package/dist/schema/SchemaCheck.js.map +1 -1
  72. package/dist/schema/TableBuilder.d.ts +247 -8
  73. package/dist/schema/TableBuilder.d.ts.map +1 -1
  74. package/dist/schema/TableBuilder.js +607 -41
  75. package/dist/schema/TableBuilder.js.map +1 -1
  76. package/dist/schema/catalog.d.ts +47 -0
  77. package/dist/schema/catalog.d.ts.map +1 -0
  78. package/dist/schema/catalog.js +111 -0
  79. package/dist/schema/catalog.js.map +1 -0
  80. package/dist/schema/introspect.js.map +1 -1
  81. package/dist/schema/types.d.ts +150 -1
  82. package/dist/schema/types.d.ts.map +1 -1
  83. package/dist/schema/types.js +11 -0
  84. package/dist/schema/types.js.map +1 -1
  85. package/dist/services/db.d.ts +6 -0
  86. package/dist/services/db.d.ts.map +1 -1
  87. package/dist/services/db.js +17 -0
  88. package/dist/services/db.js.map +1 -1
  89. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  90. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  91. package/dist/testing/DatabaseCleanup.js +21 -18
  92. package/dist/testing/DatabaseCleanup.js.map +1 -1
  93. package/dist/testing/Factory.d.ts +70 -5
  94. package/dist/testing/Factory.d.ts.map +1 -1
  95. package/dist/testing/Factory.js +209 -10
  96. package/dist/testing/Factory.js.map +1 -1
  97. package/index.darwin-arm64.node +0 -0
  98. package/index.darwin-x64.node +0 -0
  99. package/index.linux-arm64-gnu.node +0 -0
  100. package/index.linux-x64-gnu.node +0 -0
  101. package/index.win32-x64-msvc.node +0 -0
  102. package/package.json +4 -1
  103. package/scripts/guard-publish.mjs +15 -0
  104. package/src/AtlasProvider.ts +8 -1
  105. package/src/BaseEntity.ts +449 -40
  106. package/src/BaseModel.ts +324 -0
  107. package/src/BaseRepository.ts +1659 -371
  108. package/src/ModelQuery.ts +2290 -203
  109. package/src/Transaction.ts +68 -5
  110. package/src/adapters/NapiDbAdapter.ts +159 -10
  111. package/src/console/migrationCommands.ts +258 -0
  112. package/src/decorators/entity.ts +53 -6
  113. package/src/events.ts +112 -0
  114. package/src/index.ts +19 -0
  115. package/src/metadata-keys.ts +3 -2
  116. package/src/naming/NamingStrategy.ts +23 -0
  117. package/src/schema/Migration.ts +42 -3
  118. package/src/schema/MigrationRunner.ts +270 -27
  119. package/src/schema/Schema.ts +210 -3
  120. package/src/schema/SchemaCheck.ts +7 -2
  121. package/src/schema/TableBuilder.ts +735 -41
  122. package/src/schema/catalog.ts +166 -0
  123. package/src/schema/introspect.ts +3 -4
  124. package/src/schema/types.ts +137 -2
  125. package/src/services/db.ts +28 -0
  126. package/src/testing/DatabaseCleanup.ts +23 -22
  127. package/src/testing/Factory.ts +332 -15
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";
@@ -180,27 +357,84 @@ interface HavingClause {
180
357
  type: "and" | "or";
181
358
  }
182
359
 
360
+ /** A raw SQL HAVING fragment with `?` bindings — kind-tagged for the Rust compiler. */
361
+ interface HavingRawClause {
362
+ kind: "raw";
363
+ sql: string;
364
+ bindings: unknown[];
365
+ type: "and" | "or";
366
+ }
367
+
368
+ type HavingEntry = HavingClause | HavingRawClause;
369
+
370
+ /** A compiled CTE (`WITH name AS (...)`) — the sub-select is pre-compiled to SQL + params. */
371
+ interface CteSpec {
372
+ name: string;
373
+ sql: string;
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;
383
+ }
384
+
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
+ */
390
+ interface UnionSpec {
391
+ sql: string;
392
+ params: unknown[];
393
+ all: boolean;
394
+ /** Defaults to `union` when absent/null, keeping the pre-existing wire format. */
395
+ op?: "union" | "intersect" | "except" | null;
396
+ }
397
+
183
398
  interface SubqueryProjection {
184
399
  alias: string;
185
400
  subquery: SelectSpec;
186
401
  }
187
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
+
188
419
  interface SelectSpec {
189
420
  kind: "select";
190
421
  table: string;
191
422
  select: string[];
192
423
  selectSubqueries: SubqueryProjection[];
193
424
  wheres: WhereClause[];
194
- orderBy: Array<{ column: string; direction: "asc" | "desc" }>;
195
- groupBy: string[];
196
- having: HavingClause[];
425
+ orderBy: OrderByEntry[];
426
+ groupBy: GroupByEntry[];
427
+ having: HavingEntry[];
197
428
  limit: number | null;
198
429
  offset: number | null;
199
430
  distinct: boolean;
200
- ctes: unknown[];
201
- unions: unknown[];
202
- joins: string[];
203
- lockMode: "FOR UPDATE" | "FOR SHARE" | null;
431
+ distinctOn: string[];
432
+ ctes: CteSpec[];
433
+ unions: UnionSpec[];
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;
204
438
  }
205
439
 
206
440
  type WhereClause =
@@ -208,15 +442,18 @@ type WhereClause =
208
442
  | RawWhere
209
443
  | ExistsWhere
210
444
  | GroupWhere
211
- | InSubWhere;
445
+ | InSubWhere
446
+ | JsonWhere;
212
447
 
213
448
  type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
214
449
 
215
450
  /**
216
- * Process-wide strict mode flag. When enabled, `whereRaw()` and `joinRaw()`
217
- * throw unconditionally — forcing every call site to use the typed
218
- * `whereExpr()` / `joinOn()` / structured builder paths. Intended for prod
219
- * 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.
220
457
  *
221
458
  * Enable via:
222
459
  * - `setAtlasStrictMode(true)` at app bootstrap
@@ -228,7 +465,7 @@ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
228
465
  */
229
466
  let atlasStrictMode: boolean | undefined;
230
467
 
231
- /** 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. */
232
469
  export function setAtlasStrictMode(enabled: boolean): void {
233
470
  atlasStrictMode = enabled;
234
471
  }
@@ -262,12 +499,26 @@ function isInternalBypass(): boolean {
262
499
  return atlasInternalBypass;
263
500
  }
264
501
 
265
- /** 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
+ */
266
508
  interface JoinBuilder {
267
- 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
+ }>;
268
516
  on(left: string, right: string): JoinBuilder;
269
517
  andOn(left: string, right: string): JoinBuilder;
518
+ orOn(left: string, right: string): JoinBuilder;
519
+ onVal(left: string, value: unknown): JoinBuilder;
270
520
  andOnVal(left: string, value: unknown): JoinBuilder;
521
+ orOnVal(left: string, value: unknown): JoinBuilder;
271
522
  }
272
523
 
273
524
  /** Offset-based paginator (Story 29.10). */
@@ -282,23 +533,59 @@ export class Paginator<T> {
282
533
  };
283
534
  #baseUrl?: string;
284
535
  #queryString: Record<string, unknown> = {};
536
+ #metaKeys?: Record<string, string>;
285
537
 
286
538
  constructor(
287
539
  items: T[],
288
540
  base: { total: number; perPage: number; currentPage: number },
541
+ metaKeys?: Record<string, string>,
289
542
  ) {
290
543
  this.items = items;
291
544
  const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
292
545
  this.meta = { ...base, lastPage, firstPage: 1 };
546
+ this.#metaKeys = metaKeys;
293
547
  }
294
548
 
295
549
  all(): T[] {
296
550
  return this.items;
297
551
  }
298
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
+
576
+ /** True when there is more than one page of results (AdonisJS `hasPages`). */
577
+ get hasPages(): boolean {
578
+ return this.meta.lastPage > 1;
579
+ }
580
+
581
+ /** True when there is at least one more page after the current one (AdonisJS `hasMorePages`). */
582
+ get hasMorePages(): boolean {
583
+ return this.meta.currentPage < this.meta.lastPage;
584
+ }
585
+
299
586
  serialize(opts?: { fields?: string[] }): {
300
587
  data: unknown[];
301
- meta: Paginator<T>["meta"];
588
+ meta: Record<string, unknown>;
302
589
  } {
303
590
  const data = this.items.map((item) => {
304
591
  if (!opts?.fields) return item;
@@ -307,7 +594,9 @@ export class Paginator<T> {
307
594
  picked[f] = (item as Record<string, unknown>)[f];
308
595
  return picked;
309
596
  });
310
- 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() };
311
600
  }
312
601
 
313
602
  baseUrl(url: string): this {
@@ -319,29 +608,76 @@ export class Paginator<T> {
319
608
  return this;
320
609
  }
321
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
+
322
676
  toJSON(): {
323
677
  data: unknown[];
324
- meta: Paginator<T>["meta"] & Record<string, unknown>;
678
+ meta: Record<string, unknown>;
325
679
  } {
326
- const meta: Paginator<T>["meta"] & Record<string, unknown> = {
327
- ...this.meta,
328
- };
329
- if (this.#baseUrl) {
330
- const build = (page: number) => {
331
- const params = new URLSearchParams();
332
- for (const [k, v] of Object.entries(this.#queryString))
333
- params.set(k, String(v));
334
- params.set("page", String(page));
335
- return `${this.#baseUrl}?${params.toString()}`;
336
- };
337
- meta.firstPageUrl = build(1);
338
- meta.lastPageUrl = build(this.meta.lastPage);
339
- if (this.meta.currentPage < this.meta.lastPage)
340
- meta.nextPageUrl = build(this.meta.currentPage + 1);
341
- if (this.meta.currentPage > 1)
342
- meta.previousPageUrl = build(this.meta.currentPage - 1);
343
- }
344
- return { data: this.items as unknown[], meta };
680
+ return { data: this.items as unknown[], meta: this.#buildMeta() };
345
681
  }
346
682
  }
347
683
 
@@ -359,7 +695,7 @@ export class ModelQuery<T extends BaseEntity> {
359
695
  #softDeletes: boolean;
360
696
  #softScope: SoftDeleteScope = "default";
361
697
  #wheres: WhereClause[] = [];
362
- #orderBys: Array<{ column: string; direction: "asc" | "desc" }> = [];
698
+ #orderBys: OrderByEntry[] = [];
363
699
  #select: string[] = ["*"];
364
700
  #limit?: number;
365
701
  #offset?: number;
@@ -369,13 +705,62 @@ export class ModelQuery<T extends BaseEntity> {
369
705
  /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
370
706
  #subqueryAlias?: string;
371
707
  /** Raw JOIN fragments — Story 29.4. */
372
- #joins: string[] = [];
373
- /** Row lock mode — Story 30.8. */
374
- #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;
375
720
  /** Per-query debug flag — Story 29.11. */
376
721
  #debugFlag = false;
377
722
  /** Distinct flag — Story 29.5. */
378
723
  #distinct = false;
724
+ #distinctOn: string[] = [];
725
+ /** GROUP BY columns (Lucid parity). */
726
+ #groupBy: GroupByEntry[] = [];
727
+ /** HAVING clauses — structured + raw (Lucid parity). */
728
+ #having: HavingEntry[] = [];
729
+ /** CTEs registered via `.with()` (Lucid parity). */
730
+ #ctes: Array<{
731
+ name: string;
732
+ query: ModelQuery<BaseEntity>;
733
+ recursive?: boolean;
734
+ materialized?: boolean;
735
+ }> = [];
736
+ /** UNION / UNION ALL branches (Lucid parity). */
737
+ #unions: Array<{
738
+ query: ModelQuery<BaseEntity>;
739
+ all: boolean;
740
+ op?: "union" | "intersect" | "except";
741
+ }> = [];
742
+ /** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
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[] };
379
764
  /** SQL dialect for compilation — inherited from the owning BaseRepository. */
380
765
  #dialect: AtlasDialect;
381
766
 
@@ -387,6 +772,8 @@ export class ModelQuery<T extends BaseEntity> {
387
772
  resolveColumn: ColumnResolver = (c) => c,
388
773
  softDeletes = false,
389
774
  dialect: AtlasDialect = getAtlasDialect(),
775
+ prepareValue: ValuePreparer = (_c, v) => v,
776
+ onDomainEvents?: (events: DomainEvent[]) => Promise<void>,
390
777
  ) {
391
778
  this.#tableName = tableName;
392
779
  this.#db = db;
@@ -395,8 +782,15 @@ export class ModelQuery<T extends BaseEntity> {
395
782
  this.#resolveColumn = resolveColumn;
396
783
  this.#softDeletes = softDeletes;
397
784
  this.#dialect = dialect;
785
+ this.#prepareValue = prepareValue;
786
+ this.#onDomainEvents = onDomainEvents;
398
787
  }
399
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
+
400
794
  /** Include soft-deleted rows in the result (default behavior excludes them). */
401
795
  withTrashed(): this {
402
796
  this.#softScope = "with-trashed";
@@ -424,12 +818,31 @@ export class ModelQuery<T extends BaseEntity> {
424
818
 
425
819
  /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
426
820
  select(columns: string | string[]): this {
427
- this.#select = Array.isArray(columns)
821
+ const list = Array.isArray(columns)
428
822
  ? columns
429
823
  : columns.split(",").map((c) => c.trim());
824
+ this.#select = list.map((c) => this.#resolveSelect(c));
430
825
  return this;
431
826
  }
432
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
+
433
846
  where(callback: WhereCallback): this;
434
847
  where(column: string, value: unknown): this;
435
848
  where(column: string, operator: string, value: unknown): this;
@@ -480,13 +893,88 @@ export class ModelQuery<T extends BaseEntity> {
480
893
  return this;
481
894
  }
482
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
+
483
971
  /** `WHERE col != ?` — negation of `where`. */
484
972
  whereNot(column: string, value: unknown): this {
485
973
  this.#wheres.push({
486
974
  type: "and",
487
975
  column: this.#resolveColumn(column),
488
976
  operator: "!=",
489
- value,
977
+ value: this.#prep(column, value),
490
978
  });
491
979
  return this;
492
980
  }
@@ -510,7 +998,7 @@ export class ModelQuery<T extends BaseEntity> {
510
998
  type: "and",
511
999
  column: this.#resolveColumn(column),
512
1000
  operator: "IN",
513
- value: [...source],
1001
+ value: this.#prep(column, [...source]),
514
1002
  });
515
1003
  return this;
516
1004
  }
@@ -534,7 +1022,7 @@ export class ModelQuery<T extends BaseEntity> {
534
1022
  type: "and",
535
1023
  column: this.#resolveColumn(column),
536
1024
  operator: "NOT IN",
537
- value: [...source],
1025
+ value: this.#prep(column, [...source]),
538
1026
  });
539
1027
  return this;
540
1028
  }
@@ -545,7 +1033,7 @@ export class ModelQuery<T extends BaseEntity> {
545
1033
  type: "and",
546
1034
  column: this.#resolveColumn(column),
547
1035
  operator: "BETWEEN",
548
- value: [...range],
1036
+ value: this.#prep(column, [...range]),
549
1037
  });
550
1038
  return this;
551
1039
  }
@@ -556,7 +1044,7 @@ export class ModelQuery<T extends BaseEntity> {
556
1044
  type: "and",
557
1045
  column: this.#resolveColumn(column),
558
1046
  operator: "NOT BETWEEN",
559
- value: [...range],
1047
+ value: this.#prep(column, [...range]),
560
1048
  });
561
1049
  return this;
562
1050
  }
@@ -587,6 +1075,135 @@ export class ModelQuery<T extends BaseEntity> {
587
1075
  return this;
588
1076
  }
589
1077
 
1078
+ // ─── OR-combined variants (AdonisJS orWhere* family) ─────────
1079
+ // Same predicates as the whereX methods above, combined with OR instead of
1080
+ // AND — the named ergonomics Lucid exposes (vs emulating with `orWhere(cb)`).
1081
+
1082
+ /** `OR col IS NULL`. */
1083
+ orWhereNull(column: string): this {
1084
+ this.#wheres.push({
1085
+ type: "or",
1086
+ column: this.#resolveColumn(column),
1087
+ operator: "IS NULL",
1088
+ value: null,
1089
+ });
1090
+ return this;
1091
+ }
1092
+
1093
+ /** `OR col IS NOT NULL`. */
1094
+ orWhereNotNull(column: string): this {
1095
+ this.#wheres.push({
1096
+ type: "or",
1097
+ column: this.#resolveColumn(column),
1098
+ operator: "IS NOT NULL",
1099
+ value: null,
1100
+ });
1101
+ return this;
1102
+ }
1103
+
1104
+ /** `OR col != ?`. */
1105
+ orWhereNot(column: string, value: unknown): this {
1106
+ this.#wheres.push({
1107
+ type: "or",
1108
+ column: this.#resolveColumn(column),
1109
+ operator: "!=",
1110
+ value: this.#prep(column, value),
1111
+ });
1112
+ return this;
1113
+ }
1114
+
1115
+ /** `OR col IN (...)` — array or `ModelQuery` subquery source. */
1116
+ orWhereIn(
1117
+ column: string,
1118
+ source: readonly unknown[] | ModelQuery<BaseEntity>,
1119
+ ): this {
1120
+ if (source instanceof ModelQuery) {
1121
+ this.#wheres.push({
1122
+ type: "or",
1123
+ kind: "inSub",
1124
+ negated: false,
1125
+ column: this.#resolveColumn(column),
1126
+ subquery: source.#buildSpec(),
1127
+ });
1128
+ return this;
1129
+ }
1130
+ this.#wheres.push({
1131
+ type: "or",
1132
+ column: this.#resolveColumn(column),
1133
+ operator: "IN",
1134
+ value: this.#prep(column, [...source]),
1135
+ });
1136
+ return this;
1137
+ }
1138
+
1139
+ /** `OR col NOT IN (...)` — array or `ModelQuery` subquery source. */
1140
+ orWhereNotIn(
1141
+ column: string,
1142
+ source: readonly unknown[] | ModelQuery<BaseEntity>,
1143
+ ): this {
1144
+ if (source instanceof ModelQuery) {
1145
+ this.#wheres.push({
1146
+ type: "or",
1147
+ kind: "inSub",
1148
+ negated: true,
1149
+ column: this.#resolveColumn(column),
1150
+ subquery: source.#buildSpec(),
1151
+ });
1152
+ return this;
1153
+ }
1154
+ this.#wheres.push({
1155
+ type: "or",
1156
+ column: this.#resolveColumn(column),
1157
+ operator: "NOT IN",
1158
+ value: this.#prep(column, [...source]),
1159
+ });
1160
+ return this;
1161
+ }
1162
+
1163
+ /** `OR col BETWEEN ? AND ?`. */
1164
+ orWhereBetween(column: string, range: readonly [unknown, unknown]): this {
1165
+ this.#wheres.push({
1166
+ type: "or",
1167
+ column: this.#resolveColumn(column),
1168
+ operator: "BETWEEN",
1169
+ value: this.#prep(column, [...range]),
1170
+ });
1171
+ return this;
1172
+ }
1173
+
1174
+ /** `OR col NOT BETWEEN ? AND ?`. */
1175
+ orWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
1176
+ this.#wheres.push({
1177
+ type: "or",
1178
+ column: this.#resolveColumn(column),
1179
+ operator: "NOT BETWEEN",
1180
+ value: this.#prep(column, [...range]),
1181
+ });
1182
+ return this;
1183
+ }
1184
+
1185
+ /** `OR col LIKE ?`. */
1186
+ orWhereLike(column: string, pattern: string): this {
1187
+ this.#wheres.push({
1188
+ type: "or",
1189
+ column: this.#resolveColumn(column),
1190
+ operator: "LIKE",
1191
+ value: pattern,
1192
+ });
1193
+ return this;
1194
+ }
1195
+
1196
+ /** `OR col ILIKE ?` (rewritten to LOWER() LIKE LOWER() on sqlite/mysql). */
1197
+ orWhereILike(column: string, pattern: string): this {
1198
+ this.#wheres.push({
1199
+ type: "or",
1200
+ column: this.#resolveColumn(column),
1201
+ operator: "ILIKE",
1202
+ value: pattern,
1203
+ });
1204
+ return this;
1205
+ }
1206
+
590
1207
  /**
591
1208
  * **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
592
1209
  * `?`-style bindings. The Rust compiler re-indexes the placeholders so they
@@ -629,9 +1246,13 @@ export class ModelQuery<T extends BaseEntity> {
629
1246
  * Not exported from the package barrel — only accessible inside the Atlas
630
1247
  * codebase via direct ModelQuery instance access.
631
1248
  */
632
- #pushWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
1249
+ #pushWhereRaw(
1250
+ sql: string,
1251
+ bindings: readonly unknown[] = [],
1252
+ type: "and" | "or" = "and",
1253
+ ): this {
633
1254
  this.#wheres.push({
634
- type: "and",
1255
+ type,
635
1256
  kind: "raw",
636
1257
  sql,
637
1258
  bindings: [...bindings],
@@ -639,6 +1260,52 @@ export class ModelQuery<T extends BaseEntity> {
639
1260
  return this;
640
1261
  }
641
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
+
642
1309
  /**
643
1310
  * **SAFE** alternative to `whereRaw` for the common case of a single
644
1311
  * SQL expression built from a validated column + operator + bound value.
@@ -689,6 +1356,20 @@ export class ModelQuery<T extends BaseEntity> {
689
1356
  `whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
690
1357
  );
691
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
+ }
692
1373
  // `op` is interpolated raw into the fragment below, so it MUST be
693
1374
  // allow-listed — the 3-arg path gets this from the Rust operator
694
1375
  // validation, but the raw 4-arg path bypasses Rust and would
@@ -707,16 +1388,328 @@ export class ModelQuery<T extends BaseEntity> {
707
1388
  // the operator against the allow-list above.
708
1389
  if (hasExtra) {
709
1390
  const q = this.#quote(resolved);
710
- return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
1391
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
1392
+ this.#prep(column, value),
1393
+ ]);
711
1394
  }
712
- 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
+ });
713
1401
  return this;
714
1402
  }
715
1403
 
716
1404
  /**
717
- * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
718
- * by the existence of related rows, optionally constrained by a callback.
719
- *
1405
+ * Compare two COLUMNS (AdonisJS/Knex `whereColumn`) `WHERE "a" op "b"`.
1406
+ * Both sides go through the identifier quoter (injection-safe) and the
1407
+ * operator is allow-listed; nothing is bound (it's a column reference, not a
1408
+ * value), which the standard `where`/`whereExpr` value-binding path can't do.
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
+
1610
+ whereColumn(left: string, operator: string, right: string): this {
1611
+ return this.#whereColumn("and", left, operator, right);
1612
+ }
1613
+
1614
+ /** `OR`-combined {@link whereColumn}. */
1615
+ orWhereColumn(left: string, operator: string, right: string): this {
1616
+ return this.#whereColumn("or", left, operator, right);
1617
+ }
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
+
1634
+ #whereColumn(
1635
+ type: "and" | "or",
1636
+ left: string,
1637
+ operator: string,
1638
+ right: string,
1639
+ negated = false,
1640
+ ): this {
1641
+ if (!WHEREEXPR_OPERATORS.has(operator)) {
1642
+ throw new Error(
1643
+ `whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`,
1644
+ );
1645
+ }
1646
+ // Both operands are interpolated as raw identifiers (no value binding for a
1647
+ // column reference), and #quote is a plain wrapper that does NOT escape an
1648
+ // embedded quote — so validate each RESOLVED identifier against a strict
1649
+ // `[table.]column` charset. This closes the injection surface regardless of
1650
+ // what #resolveColumn returns (it can be an identity resolver on sub-queries).
1651
+ const safe = (name: string): string => {
1652
+ const resolved = this.#resolveColumnReference(name);
1653
+ if (
1654
+ !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
1655
+ ) {
1656
+ throw new Error(
1657
+ `whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`,
1658
+ );
1659
+ }
1660
+ // Quote each dotted segment separately → `"table"."column"`, never a
1661
+ // single mis-quoted `"table.column"`.
1662
+ return resolved
1663
+ .split(".")
1664
+ .map((part) => this.#quote(part))
1665
+ .join(".");
1666
+ };
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;
1671
+ this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
1672
+ return this;
1673
+ }
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
+
1709
+ /**
1710
+ * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
1711
+ * by the existence of related rows, optionally constrained by a callback.
1712
+ *
720
1713
  * userRepo.query().whereHas('comments', q => q.where('approved', true))
721
1714
  */
722
1715
  whereHas(
@@ -761,6 +1754,22 @@ export class ModelQuery<T extends BaseEntity> {
761
1754
  return this;
762
1755
  }
763
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
+
764
1773
  /**
765
1774
  * Short form of `whereHas`. With an operator + count, emits a count threshold:
766
1775
  * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
@@ -800,6 +1809,26 @@ export class ModelQuery<T extends BaseEntity> {
800
1809
  return this;
801
1810
  }
802
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
+
803
1832
  /**
804
1833
  * Set this query's projection alias — only meaningful when this ModelQuery
805
1834
  * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
@@ -844,27 +1873,37 @@ export class ModelQuery<T extends BaseEntity> {
844
1873
  /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
845
1874
  async count(column: string = "*"): Promise<number> {
846
1875
  const expr =
847
- column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
1876
+ column === "*"
1877
+ ? "COUNT(*)"
1878
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
848
1879
  return Number((await this.#runScalar(expr)) ?? 0);
849
1880
  }
850
1881
 
851
1882
  async sum(column: string): Promise<number | null> {
852
- const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
1883
+ const v = await this.#runScalar(
1884
+ `SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
1885
+ );
853
1886
  return v === null || v === undefined ? null : Number(v);
854
1887
  }
855
1888
 
856
1889
  async avg(column: string): Promise<number | null> {
857
- const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
1890
+ const v = await this.#runScalar(
1891
+ `AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
1892
+ );
858
1893
  return v === null || v === undefined ? null : Number(v);
859
1894
  }
860
1895
 
861
1896
  async min(column: string): Promise<number | null> {
862
- const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
1897
+ const v = await this.#runScalar(
1898
+ `MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
1899
+ );
863
1900
  return v === null || v === undefined ? null : Number(v);
864
1901
  }
865
1902
 
866
1903
  async max(column: string): Promise<number | null> {
867
- const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
1904
+ const v = await this.#runScalar(
1905
+ `MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
1906
+ );
868
1907
  return v === null || v === undefined ? null : Number(v);
869
1908
  }
870
1909
 
@@ -881,42 +1920,417 @@ export class ModelQuery<T extends BaseEntity> {
881
1920
  relationName: string,
882
1921
  callback?: (query: ModelQuery<BaseEntity>) => void,
883
1922
  ): this {
884
- this.#selectSubqueries.push(
885
- this.#buildRelationSubquery(
886
- relationName,
887
- callback,
888
- "count",
889
- `${relationName}_count`,
890
- ),
891
- );
892
- return this;
1923
+ this.#selectSubqueries.push(
1924
+ this.#buildRelationSubquery(
1925
+ relationName,
1926
+ callback,
1927
+ "count",
1928
+ `${relationName}_count`,
1929
+ ),
1930
+ );
1931
+ return this;
1932
+ }
1933
+
1934
+ /**
1935
+ * Project any aggregate (sum/avg/min/max/count) of a relation as an extra column.
1936
+ * The callback MUST set the aggregate via `.sum('col')` / `.avg(...)` etc. and
1937
+ * typically also set an alias via `.as('name')`.
1938
+ *
1939
+ * userRepo.query().withAggregate('posts', q => q.sum('views').as('total_views'))
1940
+ */
1941
+ withAggregate(
1942
+ relationName: string,
1943
+ callback: (query: ModelQuery<BaseEntity>) => void,
1944
+ ): this {
1945
+ this.#selectSubqueries.push(
1946
+ this.#buildRelationSubquery(
1947
+ relationName,
1948
+ callback,
1949
+ "aggregate",
1950
+ relationName,
1951
+ ),
1952
+ );
1953
+ return this;
1954
+ }
1955
+
1956
+ orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
1957
+ this.#orderBys.push({ column: this.#resolveColumn(column), direction });
1958
+ return this;
1959
+ }
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
+
1981
+ /**
1982
+ * `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
1983
+ * through the entity's column map (camelCase → snake_case) like `orderBy`.
1984
+ * For a grouping expression with no typed form, see {@link groupByRaw}.
1985
+ */
1986
+ groupBy(...columns: string[]): this {
1987
+ for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
1988
+ return this;
1989
+ }
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
+
2008
+ /**
2009
+ * `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
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.
2014
+ */
2015
+ having(column: string, operator: string, value: unknown): this {
2016
+ this.#having.push({
2017
+ column: this.#resolveHavingCol(column),
2018
+ operator,
2019
+ value: this.#prep(column, value),
2020
+ type: "and",
2021
+ });
2022
+ return this;
2023
+ }
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
+
2040
+ /** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
2041
+ orHaving(column: string, operator: string, value: unknown): this {
2042
+ this.#having.push({
2043
+ column: this.#resolveHavingCol(column),
2044
+ operator,
2045
+ value: this.#prep(column, value),
2046
+ type: "or",
2047
+ });
2048
+ return this;
2049
+ }
2050
+
2051
+ /**
2052
+ * **⚠ UNSAFE** — append a raw SQL `HAVING` fragment with `?` bindings
2053
+ * (AdonisJS/Lucid `havingRaw`). The Rust compiler re-indexes the placeholders;
2054
+ * everything else in `sql` is trusted verbatim. All values must go through
2055
+ * `bindings`.
2056
+ *
2057
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
2058
+ */
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
+ }
2068
+ this.#having.push({
2069
+ kind: "raw",
2070
+ sql,
2071
+ bindings: [...bindings],
2072
+ type: "and",
2073
+ });
2074
+ return this;
2075
+ }
2076
+
2077
+ /**
2078
+ * `UNION (<query>)` (AdonisJS/Lucid `union`). The other query is compiled and
2079
+ * appended as a parenthesised UNION branch; its bindings are re-indexed into
2080
+ * the outer parameter list.
2081
+ */
2082
+ union(query: ModelQuery<BaseEntity>): this {
2083
+ this.#unions.push({ query, all: false });
2084
+ return this;
2085
+ }
2086
+
2087
+ /** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
2088
+ unionAll(query: ModelQuery<BaseEntity>): this {
2089
+ this.#unions.push({ query, all: true });
2090
+ return this;
2091
+ }
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
+
2123
+ /**
2124
+ * `WITH <name> AS (<query>)` — register a Common Table Expression
2125
+ * (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
2126
+ * sub-query is compiled and its bindings are re-indexed into the outer list.
2127
+ */
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 {
2173
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
2174
+ throw new Error(
2175
+ `${method}(): CTE name '${name}' is not a valid identifier`,
2176
+ );
2177
+ }
2178
+ this.#ctes.push({ name, query, ...options });
2179
+ return this;
2180
+ }
2181
+
2182
+ /**
2183
+ * `@ManyToMany` only — filter loaded relations by a PIVOT-table column
2184
+ * (AdonisJS/Lucid `wherePivot`). Recorded separately from the related-table
2185
+ * WHEREs and applied to the pivot lookup query by the m2m preload resolver;
2186
+ * inert on non-m2m relations.
2187
+ *
2188
+ * userRepo.query().preload('roles', q => q.wherePivot('active', true))
2189
+ */
2190
+ wherePivot(column: string, value: unknown): this;
2191
+ wherePivot(column: string, operator: string, value: unknown): this;
2192
+ wherePivot(column: string, operatorOrValue: unknown, value?: unknown): 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]);
2237
+ }
2238
+
2239
+ /** Alias of {@link whereInPivot} kept for the earlier atlas name. */
2240
+ wherePivotIn(column: string, values: readonly unknown[]): this {
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);
893
2314
  }
894
2315
 
895
- /**
896
- * Project any aggregate (sum/avg/min/max/count) of a relation as an extra column.
897
- * The callback MUST set the aggregate via `.sum('col')` / `.avg(...)` etc. and
898
- * typically also set an alias via `.as('name')`.
899
- *
900
- * userRepo.query().withAggregate('posts', q => q.sum('views').as('total_views'))
901
- */
902
- withAggregate(
903
- relationName: string,
904
- callback: (query: ModelQuery<BaseEntity>) => void,
2316
+ #pushPivotOp(
2317
+ type: "and" | "or",
2318
+ column: string,
2319
+ operator: string,
2320
+ value: unknown,
905
2321
  ): this {
906
- this.#selectSubqueries.push(
907
- this.#buildRelationSubquery(
908
- relationName,
909
- callback,
910
- "aggregate",
911
- relationName,
912
- ),
913
- );
2322
+ this.#pivotWheres.push({ column, operator, value, type });
914
2323
  return this;
915
2324
  }
916
2325
 
917
- orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
918
- this.#orderBys.push({ column: this.#resolveColumn(column), direction });
919
- return this;
2326
+ /** Read-only accessor for pivot constraints consumed by the m2m preload resolver. */
2327
+ get pivotConstraints(): ReadonlyArray<{
2328
+ column: string;
2329
+ operator: string;
2330
+ value: unknown;
2331
+ type: "and" | "or";
2332
+ }> {
2333
+ return this.#pivotWheres;
920
2334
  }
921
2335
 
922
2336
  limit(n: number): this {
@@ -957,6 +2371,24 @@ export class ModelQuery<T extends BaseEntity> {
957
2371
  return result;
958
2372
  }
959
2373
 
2374
+ /**
2375
+ * Return the single matching row, or throw if there are zero OR more than one
2376
+ * (AdonisJS/Laravel `sole`). Use when exactly one row is a correctness
2377
+ * invariant — a second match signals a bug the silent `first()` would hide.
2378
+ */
2379
+ async sole(): Promise<T> {
2380
+ const rows = await this.limit(2).exec();
2381
+ if (rows.length === 0) {
2382
+ throw new Error(`No ${this.#tableName} found matching query (sole()).`);
2383
+ }
2384
+ if (rows.length > 1) {
2385
+ throw new Error(
2386
+ `Expected exactly one ${this.#tableName} but the query matched multiple rows (sole()).`,
2387
+ );
2388
+ }
2389
+ return rows[0];
2390
+ }
2391
+
960
2392
  /**
961
2393
  * Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
962
2394
  * A chain like `await repo.query().where('active', true).orderBy('id')`
@@ -983,21 +2415,99 @@ export class ModelQuery<T extends BaseEntity> {
983
2415
  }
984
2416
 
985
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
+
986
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
+ }
987
2486
  const wheres: WhereClause[] = [...this.#wheres];
988
- // 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).
989
2498
  if (this.#softDeletes) {
2499
+ const deletedAtCol = this.#deletedAtColumn();
990
2500
  if (this.#softScope === "default") {
991
2501
  wheres.push({
992
2502
  type: "and",
993
- column: "deleted_at",
2503
+ column: deletedAtCol,
994
2504
  operator: "IS NULL",
995
2505
  value: null,
996
2506
  });
997
2507
  } else if (this.#softScope === "only-trashed") {
998
2508
  wheres.push({
999
2509
  type: "and",
1000
- column: "deleted_at",
2510
+ column: deletedAtCol,
1001
2511
  operator: "IS NOT NULL",
1002
2512
  value: null,
1003
2513
  });
@@ -1008,19 +2518,36 @@ export class ModelQuery<T extends BaseEntity> {
1008
2518
  return {
1009
2519
  kind: "select",
1010
2520
  table: this.#tableName,
1011
- select: this.#select,
2521
+ select: selectCols,
1012
2522
  selectSubqueries: this.#selectSubqueries,
1013
2523
  wheres,
1014
2524
  orderBy: this.#orderBys,
1015
- groupBy: [],
1016
- having: [],
2525
+ groupBy: this.#groupBy,
2526
+ having: this.#having,
1017
2527
  limit: this.#limit ?? null,
1018
2528
  offset: this.#offset ?? null,
1019
2529
  distinct: this.#distinct,
1020
- ctes: [],
1021
- unions: [],
2530
+ distinctOn: this.#distinctOn,
2531
+ ctes: this.#ctes.map((c) => {
2532
+ const { sql, params } = c.query.toSQL();
2533
+ return {
2534
+ name: c.name,
2535
+ sql,
2536
+ params,
2537
+ recursive: c.recursive ?? false,
2538
+ materialized: c.materialized ?? null,
2539
+ };
2540
+ }),
2541
+ unions: this.#unions.map((u) => {
2542
+ const { sql, params } = u.query.toSQL();
2543
+ return { sql, params, all: u.all, op: u.op ?? null };
2544
+ }),
1022
2545
  joins: this.#joins,
1023
- lockMode: this.#lockMode,
2546
+ lockMode: this.#lockMode
2547
+ ? this.#lockModifier
2548
+ ? `${this.#lockMode} ${this.#lockModifier}`
2549
+ : this.#lockMode
2550
+ : null,
1024
2551
  };
1025
2552
  }
1026
2553
 
@@ -1056,7 +2583,11 @@ export class ModelQuery<T extends BaseEntity> {
1056
2583
 
1057
2584
  async #doExec(): Promise<T[]> {
1058
2585
  const { sql, params } = this.toSQL();
1059
- 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
+ );
1060
2591
  // Peel withCount / withAggregate alias columns off the raw row into $extras
1061
2592
  // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
1062
2593
  const extraKeys = this.#selectSubqueries.map((s) => s.alias);
@@ -1070,6 +2601,8 @@ export class ModelQuery<T extends BaseEntity> {
1070
2601
  }
1071
2602
  const entity = this.#hydrateFn(row);
1072
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 };
1073
2606
  return entity;
1074
2607
  });
1075
2608
 
@@ -1081,6 +2614,27 @@ export class ModelQuery<T extends BaseEntity> {
1081
2614
  return entities;
1082
2615
  }
1083
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
+
1084
2638
  /** Resolve preloaded relations via batched subqueries (no N+1). */
1085
2639
  async #resolvePreloads(entities: T[]): Promise<void> {
1086
2640
  if (!this.#entityClass) return;
@@ -1109,8 +2663,11 @@ export class ModelQuery<T extends BaseEntity> {
1109
2663
  relationName: string,
1110
2664
  ): PreloadContext | null {
1111
2665
  const relatedClass = relation.target() as new () => BaseEntity;
1112
- const relatedMeta = getEntityMetadata(relatedClass);
1113
- 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);
1114
2671
 
1115
2672
  // Resolve row keys against declared column metadata, NOT `in entity` —
1116
2673
  // entities using Adonis' `declare field: T` pattern have no own-properties
@@ -1118,24 +2675,79 @@ export class ModelQuery<T extends BaseEntity> {
1118
2675
  // every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
1119
2676
  const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
1120
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);
1121
2691
  for (const col of getColumnMetadata(relatedClass)) {
2692
+ const db = col.columnName ?? camelToSnake(col.propertyKey);
1122
2693
  validColumns.add(col.propertyKey);
1123
- 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;
1124
2700
  }
1125
2701
  validColumns.add(relatedPkName);
1126
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
+ };
1127
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;
1128
2728
  const hydrate = (row: Record<string, unknown>): BaseEntity => {
1129
2729
  const entity = new relatedClass();
1130
2730
  for (const [key, value] of Object.entries(row)) {
1131
2731
  const camelKey = snakeToCamel(key);
1132
- const targetKey = validColumns.has(camelKey)
1133
- ? camelKey
1134
- : validColumns.has(key)
1135
- ? key
1136
- : null;
1137
- 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));
1138
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
+ });
1139
2751
  return entity;
1140
2752
  };
1141
2753
 
@@ -1144,7 +2756,9 @@ export class ModelQuery<T extends BaseEntity> {
1144
2756
  relationName,
1145
2757
  relatedClass,
1146
2758
  relatedTable: relatedMeta.tableName,
1147
- 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,
1148
2762
  hydrate,
1149
2763
  runInQuery: (table, column, values) =>
1150
2764
  this.#runInQuery(table, column, values),
@@ -1206,11 +2820,7 @@ export class ModelQuery<T extends BaseEntity> {
1206
2820
  );
1207
2821
  }
1208
2822
  const throughClass = relation.through() as new () => BaseEntity;
1209
- const throughMeta = getEntityMetadata(throughClass);
1210
- if (!throughMeta)
1211
- throw new Error(
1212
- `Entity metadata missing on through class ${throughClass.name}`,
1213
- );
2823
+ const throughMeta = ensureEntityMetadata(throughClass);
1214
2824
  const throughTable = throughMeta.tableName;
1215
2825
  const throughPk = getPrimaryKey(throughClass) ?? "id";
1216
2826
  const parentLocal =
@@ -1219,7 +2829,13 @@ export class ModelQuery<T extends BaseEntity> {
1219
2829
  relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1220
2830
  const secondKey =
1221
2831
  relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1222
- 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
+ );
1223
2839
 
1224
2840
  const parentIds = entities
1225
2841
  .map((e) => e[parentLocal])
@@ -1388,13 +3004,72 @@ export class ModelQuery<T extends BaseEntity> {
1388
3004
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1389
3005
  const otherKey =
1390
3006
  pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
1391
- 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";
1392
3012
 
1393
3013
  const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1394
3014
  if (ids.length === 0) return [];
1395
3015
 
1396
- // Step 1 — pivot table: find (foreignKey otherKey) pairs
1397
- const pivotRows = await ctx.runInQuery(pivot.pivotTable, foreignKey, ids);
3016
+ // Extract PIVOT-table constraints (wherePivot / wherePivotIn) from the
3017
+ // preload callback by replaying it on a throwaway builder. The callback
3018
+ // also runs (again) inside runRelationQuery against the related table; both
3019
+ // runs are pure builder mutations, and pivot constraints are inert there.
3020
+ const pivotWheres: Array<{
3021
+ column: string;
3022
+ operator: string;
3023
+ value: unknown;
3024
+ }> = [];
3025
+ if (ctx.nestedCallback) {
3026
+ const scratch = new ModelQuery<BaseEntity>(
3027
+ ctx.relatedTable,
3028
+ this.#db,
3029
+ (r) => r as BaseEntity,
3030
+ ctx.relatedClass,
3031
+ buildColumnResolver(ctx.relatedClass),
3032
+ false,
3033
+ this.#dialect,
3034
+ buildValuePreparer(ctx.relatedClass),
3035
+ );
3036
+ ctx.nestedCallback(scratch);
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
+ }
3064
+ }
3065
+
3066
+ // Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
3067
+ const pivotRows = await this.#runInQuery(
3068
+ pivot.pivotTable,
3069
+ foreignKey,
3070
+ ids,
3071
+ pivotWheres,
3072
+ );
1398
3073
  if (pivotRows.length === 0) {
1399
3074
  for (const entity of entities) entity.setProp(relationName, []);
1400
3075
  return [];
@@ -1405,18 +3080,47 @@ export class ModelQuery<T extends BaseEntity> {
1405
3080
 
1406
3081
  // Step 2 — load all related entities in one query
1407
3082
  const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
3083
+ const pivotCols = pivot.pivotColumns ?? [];
3084
+ const pivotAdapters = pivot.pivotColumnAdapters ?? {};
3085
+ // When pivot extras are projected, each (parent, related) edge gets its OWN
3086
+ // hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
3087
+ // across parents (Lucid gives distinct pivot-bearing instances). Otherwise a
3088
+ // single shared instance per related PK is reused (cheaper, current behaviour).
3089
+ const projectPivot = pivotCols.length > 0;
3090
+ const rawByRelatedPk = new Map<unknown, Record<string, unknown>>();
1408
3091
  const byRelatedPk = new Map<unknown, BaseEntity>();
1409
3092
  const allRelated: BaseEntity[] = [];
1410
3093
  for (const row of relRows) {
1411
- const hydrated = ctx.hydrate(row);
1412
- byRelatedPk.set(row[ctx.relatedPk], hydrated);
1413
- allRelated.push(hydrated);
3094
+ rawByRelatedPk.set(row[ctx.relatedPk], row);
3095
+ if (!projectPivot) {
3096
+ const hydrated = ctx.hydrate(row);
3097
+ byRelatedPk.set(row[ctx.relatedPk], hydrated);
3098
+ allRelated.push(hydrated);
3099
+ }
1414
3100
  }
1415
3101
 
1416
- // Step 3 — group via the pivot
3102
+ // Step 3 — group via the pivot, projecting declared pivotColumns into
3103
+ // `$extras.pivot_<col>` (running each column's `consume` adapter if any).
1417
3104
  const grouped = new Map<unknown, BaseEntity[]>();
1418
3105
  for (const pivotRow of pivotRows) {
1419
- const related = byRelatedPk.get(pivotRow[otherKey]);
3106
+ let related: BaseEntity | undefined;
3107
+ if (projectPivot) {
3108
+ const raw = rawByRelatedPk.get(pivotRow[otherKey]);
3109
+ if (!raw) continue;
3110
+ related = ctx.hydrate(raw);
3111
+ for (const col of pivotCols) {
3112
+ const rawVal = pivotRow[col];
3113
+ const adapter = pivotAdapters[col];
3114
+ related.setExtra(
3115
+ `pivot_${col}`,
3116
+ // Adonis Lucid signature: (value, attribute, model).
3117
+ adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal,
3118
+ );
3119
+ }
3120
+ allRelated.push(related);
3121
+ } else {
3122
+ related = byRelatedPk.get(pivotRow[otherKey]);
3123
+ }
1420
3124
  if (!related) continue;
1421
3125
  const parentId = pivotRow[foreignKey];
1422
3126
  if (!grouped.has(parentId)) grouped.set(parentId, []);
@@ -1440,6 +3144,10 @@ export class ModelQuery<T extends BaseEntity> {
1440
3144
  this.#db,
1441
3145
  (r) => ctx.hydrate(r),
1442
3146
  ctx.relatedClass,
3147
+ buildColumnResolver(ctx.relatedClass),
3148
+ hasSoftDeletes(ctx.relatedClass),
3149
+ this.#dialect,
3150
+ buildValuePreparer(ctx.relatedClass),
1443
3151
  );
1444
3152
  ctx.nestedCallback(sub);
1445
3153
  if (sub.#preloads.size > 0) {
@@ -1452,13 +3160,40 @@ export class ModelQuery<T extends BaseEntity> {
1452
3160
  table: string,
1453
3161
  column: string,
1454
3162
  values: unknown[],
3163
+ extraWheres: ReadonlyArray<{
3164
+ column: string;
3165
+ operator: string;
3166
+ value: unknown;
3167
+ type?: "and" | "or";
3168
+ }> = [],
1455
3169
  ): Promise<Record<string, unknown>[]> {
3170
+ const wheres: Array<Record<string, unknown>> = [
3171
+ { column, operator: "IN", value: values, type: "and" },
3172
+ ];
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) {
3180
+ wheres.push({
3181
+ kind: "group",
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
+ })),
3189
+ });
3190
+ }
1456
3191
  const spec = {
1457
3192
  kind: "select",
1458
3193
  table,
1459
3194
  select: ["*"],
1460
3195
  selectSubqueries: [],
1461
- wheres: [{ column, operator: "IN", value: values, type: "and" }],
3196
+ wheres,
1462
3197
  orderBy: [],
1463
3198
  groupBy: [],
1464
3199
  having: [],
@@ -1501,7 +3236,10 @@ export class ModelQuery<T extends BaseEntity> {
1501
3236
  this.#db,
1502
3237
  (row) => row as BaseEntity,
1503
3238
  relatedClass,
1504
- (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),
1505
3243
  // Propagate the RELATED entity's soft-delete flag — hardcoding
1506
3244
  // false here meant `preload('posts')` returned soft-deleted
1507
3245
  // posts even when Post is @SoftDeletes (a data leak). The
@@ -1511,6 +3249,7 @@ export class ModelQuery<T extends BaseEntity> {
1511
3249
  // preload callback.)
1512
3250
  hasSoftDeletes(relatedClass),
1513
3251
  this.#dialect,
3252
+ buildValuePreparer(relatedClass),
1514
3253
  );
1515
3254
  sub.whereIn(column, values);
1516
3255
  if (relation.onQuery) relation.onQuery(sub as unknown);
@@ -1559,44 +3298,67 @@ export class ModelQuery<T extends BaseEntity> {
1559
3298
  );
1560
3299
  }
1561
3300
  const relatedClass = relation.target() as new () => BaseEntity;
1562
- const relatedMeta = getEntityMetadata(relatedClass);
1563
- if (!relatedMeta) {
1564
- throw new Error(
1565
- `Entity metadata missing on related class ${relatedClass.name}`,
1566
- );
1567
- }
3301
+ const relatedMeta = ensureEntityMetadata(relatedClass);
1568
3302
  const relatedTable = relatedMeta.tableName;
1569
3303
  const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
1570
3304
  const parentTable = this.#tableName;
1571
- const q =
1572
- this.#dialect === "mysql"
1573
- ? (name: string) => `\`${name}\``
1574
- : (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(".");
1575
3322
 
1576
3323
  const sub = new ModelQuery<BaseEntity>(
1577
3324
  relatedTable,
1578
3325
  this.#db,
1579
3326
  (row) => row as BaseEntity,
1580
3327
  relatedClass,
1581
- (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),
1582
3331
  false,
1583
3332
  this.#dialect,
3333
+ buildValuePreparer(relatedClass),
1584
3334
  );
1585
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);
1586
3341
  switch (relation.type) {
1587
3342
  case "hasOne":
1588
3343
  case "hasMany": {
1589
- const fk = `${camelToSnake(this.#entityClass.name)}_id`;
3344
+ // Honour custom foreignKey/localKey exactly like the eager loader —
3345
+ // hard-coding them here produced silently-wrong whereHas/withCount SQL.
3346
+ const fk =
3347
+ relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
3348
+ const localKey = resolveParent(relation.localKey ?? parentPk);
1590
3349
  sub.#pushWhereRaw(
1591
- `${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(parentPk)}`,
3350
+ `${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
1592
3351
  );
1593
3352
  break;
1594
3353
  }
1595
3354
  case "belongsTo": {
1596
- const fk = `${camelToSnake(relatedClass.name)}_id`;
1597
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
3355
+ const fk =
3356
+ relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
3357
+ const ownerKey = buildColumnResolver(relatedClass)(
3358
+ relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
3359
+ );
1598
3360
  sub.#pushWhereRaw(
1599
- `${q(relatedTable)}.${q(relatedPk)} = ${q(parentTable)}.${q(fk)}`,
3361
+ `${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`,
1600
3362
  );
1601
3363
  break;
1602
3364
  }
@@ -1613,24 +3375,48 @@ export class ModelQuery<T extends BaseEntity> {
1613
3375
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1614
3376
  const otherKey =
1615
3377
  pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1616
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
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);
1617
3384
  sub.#pushWhereRaw(
1618
- `${q(relatedTable)}.${q(relatedPk)} IN ` +
1619
- `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1620
- `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(parentPk)})`,
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)})`,
1621
3388
  );
1622
3389
  break;
1623
3390
  }
1624
- default:
1625
- // hasOneThrough / hasManyThrough build a 2-hop correlated subquery,
1626
- // which isn't implemented here. Fail loud falling through would
1627
- // leave `sub` WITHOUT a join predicate, so whereHas/withCount would
1628
- // silently match/count EVERY related row.
1629
- throw new Error(
1630
- `whereHas/withCount on a '${relation.type}' relation ` +
1631
- `(${this.#entityClass.name}.${relationName}) is not supported yet. ` +
1632
- `Use a direct hasMany/belongsTo/manyToMany relation, or filter via a sub-query.`,
3391
+ case "hasOneThrough":
3392
+ case "hasManyThrough": {
3393
+ // Two-hop correlated EXISTS: parent through related. Mirrors the
3394
+ // eager loader's key resolution (`#resolveThrough`) exactly so
3395
+ // whereHas/withCount agree with what preload() would return.
3396
+ if (!relation.through) {
3397
+ throw new Error(
3398
+ `@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`,
3399
+ );
3400
+ }
3401
+ const throughClass = relation.through() as new () => BaseEntity;
3402
+ const throughMeta = ensureEntityMetadata(throughClass);
3403
+ const throughTable = throughMeta.tableName;
3404
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
3405
+ const parentLocal = resolveParent(relation.localKey ?? parentPk);
3406
+ const firstKey =
3407
+ relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
3408
+ const secondKey =
3409
+ relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
3410
+ const secondLocal = buildColumnResolver(throughClass)(
3411
+ relation.secondLocalKey ?? throughPk,
1633
3412
  );
3413
+ sub.#pushWhereRaw(
3414
+ `${qTable(relatedTable)}.${q(secondKey)} IN ` +
3415
+ `(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
3416
+ `WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`,
3417
+ );
3418
+ break;
3419
+ }
1634
3420
  }
1635
3421
  return sub;
1636
3422
  }
@@ -1681,8 +3467,8 @@ export class ModelQuery<T extends BaseEntity> {
1681
3467
  }
1682
3468
 
1683
3469
  crossJoin(table: string): this {
1684
- const tq = this.#quote(table);
1685
- this.#joins.push(`CROSS JOIN ${tq}`);
3470
+ const tq = this.#quoteCol(table);
3471
+ this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
1686
3472
  return this;
1687
3473
  }
1688
3474
 
@@ -1702,14 +3488,14 @@ export class ModelQuery<T extends BaseEntity> {
1702
3488
  *
1703
3489
  * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
1704
3490
  */
1705
- joinRaw(fragment: string): this {
3491
+ joinRaw(fragment: string, bindings: readonly unknown[] = []): this {
1706
3492
  if (isAtlasStrictMode() && !isInternalBypass()) {
1707
3493
  throw new Error(
1708
3494
  "joinRaw() is disabled in Atlas strict mode. " +
1709
3495
  "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
1710
3496
  );
1711
3497
  }
1712
- this.#joins.push(fragment);
3498
+ this.#joins.push({ sql: fragment, params: [...bindings] });
1713
3499
  return this;
1714
3500
  }
1715
3501
 
@@ -1735,11 +3521,47 @@ export class ModelQuery<T extends BaseEntity> {
1735
3521
  return this;
1736
3522
  }
1737
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
+
1738
3542
  /** `SELECT COUNT(DISTINCT col)`. */
1739
3543
  async countDistinct(column: string): Promise<number> {
1740
3544
  return Number(
1741
- (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))})`,
1742
3563
  );
3564
+ return v === null || v === undefined ? null : Number(v);
1743
3565
  }
1744
3566
 
1745
3567
  /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
@@ -1748,7 +3570,11 @@ export class ModelQuery<T extends BaseEntity> {
1748
3570
  clone.#select = ["1"];
1749
3571
  clone.#limit = 1;
1750
3572
  const { sql, params } = clone.toSQL();
1751
- 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
+ );
1752
3578
  return rows.length > 0;
1753
3579
  }
1754
3580
 
@@ -1846,14 +3672,32 @@ export class ModelQuery<T extends BaseEntity> {
1846
3672
  // beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
1847
3673
  // tenant scope) propagates into both the COUNT and the data fetch.
1848
3674
  await fireHooks(this.#entityClass, "beforePaginate", this);
1849
- // Parallel COUNT(*) + data fetch
3675
+ // COUNT(*) + data fetch
1850
3676
  const countQ = this.clone();
1851
- countQ.#select = ["COUNT(*) AS count"];
1852
3677
  countQ.#limit = undefined;
1853
3678
  countQ.#offset = undefined;
1854
3679
  countQ.#orderBys = [];
1855
- const { sql: cSql, params: cParams } = countQ.toSQL();
1856
- 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
+ );
1857
3701
  const total = Number(cRows[0]?.count ?? 0);
1858
3702
 
1859
3703
  const dataQ = this.clone();
@@ -1863,7 +3707,14 @@ export class ModelQuery<T extends BaseEntity> {
1863
3707
  // top of the paginate hooks — paginate is its own terminal.
1864
3708
  const items = await dataQ.#doExec();
1865
3709
  await fireHooks(this.#entityClass, "afterPaginate", items);
1866
- 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
+ );
1867
3718
  }
1868
3719
 
1869
3720
  /**
@@ -1884,9 +3735,13 @@ export class ModelQuery<T extends BaseEntity> {
1884
3735
  limit: number;
1885
3736
  orderBy: string | string[];
1886
3737
  }): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
1887
- const cols = (
1888
- Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]
1889
- ).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));
1890
3745
  if (cols.length === 0)
1891
3746
  throw new Error("cursorPaginate requires at least one orderBy column");
1892
3747
  const lim = Math.max(1, Math.floor(opts.limit));
@@ -1935,9 +3790,9 @@ export class ModelQuery<T extends BaseEntity> {
1935
3790
  const last = items[items.length - 1] as Record<string, unknown> | undefined;
1936
3791
  const nextCursor =
1937
3792
  hasMore && last
1938
- ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString(
1939
- "base64",
1940
- )
3793
+ ? Buffer.from(
3794
+ JSON.stringify({ v: props.map((p) => last[p]) }),
3795
+ ).toString("base64")
1941
3796
  : null;
1942
3797
  return { items, nextCursor, hasMore };
1943
3798
  }
@@ -1958,6 +3813,22 @@ export class ModelQuery<T extends BaseEntity> {
1958
3813
  return this;
1959
3814
  }
1960
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
+
1961
3832
  /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
1962
3833
  toQuery(): string {
1963
3834
  const { sql, params } = this.toSQL();
@@ -1978,6 +3849,8 @@ export class ModelQuery<T extends BaseEntity> {
1978
3849
  this.#resolveColumn,
1979
3850
  this.#softDeletes,
1980
3851
  this.#dialect,
3852
+ this.#prepareValue,
3853
+ this.#onDomainEvents,
1981
3854
  );
1982
3855
  c.#softScope = this.#softScope;
1983
3856
  c.#wheres = structuredCloneSafe(this.#wheres);
@@ -1987,9 +3860,29 @@ export class ModelQuery<T extends BaseEntity> {
1987
3860
  c.#offset = this.#offset;
1988
3861
  c.#preloads = new Map(this.#preloads);
1989
3862
  c.#selectSubqueries = structuredClone(this.#selectSubqueries);
1990
- c.#joins = [...this.#joins];
3863
+ c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
1991
3864
  c.#lockMode = this.#lockMode;
3865
+ c.#lockModifier = this.#lockModifier;
3866
+ c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
1992
3867
  c.#distinct = this.#distinct;
3868
+ c.#distinctOn = [...this.#distinctOn];
3869
+ c.#groupBy = [...this.#groupBy];
3870
+ c.#having = structuredCloneSafe(this.#having);
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
+ }));
3877
+ c.#unions = this.#unions.map((u) => ({
3878
+ query: u.query.clone(),
3879
+ all: u.all,
3880
+ op: u.op,
3881
+ }));
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;
1993
3886
  c.#debugFlag = this.#debugFlag;
1994
3887
  return c;
1995
3888
  }
@@ -2004,15 +3897,18 @@ export class ModelQuery<T extends BaseEntity> {
2004
3897
  if (!patch || Object.keys(patch).length === 0) {
2005
3898
  throw new Error("update() requires a non-empty payload");
2006
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.
2007
3902
  const setPairs = Object.entries(patch).map(
2008
- ([k, v]) => [this.#resolveColumn(k), v] as [string, unknown],
3903
+ ([k, v]) =>
3904
+ [this.#resolveColumn(k), this.#prepareValue(k, v)] as [string, unknown],
2009
3905
  );
2010
3906
  const spec = {
2011
3907
  kind: "update",
2012
3908
  table: this.#tableName,
2013
3909
  set: setPairs,
2014
3910
  wheres: this.#wheresForDml(),
2015
- returning: returning ?? [],
3911
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2016
3912
  };
2017
3913
  const compiled = compileStatementNative(spec, this.#dialect);
2018
3914
  if (returning && returning.length > 0) {
@@ -2025,25 +3921,67 @@ export class ModelQuery<T extends BaseEntity> {
2025
3921
  return r.rowsAffected ?? 0;
2026
3922
  }
2027
3923
 
2028
- /** 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
+ */
2029
3931
  async delete(
2030
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[],
2031
3950
  ): Promise<number | Record<string, unknown>[]> {
2032
3951
  const spec = {
2033
3952
  kind: "delete",
2034
3953
  table: this.#tableName,
2035
3954
  wheres: this.#wheresForDml(),
2036
- returning: returning ?? [],
3955
+ returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
2037
3956
  };
2038
- const compiled = compileStatementNative(spec, this.#dialect);
2039
- if (returning && returning.length > 0) {
2040
- return this.#db.query<Record<string, unknown>>(
2041
- compiled.statements[0],
2042
- compiled.params,
2043
- );
2044
- }
2045
- const r = await this.#db.execute(compiled.statements[0], compiled.params);
2046
- 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);
2047
3985
  }
2048
3986
 
2049
3987
  // === Story 30.3 — increment / decrement already implemented? check ================================
@@ -2090,6 +4028,56 @@ export class ModelQuery<T extends BaseEntity> {
2090
4028
  return this;
2091
4029
  }
2092
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
+
2093
4081
  // === Private helpers ==============================================================================
2094
4082
 
2095
4083
  #quote(name: string): string {
@@ -2098,11 +4086,20 @@ export class ModelQuery<T extends BaseEntity> {
2098
4086
 
2099
4087
  /** Quote a `table.column` reference on both sides of the dot. */
2100
4088
  #quoteCol(ref: string): string {
2101
- if (ref.includes(".")) {
2102
- const [t, c] = ref.split(".", 2);
2103
- 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
+ );
2104
4098
  }
2105
- return this.#quote(ref);
4099
+ return ref
4100
+ .split(".")
4101
+ .map((seg) => this.#quote(seg))
4102
+ .join(".");
2106
4103
  }
2107
4104
 
2108
4105
  #pushJoin(
@@ -2111,7 +4108,7 @@ export class ModelQuery<T extends BaseEntity> {
2111
4108
  leftOrBuild: string | ((j: JoinBuilder) => void),
2112
4109
  right?: string,
2113
4110
  ): this {
2114
- const tq = this.#quote(table);
4111
+ const tq = this.#quoteCol(table);
2115
4112
  if (typeof leftOrBuild === "function") {
2116
4113
  const jb: JoinBuilder = {
2117
4114
  parts: [],
@@ -2123,28 +4120,61 @@ export class ModelQuery<T extends BaseEntity> {
2123
4120
  this.parts.push({ kind: "and", left: l, right: r });
2124
4121
  return this;
2125
4122
  },
2126
- andOnVal(l: string, _v: unknown) {
2127
- 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 } });
2128
4137
  return this;
2129
4138
  },
2130
4139
  };
2131
4140
  leftOrBuild(jb);
4141
+ // Collect the bound values in placeholder order as the fragment is built.
4142
+ const params: unknown[] = [];
2132
4143
  const on = jb.parts
2133
4144
  .map((p, i) => {
2134
4145
  const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
2135
- 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 ?? "")}`;
2136
4165
  })
2137
4166
  .join(" ");
2138
- this.#joins.push(`${kind} JOIN ${tq} ${on}`);
4167
+ this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
2139
4168
  return this;
2140
4169
  }
2141
4170
  if (right === undefined)
2142
4171
  throw new Error(
2143
4172
  "join() with string form requires both left and right operands",
2144
4173
  );
2145
- this.#joins.push(
2146
- `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
2147
- );
4174
+ this.#joins.push({
4175
+ sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
4176
+ params: [],
4177
+ });
2148
4178
  return this;
2149
4179
  }
2150
4180
 
@@ -2190,7 +4220,8 @@ export class ModelQuery<T extends BaseEntity> {
2190
4220
  * still rejected because the DML compiler's WHERE lowering does not yet
2191
4221
  * handle nested sub-queries or correlated EXISTS.
2192
4222
  */
2193
- #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>> {
2194
4225
  const out: Array<Record<string, unknown>> = [];
2195
4226
  for (const w of this.#wheres) {
2196
4227
  if ("kind" in w) {
@@ -2218,6 +4249,50 @@ export class ModelQuery<T extends BaseEntity> {
2218
4249
  return out;
2219
4250
  }
2220
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
+
2221
4296
  /**
2222
4297
  * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
2223
4298
  *
@@ -2255,6 +4330,7 @@ export class ModelQuery<T extends BaseEntity> {
2255
4330
  this.#resolveColumn,
2256
4331
  false,
2257
4332
  this.#dialect,
4333
+ this.#prepareValue,
2258
4334
  );
2259
4335
  callback(scratch);
2260
4336
  return { type, kind: "group", conditions: scratch.#wheres };
@@ -2308,19 +4384,30 @@ export class ModelQuery<T extends BaseEntity> {
2308
4384
  type,
2309
4385
  column: resolved,
2310
4386
  operator: "=",
2311
- value: operatorOrValue,
4387
+ value: this.#prep(column, operatorOrValue),
2312
4388
  });
2313
4389
  } else {
2314
4390
  this.#wheres.push({
2315
4391
  type,
2316
4392
  column: resolved,
2317
4393
  operator: operatorOrValue as string,
2318
- value,
4394
+ value: this.#prep(column, value),
2319
4395
  });
2320
4396
  }
2321
4397
  return this;
2322
4398
  }
2323
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
+
2324
4411
  /**
2325
4412
  * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
2326
4413
  * Used by the nested-preload machinery to recurse without re-running the root select.