@c9up/atlas 0.1.3

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/db.darwin-arm64.node +0 -0
  4. package/db.darwin-x64.node +0 -0
  5. package/db.linux-arm64-gnu.node +0 -0
  6. package/db.linux-x64-gnu.node +0 -0
  7. package/db.win32-x64-msvc.node +0 -0
  8. package/index.darwin-arm64.node +0 -0
  9. package/index.darwin-x64.node +0 -0
  10. package/index.linux-arm64-gnu.node +0 -0
  11. package/index.linux-x64-gnu.node +0 -0
  12. package/index.win32-x64-msvc.node +0 -0
  13. package/package.json +69 -0
  14. package/scripts/copy-napi.mjs +86 -0
  15. package/src/AtlasProvider.ts +297 -0
  16. package/src/BaseEntity.ts +585 -0
  17. package/src/BaseRepository.ts +1694 -0
  18. package/src/ModelQuery.ts +2293 -0
  19. package/src/Transaction.ts +83 -0
  20. package/src/adapters/NapiDbAdapter.ts +178 -0
  21. package/src/config.ts +7 -0
  22. package/src/configure.ts +37 -0
  23. package/src/decorators/entity.ts +532 -0
  24. package/src/decorators/hooks.ts +169 -0
  25. package/src/decorators/scope.ts +44 -0
  26. package/src/errors.ts +111 -0
  27. package/src/index.ts +114 -0
  28. package/src/naming/NamingStrategy.ts +106 -0
  29. package/src/query/QueryBuilder.ts +422 -0
  30. package/src/query/native.ts +74 -0
  31. package/src/schema/Migration.ts +81 -0
  32. package/src/schema/MigrationRunner.ts +532 -0
  33. package/src/schema/Schema.ts +78 -0
  34. package/src/schema/SchemaBuilder.ts +14 -0
  35. package/src/schema/Seeder.ts +132 -0
  36. package/src/schema/TableBuilder.ts +238 -0
  37. package/src/schema/types.ts +51 -0
  38. package/src/services/db.ts +45 -0
  39. package/src/testing/DatabaseCleanup.ts +49 -0
  40. package/src/testing/Factory.ts +164 -0
  41. package/src/testing/TestDatabase.ts +81 -0
  42. package/src/testing/index.ts +3 -0
  43. package/src/utils/casing.ts +11 -0
  44. package/src/utils/dialectFromUrl.ts +16 -0
  45. package/src/utils/identifier.ts +35 -0
  46. package/src/utils/safePath.ts +59 -0
  47. package/src/utils/transactionBrand.ts +10 -0
@@ -0,0 +1,2293 @@
1
+ /**
2
+ * ModelQuery — executable query builder for repositories.
3
+ *
4
+ * Like AdonisJS Lucid Model.query():
5
+ * repo.query().where('status', 'active').orderBy('created_at', 'desc').limit(10).exec()
6
+ *
7
+ * Builds SQL fluently and executes against the database connection.
8
+ */
9
+
10
+ import type { BaseEntity } from "./BaseEntity.js";
11
+ import type { DatabaseConnection } from "./BaseRepository.js";
12
+ import {
13
+ getEntityMetadata,
14
+ getPrimaryKey,
15
+ getRelationMetadata,
16
+ hasSoftDeletes,
17
+ type RelationMetadata,
18
+ } from "./decorators/entity.js";
19
+ import {
20
+ type AtlasDialect,
21
+ compileStatementNative,
22
+ getAtlasDialect,
23
+ } from "./query/native.js";
24
+ import { camelToSnake, snakeToCamel } from "./utils/casing.js";
25
+
26
+ /**
27
+ * Comparison operators allowed in `whereExpr`'s raw 4-arg form (where
28
+ * `op` is interpolated into SQL rather than parameterized). Kept tight
29
+ * to operators that take a single bound `?` value — IN / IS NULL etc.
30
+ * have no place in this helper.
31
+ */
32
+ const WHEREEXPR_OPERATORS = new Set<string>([
33
+ "=",
34
+ "!=",
35
+ "<>",
36
+ ">",
37
+ ">=",
38
+ "<",
39
+ "<=",
40
+ "LIKE",
41
+ "NOT LIKE",
42
+ ]);
43
+
44
+ /** True when every `(` in `s` has a matching `)` and none closes early. */
45
+ function hasBalancedParens(s: string): boolean {
46
+ let depth = 0;
47
+ for (const ch of s) {
48
+ if (ch === "(") depth++;
49
+ else if (ch === ")") {
50
+ depth--;
51
+ if (depth < 0) return false;
52
+ }
53
+ }
54
+ return depth === 0;
55
+ }
56
+
57
+ type PreloadCallback = (query: ModelQuery<BaseEntity>) => void;
58
+
59
+ type ColumnResolver = (column: string) => string;
60
+
61
+ /** Per-preload-relation locals shared by the resolver helpers. Built once per relation, then passed by ref. */
62
+ interface PreloadContext {
63
+ relation: RelationMetadata;
64
+ relationName: string;
65
+ relatedClass: new () => BaseEntity;
66
+ relatedTable: string;
67
+ relatedPk: string;
68
+ hydrate: (row: Record<string, unknown>) => BaseEntity;
69
+ runInQuery: (
70
+ table: string,
71
+ column: string,
72
+ values: unknown[],
73
+ ) => Promise<Record<string, unknown>[]>;
74
+ /**
75
+ * Relation-aware query — applies `relation.onQuery` + the user preload
76
+ * callback before executing. Use for the primary related-row fetch; use
77
+ * `runInQuery` for auxiliary fetches (e.g. the intermediate hop of a
78
+ * through relation or the pivot lookup of a m2m) where filters don't apply.
79
+ */
80
+ runRelationQuery: (
81
+ column: string,
82
+ values: unknown[],
83
+ ) => Promise<Record<string, unknown>[]>;
84
+ nestedCallback: PreloadCallback | undefined;
85
+ }
86
+
87
+ type SoftDeleteScope = "default" | "with-trashed" | "only-trashed";
88
+
89
+ /** Set an empty relation value on every parent and return no related rows. */
90
+ function assignEmptyRelation(
91
+ entities: BaseEntity[],
92
+ relationName: string,
93
+ single: boolean,
94
+ ): BaseEntity[] {
95
+ for (const e of entities) e.setProp(relationName, single ? null : []);
96
+ return [];
97
+ }
98
+
99
+ /**
100
+ * Map each intermediate row's `secondLocal` key to its parent id (`firstKey`).
101
+ * Throws when two intermediate rows share a key but point at different parents
102
+ * — a non-unique `secondLocalKey` would otherwise silently drop data.
103
+ */
104
+ function buildThroughToParent(
105
+ throughRows: Record<string, unknown>[],
106
+ secondLocal: string,
107
+ firstKey: string,
108
+ err: {
109
+ relationName: string;
110
+ throughTable: string;
111
+ throughClass: string;
112
+ throughPk: string;
113
+ },
114
+ ): Map<unknown, unknown> {
115
+ const throughToParent = new Map<unknown, unknown>();
116
+ for (const row of throughRows) {
117
+ const key = row[secondLocal];
118
+ if (
119
+ throughToParent.has(key) &&
120
+ throughToParent.get(key) !== row[firstKey]
121
+ ) {
122
+ throw new Error(
123
+ `@HasManyThrough/@HasOneThrough '${err.relationName}': duplicate secondLocalKey='${String(key)}' ` +
124
+ `on ${err.throughTable} maps to multiple parents. Either set secondLocalKey to a unique column ` +
125
+ `(default: ${err.throughClass}.${err.throughPk}) or fix the underlying data.`,
126
+ );
127
+ }
128
+ throughToParent.set(key, row[firstKey]);
129
+ }
130
+ return throughToParent;
131
+ }
132
+
133
+ /** A standard column predicate (column OP value). */
134
+ interface StandardWhere {
135
+ type: "and" | "or";
136
+ column: string;
137
+ operator: string;
138
+ value: unknown;
139
+ }
140
+
141
+ /** A raw SQL fragment with `?` bindings — kind-tagged for the Rust compiler. */
142
+ interface RawWhere {
143
+ type: "and" | "or";
144
+ kind: "raw";
145
+ sql: string;
146
+ bindings: unknown[];
147
+ }
148
+
149
+ /** An EXISTS / NOT EXISTS correlated subquery — used by whereHas / doesntHave. */
150
+ interface ExistsWhere {
151
+ type: "and" | "or";
152
+ kind: "exists";
153
+ negated: boolean;
154
+ subquery: SelectSpec;
155
+ }
156
+
157
+ /** Parenthesised group of WHERE conditions — built via `where(cb)`. */
158
+ interface GroupWhere {
159
+ type: "and" | "or";
160
+ kind: "group";
161
+ conditions: WhereClause[];
162
+ }
163
+
164
+ /** `col IN (SELECT ...)` / `col NOT IN (SELECT ...)` — built via `whereIn(col, subQ)`. */
165
+ interface InSubWhere {
166
+ type: "and" | "or";
167
+ kind: "inSub";
168
+ column: string;
169
+ negated: boolean;
170
+ subquery: SelectSpec;
171
+ }
172
+
173
+ /** Shape of the spec object sent to the Rust compiler. Shared by root + sub queries. */
174
+ interface HavingClause {
175
+ column: string;
176
+ operator: string;
177
+ value: unknown;
178
+ type: "and" | "or";
179
+ }
180
+
181
+ interface SubqueryProjection {
182
+ alias: string;
183
+ subquery: SelectSpec;
184
+ }
185
+
186
+ interface SelectSpec {
187
+ kind: "select";
188
+ table: string;
189
+ select: string[];
190
+ selectSubqueries: SubqueryProjection[];
191
+ wheres: WhereClause[];
192
+ orderBy: Array<{ column: string; direction: "asc" | "desc" }>;
193
+ groupBy: string[];
194
+ having: HavingClause[];
195
+ limit: number | null;
196
+ offset: number | null;
197
+ distinct: boolean;
198
+ ctes: unknown[];
199
+ unions: unknown[];
200
+ joins: string[];
201
+ lockMode: "FOR UPDATE" | "FOR SHARE" | null;
202
+ }
203
+
204
+ type WhereClause =
205
+ | StandardWhere
206
+ | RawWhere
207
+ | ExistsWhere
208
+ | GroupWhere
209
+ | InSubWhere;
210
+
211
+ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
212
+
213
+ /**
214
+ * Process-wide strict mode flag. When enabled, `whereRaw()` and `joinRaw()`
215
+ * throw unconditionally — forcing every call site to use the typed
216
+ * `whereExpr()` / `joinOn()` / structured builder paths. Intended for prod
217
+ * hardening on apps that can't audit every call site manually.
218
+ *
219
+ * Enable via:
220
+ * - `setAtlasStrictMode(true)` at app bootstrap
221
+ * - `ATLAS_STRICT=1` environment variable (picked up lazily on first call)
222
+ *
223
+ * Framework-internal call sites that legitimately need raw SQL (relation
224
+ * resolvers, preload join predicates) bypass strict mode via the private
225
+ * `__internal: true` flag on the call — not exposed in the public types.
226
+ */
227
+ let atlasStrictMode: boolean | undefined;
228
+
229
+ /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
230
+ export function setAtlasStrictMode(enabled: boolean): void {
231
+ atlasStrictMode = enabled;
232
+ }
233
+
234
+ /** Current strict mode state — lazy env var read on first access. */
235
+ export function isAtlasStrictMode(): boolean {
236
+ if (atlasStrictMode === undefined) {
237
+ atlasStrictMode =
238
+ process.env.ATLAS_STRICT === "1" || process.env.ATLAS_STRICT === "true";
239
+ }
240
+ return atlasStrictMode;
241
+ }
242
+
243
+ /**
244
+ * Module-local escape hatch. Framework internal code (relation proxies,
245
+ * preload resolvers) sets this to `true` around a section where it legitimately
246
+ * needs to call whereRaw/joinRaw. Reset to `false` in a `finally` block.
247
+ * Not exposed from the package barrel — only accessible to files in this module.
248
+ */
249
+ let atlasInternalBypass = false;
250
+ export function runWithAtlasInternalBypass<T>(fn: () => T): T {
251
+ const prev = atlasInternalBypass;
252
+ atlasInternalBypass = true;
253
+ try {
254
+ return fn();
255
+ } finally {
256
+ atlasInternalBypass = prev;
257
+ }
258
+ }
259
+ function isInternalBypass(): boolean {
260
+ return atlasInternalBypass;
261
+ }
262
+
263
+ /** Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks. */
264
+ interface JoinBuilder {
265
+ parts: Array<{ kind: "and" | "or"; left: string; right: string }>;
266
+ on(left: string, right: string): JoinBuilder;
267
+ andOn(left: string, right: string): JoinBuilder;
268
+ andOnVal(left: string, value: unknown): JoinBuilder;
269
+ }
270
+
271
+ /** Offset-based paginator (Story 29.10). */
272
+ export class Paginator<T> {
273
+ readonly items: T[];
274
+ readonly meta: {
275
+ total: number;
276
+ perPage: number;
277
+ currentPage: number;
278
+ lastPage: number;
279
+ firstPage: number;
280
+ };
281
+ #baseUrl?: string;
282
+ #queryString: Record<string, unknown> = {};
283
+
284
+ constructor(
285
+ items: T[],
286
+ base: { total: number; perPage: number; currentPage: number },
287
+ ) {
288
+ this.items = items;
289
+ const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
290
+ this.meta = { ...base, lastPage, firstPage: 1 };
291
+ }
292
+
293
+ all(): T[] {
294
+ return this.items;
295
+ }
296
+
297
+ serialize(opts?: { fields?: string[] }): {
298
+ data: unknown[];
299
+ meta: Paginator<T>["meta"];
300
+ } {
301
+ const data = this.items.map((item) => {
302
+ if (!opts?.fields) return item;
303
+ const picked: Record<string, unknown> = {};
304
+ for (const f of opts.fields)
305
+ picked[f] = (item as Record<string, unknown>)[f];
306
+ return picked;
307
+ });
308
+ return { data, meta: this.meta };
309
+ }
310
+
311
+ baseUrl(url: string): this {
312
+ this.#baseUrl = url;
313
+ return this;
314
+ }
315
+ queryString(qs: Record<string, unknown>): this {
316
+ this.#queryString = qs;
317
+ return this;
318
+ }
319
+
320
+ toJSON(): {
321
+ data: unknown[];
322
+ meta: Paginator<T>["meta"] & Record<string, unknown>;
323
+ } {
324
+ const meta: Paginator<T>["meta"] & Record<string, unknown> = {
325
+ ...this.meta,
326
+ };
327
+ if (this.#baseUrl) {
328
+ const build = (page: number) => {
329
+ const params = new URLSearchParams();
330
+ for (const [k, v] of Object.entries(this.#queryString))
331
+ params.set(k, String(v));
332
+ params.set("page", String(page));
333
+ return `${this.#baseUrl}?${params.toString()}`;
334
+ };
335
+ meta.firstPageUrl = build(1);
336
+ meta.lastPageUrl = build(this.meta.lastPage);
337
+ if (this.meta.currentPage < this.meta.lastPage)
338
+ meta.nextPageUrl = build(this.meta.currentPage + 1);
339
+ if (this.meta.currentPage > 1)
340
+ meta.previousPageUrl = build(this.meta.currentPage - 1);
341
+ }
342
+ return { data: this.items as unknown[], meta };
343
+ }
344
+ }
345
+
346
+ /** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
347
+ function structuredCloneSafe<T>(value: T): T {
348
+ return structuredClone(value);
349
+ }
350
+
351
+ export class ModelQuery<T extends BaseEntity> {
352
+ #tableName: string;
353
+ #db: DatabaseConnection;
354
+ #hydrateFn: (row: Record<string, unknown>) => T;
355
+ #entityClass: new () => T;
356
+ #resolveColumn: ColumnResolver;
357
+ #softDeletes: boolean;
358
+ #softScope: SoftDeleteScope = "default";
359
+ #wheres: WhereClause[] = [];
360
+ #orderBys: Array<{ column: string; direction: "asc" | "desc" }> = [];
361
+ #select: string[] = ["*"];
362
+ #limit?: number;
363
+ #offset?: number;
364
+ #preloads = new Map<string, PreloadCallback | undefined>();
365
+ /** Correlated subquery projections (withCount / withAggregate). */
366
+ #selectSubqueries: SubqueryProjection[] = [];
367
+ /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
368
+ #subqueryAlias?: string;
369
+ /** Raw JOIN fragments — Story 29.4. */
370
+ #joins: string[] = [];
371
+ /** Row lock mode — Story 30.8. */
372
+ #lockMode: "FOR UPDATE" | "FOR SHARE" | null = null;
373
+ /** Per-query debug flag — Story 29.11. */
374
+ #debugFlag = false;
375
+ /** Distinct flag — Story 29.5. */
376
+ #distinct = false;
377
+ /** SQL dialect for compilation — inherited from the owning BaseRepository. */
378
+ #dialect: AtlasDialect;
379
+
380
+ constructor(
381
+ tableName: string,
382
+ db: DatabaseConnection,
383
+ hydrateFn: (row: Record<string, unknown>) => T,
384
+ entityClass: new () => T,
385
+ resolveColumn: ColumnResolver = (c) => c,
386
+ softDeletes = false,
387
+ dialect: AtlasDialect = getAtlasDialect(),
388
+ ) {
389
+ this.#tableName = tableName;
390
+ this.#db = db;
391
+ this.#hydrateFn = hydrateFn;
392
+ this.#entityClass = entityClass;
393
+ this.#resolveColumn = resolveColumn;
394
+ this.#softDeletes = softDeletes;
395
+ this.#dialect = dialect;
396
+ }
397
+
398
+ /** Include soft-deleted rows in the result (default behavior excludes them). */
399
+ withTrashed(): this {
400
+ this.#softScope = "with-trashed";
401
+ return this;
402
+ }
403
+
404
+ /** Return ONLY soft-deleted rows (deleted_at IS NOT NULL). */
405
+ onlyTrashed(): this {
406
+ this.#softScope = "only-trashed";
407
+ return this;
408
+ }
409
+
410
+ /**
411
+ * Eager-load a relation (AdonisJS-style).
412
+ * Relations are never loaded automatically — you must call .preload() explicitly.
413
+ *
414
+ * Usage:
415
+ * repo.query().preload('posts').exec()
416
+ * repo.query().preload('posts', q => q.where('published', true)).exec()
417
+ */
418
+ preload(relationName: string, callback?: PreloadCallback): this {
419
+ this.#preloads.set(relationName, callback);
420
+ return this;
421
+ }
422
+
423
+ /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
424
+ select(columns: string | string[]): this {
425
+ this.#select = Array.isArray(columns)
426
+ ? columns
427
+ : columns.split(",").map((c) => c.trim());
428
+ return this;
429
+ }
430
+
431
+ where(callback: WhereCallback): this;
432
+ where(column: string, value: unknown): this;
433
+ where(column: string, operator: string, value: unknown): this;
434
+ where(
435
+ columnOrCb: string | WhereCallback,
436
+ operatorOrValue?: unknown,
437
+ value?: unknown,
438
+ ): this {
439
+ if (typeof columnOrCb === "function") {
440
+ this.#wheres.push(this.#buildGroup("and", columnOrCb));
441
+ return this;
442
+ }
443
+ return this.#pushWhere("and", columnOrCb, operatorOrValue, value);
444
+ }
445
+
446
+ orWhere(callback: WhereCallback): this;
447
+ orWhere(column: string, value: unknown): this;
448
+ orWhere(column: string, operator: string, value: unknown): this;
449
+ orWhere(
450
+ columnOrCb: string | WhereCallback,
451
+ operatorOrValue?: unknown,
452
+ value?: unknown,
453
+ ): this {
454
+ if (typeof columnOrCb === "function") {
455
+ this.#wheres.push(this.#buildGroup("or", columnOrCb));
456
+ return this;
457
+ }
458
+ return this.#pushWhere("or", columnOrCb, operatorOrValue, value);
459
+ }
460
+
461
+ whereNull(column: string): this {
462
+ this.#wheres.push({
463
+ type: "and",
464
+ column: this.#resolveColumn(column),
465
+ operator: "IS NULL",
466
+ value: null,
467
+ });
468
+ return this;
469
+ }
470
+
471
+ whereNotNull(column: string): this {
472
+ this.#wheres.push({
473
+ type: "and",
474
+ column: this.#resolveColumn(column),
475
+ operator: "IS NOT NULL",
476
+ value: null,
477
+ });
478
+ return this;
479
+ }
480
+
481
+ /** `WHERE col != ?` — negation of `where`. */
482
+ whereNot(column: string, value: unknown): this {
483
+ this.#wheres.push({
484
+ type: "and",
485
+ column: this.#resolveColumn(column),
486
+ operator: "!=",
487
+ value,
488
+ });
489
+ return this;
490
+ }
491
+
492
+ /** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
493
+ whereIn(
494
+ column: string,
495
+ source: readonly unknown[] | ModelQuery<BaseEntity>,
496
+ ): this {
497
+ if (source instanceof ModelQuery) {
498
+ this.#wheres.push({
499
+ type: "and",
500
+ kind: "inSub",
501
+ negated: false,
502
+ column: this.#resolveColumn(column),
503
+ subquery: source.#buildSpec(),
504
+ });
505
+ return this;
506
+ }
507
+ this.#wheres.push({
508
+ type: "and",
509
+ column: this.#resolveColumn(column),
510
+ operator: "IN",
511
+ value: [...source],
512
+ });
513
+ return this;
514
+ }
515
+
516
+ /** `WHERE col NOT IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
517
+ whereNotIn(
518
+ column: string,
519
+ source: readonly unknown[] | ModelQuery<BaseEntity>,
520
+ ): this {
521
+ if (source instanceof ModelQuery) {
522
+ this.#wheres.push({
523
+ type: "and",
524
+ kind: "inSub",
525
+ negated: true,
526
+ column: this.#resolveColumn(column),
527
+ subquery: source.#buildSpec(),
528
+ });
529
+ return this;
530
+ }
531
+ this.#wheres.push({
532
+ type: "and",
533
+ column: this.#resolveColumn(column),
534
+ operator: "NOT IN",
535
+ value: [...source],
536
+ });
537
+ return this;
538
+ }
539
+
540
+ /** `WHERE col BETWEEN ? AND ?` — inclusive range. */
541
+ whereBetween(column: string, range: readonly [unknown, unknown]): this {
542
+ this.#wheres.push({
543
+ type: "and",
544
+ column: this.#resolveColumn(column),
545
+ operator: "BETWEEN",
546
+ value: [...range],
547
+ });
548
+ return this;
549
+ }
550
+
551
+ /** `WHERE col NOT BETWEEN ? AND ?` */
552
+ whereNotBetween(column: string, range: readonly [unknown, unknown]): this {
553
+ this.#wheres.push({
554
+ type: "and",
555
+ column: this.#resolveColumn(column),
556
+ operator: "NOT BETWEEN",
557
+ value: [...range],
558
+ });
559
+ return this;
560
+ }
561
+
562
+ /** `WHERE col LIKE ?` — case-sensitive pattern match. */
563
+ whereLike(column: string, pattern: string): this {
564
+ this.#wheres.push({
565
+ type: "and",
566
+ column: this.#resolveColumn(column),
567
+ operator: "LIKE",
568
+ value: pattern,
569
+ });
570
+ return this;
571
+ }
572
+
573
+ /**
574
+ * `WHERE col ILIKE ?` — case-insensitive pattern match. Uses native ILIKE
575
+ * on PostgreSQL; the Rust compiler rewrites it to `LOWER(col) LIKE LOWER(?)`
576
+ * on SQLite and MySQL at compile time.
577
+ */
578
+ whereILike(column: string, pattern: string): this {
579
+ this.#wheres.push({
580
+ type: "and",
581
+ column: this.#resolveColumn(column),
582
+ operator: "ILIKE",
583
+ value: pattern,
584
+ });
585
+ return this;
586
+ }
587
+
588
+ /**
589
+ * **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
590
+ * `?`-style bindings. The Rust compiler re-indexes the placeholders so they
591
+ * don't clash with other clause params, but everything else in `sql` is
592
+ * trusted verbatim. Caller is responsible for the fragment's safety — all
593
+ * **values** must still go through `bindings`.
594
+ *
595
+ * Prefer `whereExpr()` for the common case of a column-referencing predicate
596
+ * where Atlas can handle the identifier quoting for you. Reach for
597
+ * `whereRaw` only when the SQL is a dialect-specific construct with no
598
+ * typed equivalent (window functions, `DATE_TRUNC`, vendor extensions…).
599
+ *
600
+ * query.whereRaw('total > ? AND created_at < ?', [100, '2026-01-01'])
601
+ *
602
+ * **Strict mode**: when `setAtlasStrictMode(true)` is active (or the
603
+ * `ATLAS_STRICT` env var is set), this method throws unless called via the
604
+ * framework-internal `__unsafeWhereRaw` path. Production apps should enable
605
+ * strict mode and rewrite call sites to use `whereExpr()` / structured
606
+ * builders.
607
+ *
608
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
609
+ */
610
+ whereRaw(sql: string, bindings: readonly unknown[] = []): this {
611
+ if (isAtlasStrictMode() && !isInternalBypass()) {
612
+ throw new Error(
613
+ "whereRaw() is disabled in Atlas strict mode. " +
614
+ "Use whereExpr() or a structured builder method instead. " +
615
+ "Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.",
616
+ );
617
+ }
618
+ return this.#pushWhereRaw(sql, bindings);
619
+ }
620
+
621
+ /**
622
+ * Framework-internal raw WHERE path — bypasses strict mode. Used by
623
+ * relation preload resolvers (join predicates, pivot correlations) and by
624
+ * the internal `whereExpr(col, extra, op, value)` helper, which has
625
+ * already validated the fragment against a safe charset.
626
+ *
627
+ * Not exported from the package barrel — only accessible inside the Atlas
628
+ * codebase via direct ModelQuery instance access.
629
+ */
630
+ #pushWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
631
+ this.#wheres.push({
632
+ type: "and",
633
+ kind: "raw",
634
+ sql,
635
+ bindings: [...bindings],
636
+ });
637
+ return this;
638
+ }
639
+
640
+ /**
641
+ * **SAFE** alternative to `whereRaw` for the common case of a single
642
+ * SQL expression built from a validated column + operator + bound value.
643
+ * The column goes through the normal identifier quoter (rejects injection
644
+ * characters), the operator is validated against the Rust allowlist, and
645
+ * the value is always bound — never interpolated. Use this in app code;
646
+ * reserve `whereRaw` for dialect-specific constructs with no typed form.
647
+ *
648
+ * query.whereExpr('total', '>', 100) // WHERE "total" > ?
649
+ * query.whereExpr('total', '+ tax', '>=', 100) // WHERE "total" + tax >= ?
650
+ *
651
+ * The optional `extraExpression` parameter is appended to the quoted column
652
+ * identifier before the operator — handy for `+`, `-`, or function
653
+ * wrapping. It must match `[A-Za-z0-9_() +\-*\/,]+` (no quotes, no
654
+ * semicolons, no comments) or the call throws at construction time.
655
+ */
656
+ whereExpr(column: string, operator: string, value: unknown): this;
657
+ whereExpr(
658
+ column: string,
659
+ extraExpression: string,
660
+ operator: string,
661
+ value: unknown,
662
+ ): this;
663
+ whereExpr(
664
+ column: string,
665
+ operatorOrExtra: string,
666
+ operatorOrValue: unknown,
667
+ maybeValue?: unknown,
668
+ ): this {
669
+ // 3-arg form: whereExpr(col, op, value)
670
+ // 4-arg form: whereExpr(col, extraExpr, op, value)
671
+ const hasExtra = maybeValue !== undefined;
672
+ const extra = hasExtra ? operatorOrExtra : "";
673
+ const op = hasExtra ? (operatorOrValue as string) : operatorOrExtra;
674
+ const value = hasExtra ? maybeValue : operatorOrValue;
675
+ if (hasExtra) {
676
+ if (!/^[A-Za-z0-9_() +\-*/,]+$/.test(extra)) {
677
+ throw new Error(
678
+ `whereExpr: extraExpression '${extra}' contains forbidden characters. ` +
679
+ `Only [A-Za-z0-9_() +-*/,] are allowed. Use whereRaw() if you need more.`,
680
+ );
681
+ }
682
+ // The charset alone doesn't stop a structural break-out like
683
+ // `) OR (1` — require balanced parentheses so `extra` can't
684
+ // close the column's context and splice a new predicate.
685
+ if (!hasBalancedParens(extra)) {
686
+ throw new Error(
687
+ `whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
688
+ );
689
+ }
690
+ // `op` is interpolated raw into the fragment below, so it MUST be
691
+ // allow-listed — the 3-arg path gets this from the Rust operator
692
+ // validation, but the raw 4-arg path bypasses Rust and would
693
+ // otherwise let `op` inject (e.g. `'> 0 OR 1=1 --'`).
694
+ if (!WHEREEXPR_OPERATORS.has(op)) {
695
+ throw new Error(
696
+ `whereExpr: operator '${op}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}, or whereRaw() for anything else.`,
697
+ );
698
+ }
699
+ }
700
+ const resolved = this.#resolveColumn(column);
701
+ // Route through the standard WHERE path so the Rust compiler quotes the
702
+ // column and validates the operator. For the extra-expression form we
703
+ // build a raw WHERE internally via #pushWhereRaw (strict-mode exempt) —
704
+ // but only AFTER we've validated the extra charset + paren balance AND
705
+ // the operator against the allow-list above.
706
+ if (hasExtra) {
707
+ const q = this.#quote(resolved);
708
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
709
+ }
710
+ this.#wheres.push({ type: "and", column: resolved, operator: op, value });
711
+ return this;
712
+ }
713
+
714
+ /**
715
+ * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
716
+ * by the existence of related rows, optionally constrained by a callback.
717
+ *
718
+ * userRepo.query().whereHas('comments', q => q.where('approved', true))
719
+ */
720
+ whereHas(
721
+ relationName: string,
722
+ callback?: (query: ModelQuery<BaseEntity>) => void,
723
+ ): this {
724
+ this.#wheres.push(
725
+ this.#buildExistsClause("and", false, relationName, callback),
726
+ );
727
+ return this;
728
+ }
729
+
730
+ /** `OR WHERE EXISTS (...)` — composes with surrounding WHERE groups. */
731
+ orWhereHas(
732
+ relationName: string,
733
+ callback?: (query: ModelQuery<BaseEntity>) => void,
734
+ ): this {
735
+ this.#wheres.push(
736
+ this.#buildExistsClause("or", false, relationName, callback),
737
+ );
738
+ return this;
739
+ }
740
+
741
+ /** `WHERE NOT EXISTS (...)` — negation of whereHas. */
742
+ whereDoesntHave(
743
+ relationName: string,
744
+ callback?: (query: ModelQuery<BaseEntity>) => void,
745
+ ): this {
746
+ this.#wheres.push(
747
+ this.#buildExistsClause("and", true, relationName, callback),
748
+ );
749
+ return this;
750
+ }
751
+
752
+ orWhereDoesntHave(
753
+ relationName: string,
754
+ callback?: (query: ModelQuery<BaseEntity>) => void,
755
+ ): this {
756
+ this.#wheres.push(
757
+ this.#buildExistsClause("or", true, relationName, callback),
758
+ );
759
+ return this;
760
+ }
761
+
762
+ /**
763
+ * Short form of `whereHas`. With an operator + count, emits a count threshold:
764
+ * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
765
+ * has('comments', '>', 2) → EXISTS (... HAVING COUNT(*) > ?)
766
+ */
767
+ has(relationName: string, countOp?: string, countThreshold?: number): this {
768
+ this.#wheres.push(
769
+ this.#buildExistsClause(
770
+ "and",
771
+ false,
772
+ relationName,
773
+ undefined,
774
+ countOp,
775
+ countThreshold,
776
+ ),
777
+ );
778
+ return this;
779
+ }
780
+
781
+ orHas(relationName: string, countOp?: string, countThreshold?: number): this {
782
+ this.#wheres.push(
783
+ this.#buildExistsClause(
784
+ "or",
785
+ false,
786
+ relationName,
787
+ undefined,
788
+ countOp,
789
+ countThreshold,
790
+ ),
791
+ );
792
+ return this;
793
+ }
794
+
795
+ /** `WHERE NOT EXISTS (...)` — short form. */
796
+ doesntHave(relationName: string): this {
797
+ this.#wheres.push(this.#buildExistsClause("and", true, relationName));
798
+ return this;
799
+ }
800
+
801
+ /**
802
+ * Set this query's projection alias — only meaningful when this ModelQuery
803
+ * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
804
+ * The outer query reads `#subqueryAlias` to rename the `$extras` key.
805
+ *
806
+ * repo.query().withCount('posts', q => q.as('published').where('published', true))
807
+ * // → $extras.published (instead of posts_count)
808
+ */
809
+ as(alias: string): this {
810
+ this.#subqueryAlias = alias;
811
+ return this;
812
+ }
813
+
814
+ /** Read-only accessor used by lazy loaders to recover the alias set via `.as()`. */
815
+ get subqueryAlias(): string | undefined {
816
+ return this.#subqueryAlias;
817
+ }
818
+
819
+ /** Read-only accessor used by lazy loaders to list the aliases projected by withCount/withAggregate. */
820
+ get projectedAliases(): readonly string[] {
821
+ return this.#selectSubqueries.map((s) => s.alias);
822
+ }
823
+
824
+ // --- Sub-builder aggregate setters (used inside withCount / withAggregate callbacks) ---
825
+
826
+ /** Set this sub-builder's SELECT to an aggregate expression. Used inside `withAggregate` callbacks. */
827
+ selectAggregate(
828
+ kind: "count" | "sum" | "avg" | "min" | "max",
829
+ column: string = "*",
830
+ ): this {
831
+ const fn = kind.toUpperCase();
832
+ if (column === "*") {
833
+ this.#select = [`${fn}(*)`];
834
+ } else {
835
+ this.#select = [`${fn}(${this.#resolveColumn(column)})`];
836
+ }
837
+ return this;
838
+ }
839
+
840
+ // --- Top-level scalar executors (Story 29.5) ---
841
+
842
+ /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
843
+ async count(column: string = "*"): Promise<number> {
844
+ const expr =
845
+ column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
846
+ return Number((await this.#runScalar(expr)) ?? 0);
847
+ }
848
+
849
+ async sum(column: string): Promise<number | null> {
850
+ const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
851
+ return v === null || v === undefined ? null : Number(v);
852
+ }
853
+
854
+ async avg(column: string): Promise<number | null> {
855
+ const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
856
+ return v === null || v === undefined ? null : Number(v);
857
+ }
858
+
859
+ async min(column: string): Promise<number | null> {
860
+ const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
861
+ return v === null || v === undefined ? null : Number(v);
862
+ }
863
+
864
+ async max(column: string): Promise<number | null> {
865
+ const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
866
+ return v === null || v === undefined ? null : Number(v);
867
+ }
868
+
869
+ /**
870
+ * Project a correlated `COUNT(*)` of a relation as an extra column. Default
871
+ * alias is `${relationName}_count`; override by calling `.as('alias')` inside
872
+ * the optional callback. The count lands on `entity.$extras[alias]`.
873
+ *
874
+ * userRepo.query().withCount('posts') // → $extras.posts_count
875
+ * userRepo.query().withCount('posts', q => q.where('published', true))
876
+ * userRepo.query().withCount('posts', q => q.as('published_count').where('published', true))
877
+ */
878
+ withCount(
879
+ relationName: string,
880
+ callback?: (query: ModelQuery<BaseEntity>) => void,
881
+ ): this {
882
+ this.#selectSubqueries.push(
883
+ this.#buildRelationSubquery(
884
+ relationName,
885
+ callback,
886
+ "count",
887
+ `${relationName}_count`,
888
+ ),
889
+ );
890
+ return this;
891
+ }
892
+
893
+ /**
894
+ * Project any aggregate (sum/avg/min/max/count) of a relation as an extra column.
895
+ * The callback MUST set the aggregate via `.sum('col')` / `.avg(...)` etc. and
896
+ * typically also set an alias via `.as('name')`.
897
+ *
898
+ * userRepo.query().withAggregate('posts', q => q.sum('views').as('total_views'))
899
+ */
900
+ withAggregate(
901
+ relationName: string,
902
+ callback: (query: ModelQuery<BaseEntity>) => void,
903
+ ): this {
904
+ this.#selectSubqueries.push(
905
+ this.#buildRelationSubquery(
906
+ relationName,
907
+ callback,
908
+ "aggregate",
909
+ relationName,
910
+ ),
911
+ );
912
+ return this;
913
+ }
914
+
915
+ orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
916
+ this.#orderBys.push({ column: this.#resolveColumn(column), direction });
917
+ return this;
918
+ }
919
+
920
+ limit(n: number): this {
921
+ // Guard here with a clear message — the Rust spec types limit as
922
+ // u64, so a negative/non-integer otherwise surfaces as a cryptic
923
+ // serde deserialization error at compile time. Matches the
924
+ // QueryBuilder.limit guard.
925
+ if (!Number.isInteger(n) || n < 0) {
926
+ throw new Error(`limit must be a non-negative integer, got ${n}`);
927
+ }
928
+ this.#limit = n;
929
+ return this;
930
+ }
931
+
932
+ offset(n: number): this {
933
+ if (!Number.isInteger(n) || n < 0) {
934
+ throw new Error(`offset must be a non-negative integer, got ${n}`);
935
+ }
936
+ this.#offset = n;
937
+ return this;
938
+ }
939
+
940
+ /** Execute and return the first matching entity or null. */
941
+ async first(): Promise<T | null> {
942
+ this.#limit = 1;
943
+ const results = await this.exec();
944
+ return results[0] ?? null;
945
+ }
946
+
947
+ /** Execute and return the first matching entity or throw. */
948
+ async firstOrFail(): Promise<T> {
949
+ const result = await this.first();
950
+ if (!result) throw new Error(`No ${this.#tableName} found matching query`);
951
+ return result;
952
+ }
953
+
954
+ /**
955
+ * Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
956
+ * A chain like `await repo.query().where('active', true).orderBy('id')`
957
+ * works without an explicit `.exec()` thanks to this method.
958
+ *
959
+ * Idempotent: `exec()` memoizes its promise, so awaiting the same builder
960
+ * twice — or any Promise-like assimilation (Promise.resolve, Promise.all,
961
+ * vitest's `.resolves` matcher, instrumentation libs that probe `.then`,
962
+ * dynamic-import unwrap) — shares one SQL round-trip. Call `.clone()` to
963
+ * get a fresh builder that re-executes.
964
+ */
965
+ // biome-ignore lint/suspicious/noThenProperty: thenable IS the public API — `await someQuery` is the documented ergonomic for the builder. Removing `.then` breaks every call site.
966
+ then<TResult1 = T[], TResult2 = never>(
967
+ onfulfilled?:
968
+ | ((value: T[]) => TResult1 | PromiseLike<TResult1>)
969
+ | null
970
+ | undefined,
971
+ onrejected?:
972
+ | ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
973
+ | null
974
+ | undefined,
975
+ ): Promise<TResult1 | TResult2> {
976
+ return this.exec().then(onfulfilled, onrejected);
977
+ }
978
+
979
+ /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
980
+ #buildSpec(): SelectSpec {
981
+ const wheres: WhereClause[] = [...this.#wheres];
982
+ // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes
983
+ if (this.#softDeletes) {
984
+ if (this.#softScope === "default") {
985
+ wheres.push({
986
+ type: "and",
987
+ column: "deleted_at",
988
+ operator: "IS NULL",
989
+ value: null,
990
+ });
991
+ } else if (this.#softScope === "only-trashed") {
992
+ wheres.push({
993
+ type: "and",
994
+ column: "deleted_at",
995
+ operator: "IS NOT NULL",
996
+ value: null,
997
+ });
998
+ }
999
+ // 'with-trashed' adds no filter
1000
+ }
1001
+
1002
+ return {
1003
+ kind: "select",
1004
+ table: this.#tableName,
1005
+ select: this.#select,
1006
+ selectSubqueries: this.#selectSubqueries,
1007
+ wheres,
1008
+ orderBy: this.#orderBys,
1009
+ groupBy: [],
1010
+ having: [],
1011
+ limit: this.#limit ?? null,
1012
+ offset: this.#offset ?? null,
1013
+ distinct: this.#distinct,
1014
+ ctes: [],
1015
+ unions: [],
1016
+ joins: this.#joins,
1017
+ lockMode: this.#lockMode,
1018
+ };
1019
+ }
1020
+
1021
+ /** Build SQL + params via the Rust query compiler. */
1022
+ toSQL(): { sql: string; params: unknown[] } {
1023
+ const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
1024
+ return { sql: compiled.statements[0], params: compiled.params };
1025
+ }
1026
+
1027
+ /**
1028
+ * Cached exec result. Memoizing the promise makes the builder a one-shot
1029
+ * Promise-like: multiple awaits / `Promise.resolve(query)` / `then` probes
1030
+ * by instrumentation libraries / `expect().resolves` / dynamic-import
1031
+ * unwrap — all share the same SQL round-trip. Pre-memoization, any
1032
+ * Promise-like assimilation silently triggered the query a second time.
1033
+ *
1034
+ * Callers that want a fresh query result must `.clone()` the builder.
1035
+ */
1036
+ #cachedExec?: Promise<T[]>;
1037
+
1038
+ /** Execute and return all matching entities, with preloaded relations. */
1039
+ exec(): Promise<T[]> {
1040
+ this.#cachedExec ??= this.#doExec();
1041
+ return this.#cachedExec;
1042
+ }
1043
+
1044
+ async #doExec(): Promise<T[]> {
1045
+ const { sql, params } = this.toSQL();
1046
+ const rawRows = await this.#db.query<Record<string, unknown>>(sql, params);
1047
+ // Peel withCount / withAggregate alias columns off the raw row into $extras
1048
+ // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
1049
+ const extraKeys = this.#selectSubqueries.map((s) => s.alias);
1050
+ const entities = rawRows.map((row) => {
1051
+ const picked: Record<string, unknown> = {};
1052
+ for (const key of extraKeys) {
1053
+ if (key in row) {
1054
+ picked[key] = row[key];
1055
+ delete row[key];
1056
+ }
1057
+ }
1058
+ const entity = this.#hydrateFn(row);
1059
+ for (const [k, v] of Object.entries(picked)) entity.setExtra(k, v);
1060
+ return entity;
1061
+ });
1062
+
1063
+ // Resolve preloads (eager loading)
1064
+ if (this.#preloads.size > 0 && this.#entityClass && entities.length > 0) {
1065
+ await this.#resolvePreloads(entities);
1066
+ }
1067
+
1068
+ return entities;
1069
+ }
1070
+
1071
+ /** Resolve preloaded relations via batched subqueries (no N+1). */
1072
+ async #resolvePreloads(entities: T[]): Promise<void> {
1073
+ if (!this.#entityClass) return;
1074
+ const relations = getRelationMetadata(this.#entityClass);
1075
+
1076
+ for (const relationName of this.#preloads.keys()) {
1077
+ const relation = relations.find((r) => r.propertyKey === relationName);
1078
+ if (!relation) continue;
1079
+
1080
+ const ctx = this.#buildPreloadContext(relation, relationName);
1081
+ if (!ctx) continue;
1082
+
1083
+ const allRelated = await this.#resolveOneRelation(
1084
+ entities,
1085
+ relationName,
1086
+ relation.type,
1087
+ ctx,
1088
+ );
1089
+ await this.#applyNestedPreloads(allRelated, ctx);
1090
+ }
1091
+ }
1092
+
1093
+ /** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
1094
+ #buildPreloadContext(
1095
+ relation: RelationMetadata,
1096
+ relationName: string,
1097
+ ): PreloadContext | null {
1098
+ const relatedClass = relation.target() as new () => BaseEntity;
1099
+ const relatedMeta = getEntityMetadata(relatedClass);
1100
+ if (!relatedMeta) return null;
1101
+
1102
+ const hydrate = (row: Record<string, unknown>): BaseEntity => {
1103
+ const entity = new relatedClass();
1104
+ for (const [key, value] of Object.entries(row)) {
1105
+ const camelKey = snakeToCamel(key);
1106
+ const targetKey =
1107
+ camelKey in entity ? camelKey : key in entity ? key : null;
1108
+ if (targetKey !== null) entity.setProp(targetKey, value);
1109
+ }
1110
+ return entity;
1111
+ };
1112
+
1113
+ return {
1114
+ relation,
1115
+ relationName,
1116
+ relatedClass,
1117
+ relatedTable: relatedMeta.tableName,
1118
+ relatedPk: getPrimaryKey(relatedClass) ?? "id",
1119
+ hydrate,
1120
+ runInQuery: (table, column, values) =>
1121
+ this.#runInQuery(table, column, values),
1122
+ runRelationQuery: (column, values) =>
1123
+ this.#runRelationQuery(
1124
+ relatedMeta.tableName,
1125
+ relatedClass,
1126
+ column,
1127
+ values,
1128
+ relation,
1129
+ this.#preloads.get(relationName),
1130
+ ),
1131
+ nestedCallback: this.#preloads.get(relationName),
1132
+ };
1133
+ }
1134
+
1135
+ /** Dispatch to the appropriate relation resolver based on the relation type. */
1136
+ async #resolveOneRelation(
1137
+ entities: T[],
1138
+ relationName: string,
1139
+ type: RelationMetadata["type"],
1140
+ ctx: PreloadContext,
1141
+ ): Promise<BaseEntity[]> {
1142
+ switch (type) {
1143
+ case "hasMany":
1144
+ return this.#resolveHasMany(entities, relationName, ctx);
1145
+ case "hasOne":
1146
+ return this.#resolveHasOne(entities, relationName, ctx);
1147
+ case "belongsTo":
1148
+ return this.#resolveBelongsTo(entities, relationName, ctx);
1149
+ case "manyToMany":
1150
+ return this.#resolveManyToMany(entities, relationName, ctx);
1151
+ case "hasOneThrough":
1152
+ case "hasManyThrough":
1153
+ return this.#resolveThrough(
1154
+ entities,
1155
+ relationName,
1156
+ ctx,
1157
+ type === "hasOneThrough",
1158
+ );
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Two-hop relations (Story 31.2). Walks parent → intermediate → related in
1164
+ * two SELECTs (N+1 would be worse) and groups the final rows by the parent
1165
+ * id discovered through the intermediate join.
1166
+ */
1167
+ async #resolveThrough(
1168
+ entities: T[],
1169
+ relationName: string,
1170
+ ctx: PreloadContext,
1171
+ single: boolean,
1172
+ ): Promise<BaseEntity[]> {
1173
+ const relation = ctx.relation;
1174
+ if (!relation.through) {
1175
+ throw new Error(
1176
+ `@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`,
1177
+ );
1178
+ }
1179
+ const throughClass = relation.through() as new () => BaseEntity;
1180
+ const throughMeta = getEntityMetadata(throughClass);
1181
+ if (!throughMeta)
1182
+ throw new Error(
1183
+ `Entity metadata missing on through class ${throughClass.name}`,
1184
+ );
1185
+ const throughTable = throughMeta.tableName;
1186
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
1187
+ const parentLocal =
1188
+ relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1189
+ const firstKey =
1190
+ relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1191
+ const secondKey =
1192
+ relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1193
+ const secondLocal = relation.secondLocalKey ?? throughPk;
1194
+
1195
+ const parentIds = entities
1196
+ .map((e) => e[parentLocal])
1197
+ .filter((v) => v != null);
1198
+ if (parentIds.length === 0) {
1199
+ return assignEmptyRelation(entities, relationName, single);
1200
+ }
1201
+
1202
+ // Step 1 — intermediate rows: (throughPk, firstKey)
1203
+ const throughRows = await ctx.runInQuery(throughTable, firstKey, parentIds);
1204
+ if (throughRows.length === 0) {
1205
+ return assignEmptyRelation(entities, relationName, single);
1206
+ }
1207
+ // Map secondLocal (= through PK by default) → parentId, throwing on a
1208
+ // non-unique key that would silently drop data.
1209
+ const throughToParent = buildThroughToParent(
1210
+ throughRows,
1211
+ secondLocal,
1212
+ firstKey,
1213
+ {
1214
+ relationName,
1215
+ throughTable,
1216
+ throughClass: throughClass.name,
1217
+ throughPk,
1218
+ },
1219
+ );
1220
+
1221
+ // Step 2 — related rows where secondKey IN (throughPk)
1222
+ const throughIds = [...throughToParent.keys()];
1223
+ const relRows = await ctx.runRelationQuery(secondKey, throughIds);
1224
+
1225
+ const grouped = new Map<unknown, BaseEntity[]>();
1226
+ const allRelated: BaseEntity[] = [];
1227
+ for (const row of relRows) {
1228
+ const hydrated = ctx.hydrate(row);
1229
+ const parentId = throughToParent.get(row[secondKey]);
1230
+ if (!grouped.has(parentId)) grouped.set(parentId, []);
1231
+ grouped.get(parentId)?.push(hydrated);
1232
+ allRelated.push(hydrated);
1233
+ }
1234
+
1235
+ for (const entity of entities) {
1236
+ const matches = grouped.get(entity[parentLocal]) ?? [];
1237
+ entity.setProp(relationName, single ? (matches[0] ?? null) : matches);
1238
+ }
1239
+ return allRelated;
1240
+ }
1241
+
1242
+ async #resolveHasOne(
1243
+ entities: T[],
1244
+ relationName: string,
1245
+ ctx: PreloadContext,
1246
+ ): Promise<BaseEntity[]> {
1247
+ const fk =
1248
+ ctx.relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1249
+ const pk =
1250
+ ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1251
+ const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1252
+ if (ids.length === 0) {
1253
+ for (const e of entities) e.setProp(relationName, null);
1254
+ return [];
1255
+ }
1256
+ const relRows = await ctx.runRelationQuery(fk, ids);
1257
+ // Track how many rows match each parent id. More than one = invariant
1258
+ // violation on a `@HasOne` relation — throw instead of silently dropping
1259
+ // the extras (which would hide real data integrity bugs).
1260
+ const indexed = new Map<unknown, BaseEntity>();
1261
+ const counts = new Map<unknown, number>();
1262
+ const allRelated: BaseEntity[] = [];
1263
+ for (const row of relRows) {
1264
+ const key = row[fk];
1265
+ const next = (counts.get(key) ?? 0) + 1;
1266
+ counts.set(key, next);
1267
+ if (next > 1) {
1268
+ throw new Error(
1269
+ `@HasOne invariant violated: ${this.#entityClass.name}.${relationName} ` +
1270
+ `found ${next} rows in "${ctx.relatedTable}" for parent ${pk}=${String(key)}. ` +
1271
+ `Use @HasMany if multiple rows are expected, or add a unique index on "${fk}".`,
1272
+ );
1273
+ }
1274
+ const hydrated = ctx.hydrate(row);
1275
+ indexed.set(key, hydrated);
1276
+ allRelated.push(hydrated);
1277
+ }
1278
+ for (const entity of entities) {
1279
+ entity.setProp(relationName, indexed.get(entity[pk]) ?? null);
1280
+ }
1281
+ return allRelated;
1282
+ }
1283
+
1284
+ async #resolveHasMany(
1285
+ entities: T[],
1286
+ relationName: string,
1287
+ ctx: PreloadContext,
1288
+ ): Promise<BaseEntity[]> {
1289
+ const fk =
1290
+ ctx.relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1291
+ const pk =
1292
+ ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1293
+ const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1294
+ if (ids.length === 0) return [];
1295
+
1296
+ const relRows = await ctx.runRelationQuery(fk, ids);
1297
+ const grouped = new Map<unknown, BaseEntity[]>();
1298
+ const allRelated: BaseEntity[] = [];
1299
+ for (const row of relRows) {
1300
+ const key = row[fk];
1301
+ const hydrated = ctx.hydrate(row);
1302
+ if (!grouped.has(key)) grouped.set(key, []);
1303
+ grouped.get(key)?.push(hydrated);
1304
+ allRelated.push(hydrated);
1305
+ }
1306
+
1307
+ for (const entity of entities) {
1308
+ entity.setProp(relationName, grouped.get(entity[pk]) ?? []);
1309
+ }
1310
+ return allRelated;
1311
+ }
1312
+
1313
+ async #resolveBelongsTo(
1314
+ entities: T[],
1315
+ relationName: string,
1316
+ ctx: PreloadContext,
1317
+ ): Promise<BaseEntity[]> {
1318
+ const fk =
1319
+ ctx.relation.foreignKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
1320
+ const fkProp = `${relationName}Id`;
1321
+ const ids = entities
1322
+ .map((e) => e[fkProp] ?? e[fk])
1323
+ .filter((v) => v != null);
1324
+ const uniqueIds = [...new Set(ids)];
1325
+ if (uniqueIds.length === 0) return [];
1326
+
1327
+ const relRows = await ctx.runRelationQuery(ctx.relatedPk, uniqueIds);
1328
+ const indexed = new Map<unknown, BaseEntity>();
1329
+ const allRelated: BaseEntity[] = [];
1330
+ for (const row of relRows) {
1331
+ const hydrated = ctx.hydrate(row);
1332
+ indexed.set(row[ctx.relatedPk], hydrated);
1333
+ allRelated.push(hydrated);
1334
+ }
1335
+
1336
+ for (const entity of entities) {
1337
+ const fkValue = entity[fkProp] ?? entity[fk];
1338
+ entity.setProp(relationName, indexed.get(fkValue) ?? null);
1339
+ }
1340
+ return allRelated;
1341
+ }
1342
+
1343
+ async #resolveManyToMany(
1344
+ entities: T[],
1345
+ relationName: string,
1346
+ ctx: PreloadContext,
1347
+ ): Promise<BaseEntity[]> {
1348
+ if (!ctx.relation.pivot) {
1349
+ throw new Error(
1350
+ `@ManyToMany on ${this.#entityClass.name}.${relationName} requires pivot options`,
1351
+ );
1352
+ }
1353
+ const pivot = ctx.relation.pivot;
1354
+ const foreignKey =
1355
+ pivot.foreignKey ??
1356
+ `${camelToSnake(this.#tableName.replace(/s$/, ""))}_id`;
1357
+ const otherKey =
1358
+ pivot.otherKey ??
1359
+ `${camelToSnake(ctx.relatedTable.replace(/s$/, ""))}_id`;
1360
+ const pk = getPrimaryKey(this.#entityClass) ?? "id";
1361
+
1362
+ const ids = entities.map((e) => e[pk]).filter((v) => v != null);
1363
+ if (ids.length === 0) return [];
1364
+
1365
+ // Step 1 — pivot table: find (foreignKey → otherKey) pairs
1366
+ const pivotRows = await ctx.runInQuery(pivot.pivotTable, foreignKey, ids);
1367
+ if (pivotRows.length === 0) {
1368
+ for (const entity of entities) entity.setProp(relationName, []);
1369
+ return [];
1370
+ }
1371
+ const otherIds = [
1372
+ ...new Set(pivotRows.map((r) => r[otherKey]).filter((v) => v != null)),
1373
+ ];
1374
+
1375
+ // Step 2 — load all related entities in one query
1376
+ const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
1377
+ const byRelatedPk = new Map<unknown, BaseEntity>();
1378
+ const allRelated: BaseEntity[] = [];
1379
+ for (const row of relRows) {
1380
+ const hydrated = ctx.hydrate(row);
1381
+ byRelatedPk.set(row[ctx.relatedPk], hydrated);
1382
+ allRelated.push(hydrated);
1383
+ }
1384
+
1385
+ // Step 3 — group via the pivot
1386
+ const grouped = new Map<unknown, BaseEntity[]>();
1387
+ for (const pivotRow of pivotRows) {
1388
+ const related = byRelatedPk.get(pivotRow[otherKey]);
1389
+ if (!related) continue;
1390
+ const parentId = pivotRow[foreignKey];
1391
+ if (!grouped.has(parentId)) grouped.set(parentId, []);
1392
+ grouped.get(parentId)?.push(related);
1393
+ }
1394
+
1395
+ for (const entity of entities) {
1396
+ entity.setProp(relationName, grouped.get(entity[pk]) ?? []);
1397
+ }
1398
+ return allRelated;
1399
+ }
1400
+
1401
+ /** Recursively resolve preloads declared by the nested callback. */
1402
+ async #applyNestedPreloads(
1403
+ relatedEntities: BaseEntity[],
1404
+ ctx: PreloadContext,
1405
+ ): Promise<void> {
1406
+ if (!ctx.nestedCallback || relatedEntities.length === 0) return;
1407
+ const sub = new ModelQuery<BaseEntity>(
1408
+ ctx.relatedTable,
1409
+ this.#db,
1410
+ (r) => ctx.hydrate(r),
1411
+ ctx.relatedClass,
1412
+ );
1413
+ ctx.nestedCallback(sub);
1414
+ if (sub.#preloads.size > 0) {
1415
+ await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
1416
+ }
1417
+ }
1418
+
1419
+ /** Compile + execute a `SELECT * FROM <table> WHERE <column> IN (...)` via the Rust compiler. */
1420
+ async #runInQuery(
1421
+ table: string,
1422
+ column: string,
1423
+ values: unknown[],
1424
+ ): Promise<Record<string, unknown>[]> {
1425
+ const spec = {
1426
+ kind: "select",
1427
+ table,
1428
+ select: ["*"],
1429
+ selectSubqueries: [],
1430
+ wheres: [{ column, operator: "IN", value: values, type: "and" }],
1431
+ orderBy: [],
1432
+ groupBy: [],
1433
+ having: [],
1434
+ limit: null,
1435
+ offset: null,
1436
+ distinct: false,
1437
+ ctes: [],
1438
+ unions: [],
1439
+ joins: [],
1440
+ lockMode: null,
1441
+ };
1442
+ const compiled = compileStatementNative(spec, this.#dialect);
1443
+ return this.#db.query<Record<string, unknown>>(
1444
+ compiled.statements[0],
1445
+ compiled.params,
1446
+ );
1447
+ }
1448
+
1449
+ /**
1450
+ * Run a relation preload against the related table, applying the relation's
1451
+ * declared `onQuery` constraint (Story 31.4) AND the user-supplied preload
1452
+ * callback (e.g. `preload('posts', q => q.where('published', true))`) —
1453
+ * which, prior to this helper, was silently dropped for the primary-level
1454
+ * row set and only applied on nested preloads.
1455
+ *
1456
+ * Returns raw rows (snake_case keys) so existing resolvers can continue to
1457
+ * index/group by FK without a hydration round-trip. Nested preloads declared
1458
+ * inside the callback are re-collected later by `#applyNestedPreloads`.
1459
+ */
1460
+ async #runRelationQuery(
1461
+ relatedTable: string,
1462
+ relatedClass: new () => BaseEntity,
1463
+ column: string,
1464
+ values: unknown[],
1465
+ relation: RelationMetadata,
1466
+ userCallback: PreloadCallback | undefined,
1467
+ ): Promise<Record<string, unknown>[]> {
1468
+ const sub = new ModelQuery<BaseEntity>(
1469
+ relatedTable,
1470
+ this.#db,
1471
+ (row) => row as BaseEntity,
1472
+ relatedClass,
1473
+ (c) => c,
1474
+ // Propagate the RELATED entity's soft-delete flag — hardcoding
1475
+ // false here meant `preload('posts')` returned soft-deleted
1476
+ // posts even when Post is @SoftDeletes (a data leak). The
1477
+ // related query now applies its own `deleted_at IS NULL` filter,
1478
+ // matching a direct query on that entity. (with-trashed on the
1479
+ // related set, if ever needed, would be opted-in via the
1480
+ // preload callback.)
1481
+ hasSoftDeletes(relatedClass),
1482
+ this.#dialect,
1483
+ );
1484
+ sub.whereIn(column, values);
1485
+ if (relation.onQuery) relation.onQuery(sub as unknown);
1486
+ if (userCallback) userCallback(sub);
1487
+ const { sql, params } = sub.toSQL();
1488
+ return this.#db.query<Record<string, unknown>>(sql, params);
1489
+ }
1490
+
1491
+ /**
1492
+ * Build a correlated subquery over a relation. Returns `SubqueryProjection`
1493
+ * used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
1494
+ * mode; `'aggregate'` mode requires the callback to set the select itself via
1495
+ * `.sum()` / `.avg()` / `.min()` / `.max()` / `.count()`.
1496
+ */
1497
+ #buildRelationSubquery(
1498
+ relationName: string,
1499
+ callback: ((q: ModelQuery<BaseEntity>) => void) | undefined,
1500
+ mode: "count" | "aggregate",
1501
+ defaultAlias: string,
1502
+ ): SubqueryProjection {
1503
+ const sub = this.#makeRelationSub(relationName);
1504
+ if (mode === "count") sub.selectAggregate("count", "*");
1505
+ if (callback) callback(sub);
1506
+ if (
1507
+ mode === "aggregate" &&
1508
+ (sub.#select.length !== 1 || sub.#select[0] === "*")
1509
+ ) {
1510
+ throw new Error(
1511
+ `withAggregate('${relationName}') callback must set an aggregate via .sum/.avg/.min/.max/.count`,
1512
+ );
1513
+ }
1514
+ const alias = sub.#subqueryAlias ?? defaultAlias;
1515
+ return { alias, subquery: sub.#buildSpec() };
1516
+ }
1517
+
1518
+ /**
1519
+ * Shared helper for whereHas + withCount + withAggregate: build a sub ModelQuery
1520
+ * on the related table with the correlated join predicate already injected.
1521
+ */
1522
+ #makeRelationSub(relationName: string): ModelQuery<BaseEntity> {
1523
+ const relations = getRelationMetadata(this.#entityClass);
1524
+ const relation = relations.find((r) => r.propertyKey === relationName);
1525
+ if (!relation) {
1526
+ throw new Error(
1527
+ `Relation '${relationName}' not found on ${this.#entityClass.name}`,
1528
+ );
1529
+ }
1530
+ const relatedClass = relation.target() as new () => BaseEntity;
1531
+ const relatedMeta = getEntityMetadata(relatedClass);
1532
+ if (!relatedMeta) {
1533
+ throw new Error(
1534
+ `Entity metadata missing on related class ${relatedClass.name}`,
1535
+ );
1536
+ }
1537
+ const relatedTable = relatedMeta.tableName;
1538
+ const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
1539
+ const parentTable = this.#tableName;
1540
+ const q =
1541
+ this.#dialect === "mysql"
1542
+ ? (name: string) => `\`${name}\``
1543
+ : (name: string) => `"${name}"`;
1544
+
1545
+ const sub = new ModelQuery<BaseEntity>(
1546
+ relatedTable,
1547
+ this.#db,
1548
+ (row) => row as BaseEntity,
1549
+ relatedClass,
1550
+ (c) => c,
1551
+ false,
1552
+ this.#dialect,
1553
+ );
1554
+
1555
+ switch (relation.type) {
1556
+ case "hasOne":
1557
+ case "hasMany": {
1558
+ const fk = `${camelToSnake(this.#entityClass.name)}_id`;
1559
+ sub.#pushWhereRaw(
1560
+ `${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(parentPk)}`,
1561
+ );
1562
+ break;
1563
+ }
1564
+ case "belongsTo": {
1565
+ const fk = `${camelToSnake(relatedClass.name)}_id`;
1566
+ const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1567
+ sub.#pushWhereRaw(
1568
+ `${q(relatedTable)}.${q(relatedPk)} = ${q(parentTable)}.${q(fk)}`,
1569
+ );
1570
+ break;
1571
+ }
1572
+ case "manyToMany": {
1573
+ if (!relation.pivot) {
1574
+ throw new Error(
1575
+ `@ManyToMany on ${this.#entityClass.name}.${relationName} requires pivot options`,
1576
+ );
1577
+ }
1578
+ const pivot = relation.pivot;
1579
+ const foreignKey =
1580
+ pivot.foreignKey ??
1581
+ `${camelToSnake(parentTable.replace(/s$/, ""))}_id`;
1582
+ const otherKey =
1583
+ pivot.otherKey ??
1584
+ `${camelToSnake(relatedTable.replace(/s$/, ""))}_id`;
1585
+ const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1586
+ sub.#pushWhereRaw(
1587
+ `${q(relatedTable)}.${q(relatedPk)} IN ` +
1588
+ `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1589
+ `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(parentPk)})`,
1590
+ );
1591
+ break;
1592
+ }
1593
+ }
1594
+ return sub;
1595
+ }
1596
+
1597
+ /**
1598
+ * Resolve a relation to its table + correlated join predicate and return an
1599
+ * `ExistsWhere` clause. Used by whereHas / has / doesntHave / whereDoesntHave.
1600
+ *
1601
+ * The join predicate is injected as a `whereRaw` on the sub-query so the Rust
1602
+ * compiler handles identifier quoting uniformly. ManyToMany uses a pivot
1603
+ * subquery (`EXISTS (SELECT FROM related WHERE id IN (SELECT other_key FROM pivot WHERE foreign_key = parent.id))`).
1604
+ *
1605
+ * Count threshold form (`has('comments', '>', 2)`) adds a HAVING COUNT(*)
1606
+ * without GROUP BY — SQL treats the whole sub-result as one group, so
1607
+ * COUNT(*) against the correlated rows returns the right number.
1608
+ */
1609
+ // === Story 29.4 — joins ===========================================================================
1610
+
1611
+ /** `INNER JOIN <table> ON <left> = <right>`. */
1612
+ innerJoin(table: string, left: string, right: string): this;
1613
+ innerJoin(table: string, build: (j: JoinBuilder) => void): this;
1614
+ innerJoin(
1615
+ table: string,
1616
+ leftOrBuild: string | ((j: JoinBuilder) => void),
1617
+ right?: string,
1618
+ ): this {
1619
+ return this.#pushJoin("INNER", table, leftOrBuild, right);
1620
+ }
1621
+
1622
+ leftJoin(table: string, left: string, right: string): this;
1623
+ leftJoin(table: string, build: (j: JoinBuilder) => void): this;
1624
+ leftJoin(
1625
+ table: string,
1626
+ leftOrBuild: string | ((j: JoinBuilder) => void),
1627
+ right?: string,
1628
+ ): this {
1629
+ return this.#pushJoin("LEFT", table, leftOrBuild, right);
1630
+ }
1631
+
1632
+ rightJoin(table: string, left: string, right: string): this;
1633
+ rightJoin(table: string, build: (j: JoinBuilder) => void): this;
1634
+ rightJoin(
1635
+ table: string,
1636
+ leftOrBuild: string | ((j: JoinBuilder) => void),
1637
+ right?: string,
1638
+ ): this {
1639
+ return this.#pushJoin("RIGHT", table, leftOrBuild, right);
1640
+ }
1641
+
1642
+ crossJoin(table: string): this {
1643
+ const tq = this.#quote(table);
1644
+ this.#joins.push(`CROSS JOIN ${tq}`);
1645
+ return this;
1646
+ }
1647
+
1648
+ /**
1649
+ * **⚠ UNSAFE** — append a raw JOIN fragment verbatim. No identifier quoting,
1650
+ * no operator validation. Caller is fully responsible for safety.
1651
+ *
1652
+ * Prefer `joinOn()` for the common two-column equi-join case where Atlas
1653
+ * can quote the identifiers for you. Reach for `joinRaw` only when you
1654
+ * need a dialect-specific construct (`LATERAL`, `USING`, index hints…).
1655
+ *
1656
+ * query.joinRaw('LEFT JOIN LATERAL (SELECT ... FROM ...) t ON true')
1657
+ *
1658
+ * **Strict mode**: throws when `setAtlasStrictMode(true)` is active.
1659
+ * Use `joinOn()` or the callback form of `innerJoin`/`leftJoin`/`rightJoin`
1660
+ * instead.
1661
+ *
1662
+ * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
1663
+ */
1664
+ joinRaw(fragment: string): this {
1665
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1666
+ throw new Error(
1667
+ "joinRaw() is disabled in Atlas strict mode. " +
1668
+ "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
1669
+ );
1670
+ }
1671
+ this.#joins.push(fragment);
1672
+ return this;
1673
+ }
1674
+
1675
+ /**
1676
+ * **SAFE** helper that builds an `INNER JOIN <table> ON <left> = <right>`
1677
+ * with dialect-correct identifier quoting on both sides. Thin sugar over
1678
+ * `innerJoin(table, left, right)` for symmetry with `whereExpr` — both
1679
+ * are the "don't reach for *Raw" entry points.
1680
+ *
1681
+ * query.joinOn('users', 'users.id', 'orders.user_id')
1682
+ *
1683
+ * Use the callback form of `innerJoin` / `leftJoin` / `rightJoin` when
1684
+ * you need multiple join conditions.
1685
+ */
1686
+ joinOn(table: string, left: string, right: string): this {
1687
+ return this.innerJoin(table, left, right);
1688
+ }
1689
+
1690
+ // === Story 29.5 — aggregates / exists / pluck =====================================================
1691
+
1692
+ distinct(): this {
1693
+ this.#distinct = true;
1694
+ return this;
1695
+ }
1696
+
1697
+ /** `SELECT COUNT(DISTINCT col)`. */
1698
+ async countDistinct(column: string): Promise<number> {
1699
+ return Number(
1700
+ (await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0,
1701
+ );
1702
+ }
1703
+
1704
+ /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
1705
+ async exists(): Promise<boolean> {
1706
+ const clone = this.clone();
1707
+ clone.#select = ["1"];
1708
+ clone.#limit = 1;
1709
+ const { sql, params } = clone.toSQL();
1710
+ const rows = await this.#db.query<Record<string, unknown>>(sql, params);
1711
+ return rows.length > 0;
1712
+ }
1713
+
1714
+ async doesntExist(): Promise<boolean> {
1715
+ return !(await this.exists());
1716
+ }
1717
+
1718
+ /** Flat column projection. Rejects object/relation columns. */
1719
+ async pluck(column: string): Promise<unknown[]> {
1720
+ const col = this.#resolveColumn(column);
1721
+ const clone = this.clone();
1722
+ clone.#select = [col];
1723
+ const { sql, params } = clone.toSQL();
1724
+ const rows = await this.#db.query<Record<string, unknown>>(sql, params);
1725
+ return rows.map((row) => {
1726
+ const v = row[col];
1727
+ if (v !== null && typeof v === "object") {
1728
+ throw new Error(
1729
+ `pluck('${column}') rejected — column is an object/relation`,
1730
+ );
1731
+ }
1732
+ return v;
1733
+ });
1734
+ }
1735
+
1736
+ // === Story 29.8 — scopes ===========================================================================
1737
+
1738
+ /** Apply scopes declared on the entity class via `static scopes = {...}`. */
1739
+ apply(
1740
+ callback: (
1741
+ scopes: Record<string, (...args: unknown[]) => ModelQuery<T>>,
1742
+ ) => void,
1743
+ ): this {
1744
+ const scopes = (
1745
+ this.#entityClass as {
1746
+ scopes?: Record<string, (q: ModelQuery<T>, ...rest: unknown[]) => void>;
1747
+ }
1748
+ ).scopes;
1749
+ if (!scopes)
1750
+ throw new Error(`${this.#entityClass.name} declares no static scopes`);
1751
+ const proxy: Record<string, (...args: unknown[]) => ModelQuery<T>> = {};
1752
+ for (const [name, fn] of Object.entries(scopes)) {
1753
+ proxy[name] = (...args: unknown[]) => {
1754
+ fn(this, ...args);
1755
+ return this;
1756
+ };
1757
+ }
1758
+ const wrapper = new Proxy(proxy, {
1759
+ get: (target, prop: string) => {
1760
+ if (prop in target) return target[prop];
1761
+ throw new Error(
1762
+ `Unknown scope '${String(prop)}' on ${this.#entityClass.name}`,
1763
+ );
1764
+ },
1765
+ });
1766
+ callback(wrapper);
1767
+ return this;
1768
+ }
1769
+
1770
+ /** Alias for `apply` — Lucid compatibility. */
1771
+ withScopes(
1772
+ callback: (
1773
+ scopes: Record<string, (...args: unknown[]) => ModelQuery<T>>,
1774
+ ) => void,
1775
+ ): this {
1776
+ return this.apply(callback);
1777
+ }
1778
+
1779
+ // === Story 29.9 — if / unless ======================================================================
1780
+
1781
+ if<V>(
1782
+ condition: V | undefined | null | false,
1783
+ ifFn: (q: this, value: V) => void,
1784
+ elseFn?: (q: this) => void,
1785
+ ): this {
1786
+ if (condition) ifFn(this, condition as V);
1787
+ else if (elseFn) elseFn(this);
1788
+ return this;
1789
+ }
1790
+
1791
+ unless<V>(
1792
+ condition: V | undefined | null | false,
1793
+ fn: (q: this) => void,
1794
+ ): this {
1795
+ if (!condition) fn(this);
1796
+ return this;
1797
+ }
1798
+
1799
+ // === Story 29.10 — pagination =====================================================================
1800
+
1801
+ /** Offset-based paginator. */
1802
+ async paginate(page: number, perPage: number): Promise<Paginator<T>> {
1803
+ const p = Math.max(1, Math.floor(page));
1804
+ const pp = Math.max(1, Math.floor(perPage));
1805
+ // Parallel COUNT(*) + data fetch
1806
+ const countQ = this.clone();
1807
+ countQ.#select = ["COUNT(*) AS count"];
1808
+ countQ.#limit = undefined;
1809
+ countQ.#offset = undefined;
1810
+ countQ.#orderBys = [];
1811
+ const { sql: cSql, params: cParams } = countQ.toSQL();
1812
+ const cRows = await this.#db.query<Record<string, unknown>>(cSql, cParams);
1813
+ const total = Number(cRows[0]?.count ?? 0);
1814
+
1815
+ const dataQ = this.clone();
1816
+ dataQ.#limit = pp;
1817
+ dataQ.#offset = (p - 1) * pp;
1818
+ const items = await dataQ.exec();
1819
+ return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
1820
+ }
1821
+
1822
+ /**
1823
+ * Cursor-based pagination — base64 opaque keyset, multi-column aware.
1824
+ *
1825
+ * `orderBy` can be a single column (`'created_at'`) or a tuple
1826
+ * (`['created_at', 'id']`) for stable tie-breaking. The cursor encodes
1827
+ * the last row's values for every ordering column, and the next page
1828
+ * query uses a lexicographic tuple predicate:
1829
+ *
1830
+ * (col1, col2) > (?, ?) ≡ col1 > ? OR (col1 = ? AND col2 > ?)
1831
+ *
1832
+ * Expanded into a disjunctive form because not every supported dialect
1833
+ * accepts row-value comparisons.
1834
+ */
1835
+ async cursorPaginate(opts: {
1836
+ cursor?: string;
1837
+ limit: number;
1838
+ orderBy: string | string[];
1839
+ }): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
1840
+ const cols = (
1841
+ Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]
1842
+ ).map((c) => this.#resolveColumn(c));
1843
+ if (cols.length === 0)
1844
+ throw new Error("cursorPaginate requires at least one orderBy column");
1845
+ const lim = Math.max(1, Math.floor(opts.limit));
1846
+ const clone = this.clone();
1847
+
1848
+ if (opts.cursor) {
1849
+ // Cursors arrive from the API boundary (often a query string). Wrap the
1850
+ // decode so a malformed cursor produces a controlled user-facing error
1851
+ // instead of a raw `SyntaxError` from JSON.parse.
1852
+ let decoded: { v: unknown[] };
1853
+ try {
1854
+ const raw = Buffer.from(opts.cursor, "base64").toString("utf-8");
1855
+ decoded = JSON.parse(raw) as { v: unknown[] };
1856
+ } catch {
1857
+ throw new Error(
1858
+ `cursorPaginate: malformed cursor '${opts.cursor.slice(0, 32)}…' — ` +
1859
+ `must be a base64-encoded JSON object of shape { v: unknown[] }`,
1860
+ );
1861
+ }
1862
+ if (!Array.isArray(decoded.v) || decoded.v.length !== cols.length) {
1863
+ throw new Error(
1864
+ `cursor tuple length mismatch (expected ${cols.length}, got ${decoded.v?.length ?? 0})`,
1865
+ );
1866
+ }
1867
+ // Build the disjunctive tuple comparison as a nested group of WHEREs.
1868
+ clone.where((q) => {
1869
+ for (let i = 0; i < cols.length; i++) {
1870
+ q.orWhere((inner) => {
1871
+ for (let j = 0; j < i; j++) inner.where(cols[j], decoded.v[j]);
1872
+ inner.where(cols[i], ">", decoded.v[i]);
1873
+ });
1874
+ }
1875
+ });
1876
+ }
1877
+
1878
+ clone.#orderBys = cols.map((column) => ({
1879
+ column,
1880
+ direction: "asc" as const,
1881
+ }));
1882
+ clone.#limit = lim + 1;
1883
+ const rows = await clone.exec();
1884
+ const hasMore = rows.length > lim;
1885
+ const items = hasMore ? rows.slice(0, lim) : rows;
1886
+ const last = items[items.length - 1] as Record<string, unknown> | undefined;
1887
+ const nextCursor =
1888
+ hasMore && last
1889
+ ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString(
1890
+ "base64",
1891
+ )
1892
+ : null;
1893
+ return { items, nextCursor, hasMore };
1894
+ }
1895
+
1896
+ /** Thin alias for `offset((page-1)*perPage).limit(perPage)`. */
1897
+ forPage(page: number, perPage: number): this {
1898
+ const p = Math.max(1, Math.floor(page));
1899
+ const pp = Math.max(1, Math.floor(perPage));
1900
+ this.#offset = (p - 1) * pp;
1901
+ this.#limit = pp;
1902
+ return this;
1903
+ }
1904
+
1905
+ // === Story 29.11 — debug / toQuery / clone ========================================================
1906
+
1907
+ debug(flag = true): this {
1908
+ this.#debugFlag = flag;
1909
+ return this;
1910
+ }
1911
+
1912
+ /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
1913
+ toQuery(): string {
1914
+ const { sql, params } = this.toSQL();
1915
+ let i = 0;
1916
+ return sql.replace(/\?|\$\d+/g, () => {
1917
+ const v = params[i++];
1918
+ return this.#literalEscape(v);
1919
+ });
1920
+ }
1921
+
1922
+ /** Deep clone of this query — mutations on the clone never affect the original. */
1923
+ clone(): ModelQuery<T> {
1924
+ const c = new ModelQuery<T>(
1925
+ this.#tableName,
1926
+ this.#db,
1927
+ this.#hydrateFn,
1928
+ this.#entityClass,
1929
+ this.#resolveColumn,
1930
+ this.#softDeletes,
1931
+ this.#dialect,
1932
+ );
1933
+ c.#softScope = this.#softScope;
1934
+ c.#wheres = structuredCloneSafe(this.#wheres);
1935
+ c.#orderBys = [...this.#orderBys];
1936
+ c.#select = [...this.#select];
1937
+ c.#limit = this.#limit;
1938
+ c.#offset = this.#offset;
1939
+ c.#preloads = new Map(this.#preloads);
1940
+ c.#selectSubqueries = structuredClone(this.#selectSubqueries);
1941
+ c.#joins = [...this.#joins];
1942
+ c.#lockMode = this.#lockMode;
1943
+ c.#distinct = this.#distinct;
1944
+ c.#debugFlag = this.#debugFlag;
1945
+ return c;
1946
+ }
1947
+
1948
+ // === Story 30.2 — update / delete fluent ===========================================================
1949
+
1950
+ /** Execute a fluent UPDATE. Returns affected rows (or rows when `returning` is set). */
1951
+ async update(
1952
+ patch: Record<string, unknown>,
1953
+ returning?: string[],
1954
+ ): Promise<number | Record<string, unknown>[]> {
1955
+ if (!patch || Object.keys(patch).length === 0) {
1956
+ throw new Error("update() requires a non-empty payload");
1957
+ }
1958
+ const setPairs = Object.entries(patch).map(
1959
+ ([k, v]) => [this.#resolveColumn(k), v] as [string, unknown],
1960
+ );
1961
+ const spec = {
1962
+ kind: "update",
1963
+ table: this.#tableName,
1964
+ set: setPairs,
1965
+ wheres: this.#wheresForDml(),
1966
+ returning: returning ?? [],
1967
+ };
1968
+ const compiled = compileStatementNative(spec, this.#dialect);
1969
+ if (returning && returning.length > 0) {
1970
+ return this.#db.query<Record<string, unknown>>(
1971
+ compiled.statements[0],
1972
+ compiled.params,
1973
+ );
1974
+ }
1975
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
1976
+ return r.rowsAffected ?? 0;
1977
+ }
1978
+
1979
+ /** Execute a fluent DELETE. Returns affected rows (or rows when `returning` is set). */
1980
+ async delete(
1981
+ returning?: string[],
1982
+ ): Promise<number | Record<string, unknown>[]> {
1983
+ const spec = {
1984
+ kind: "delete",
1985
+ table: this.#tableName,
1986
+ wheres: this.#wheresForDml(),
1987
+ returning: returning ?? [],
1988
+ };
1989
+ const compiled = compileStatementNative(spec, this.#dialect);
1990
+ if (returning && returning.length > 0) {
1991
+ return this.#db.query<Record<string, unknown>>(
1992
+ compiled.statements[0],
1993
+ compiled.params,
1994
+ );
1995
+ }
1996
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
1997
+ return r.rowsAffected ?? 0;
1998
+ }
1999
+
2000
+ // === Story 30.3 — increment / decrement already implemented? check ================================
2001
+
2002
+ increment(column: string, amount: number): Promise<number>;
2003
+ increment(patch: Record<string, number>): Promise<number>;
2004
+ increment(
2005
+ colOrPatch: string | Record<string, number>,
2006
+ amount = 1,
2007
+ ): Promise<number> {
2008
+ return this.#runIncDec("increment", colOrPatch, amount);
2009
+ }
2010
+
2011
+ decrement(column: string, amount: number): Promise<number>;
2012
+ decrement(patch: Record<string, number>): Promise<number>;
2013
+ decrement(
2014
+ colOrPatch: string | Record<string, number>,
2015
+ amount = 1,
2016
+ ): Promise<number> {
2017
+ return this.#runIncDec("decrement", colOrPatch, amount);
2018
+ }
2019
+
2020
+ // === Story 30.8 — forUpdate / forShare =============================================================
2021
+
2022
+ forUpdate(): this {
2023
+ if (this.#dialect === "sqlite") {
2024
+ console.warn(
2025
+ "[atlas] forUpdate ignored on sqlite (no row-level lock support)",
2026
+ );
2027
+ } else {
2028
+ this.#lockMode = "FOR UPDATE";
2029
+ }
2030
+ return this;
2031
+ }
2032
+
2033
+ forShare(): this {
2034
+ if (this.#dialect === "sqlite") {
2035
+ console.warn(
2036
+ "[atlas] forShare ignored on sqlite (no row-level lock support)",
2037
+ );
2038
+ } else {
2039
+ this.#lockMode = "FOR SHARE";
2040
+ }
2041
+ return this;
2042
+ }
2043
+
2044
+ // === Private helpers ==============================================================================
2045
+
2046
+ #quote(name: string): string {
2047
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
2048
+ }
2049
+
2050
+ /** Quote a `table.column` reference on both sides of the dot. */
2051
+ #quoteCol(ref: string): string {
2052
+ if (ref.includes(".")) {
2053
+ const [t, c] = ref.split(".", 2);
2054
+ return `${this.#quote(t)}.${this.#quote(c)}`;
2055
+ }
2056
+ return this.#quote(ref);
2057
+ }
2058
+
2059
+ #pushJoin(
2060
+ kind: "INNER" | "LEFT" | "RIGHT",
2061
+ table: string,
2062
+ leftOrBuild: string | ((j: JoinBuilder) => void),
2063
+ right?: string,
2064
+ ): this {
2065
+ const tq = this.#quote(table);
2066
+ if (typeof leftOrBuild === "function") {
2067
+ const jb: JoinBuilder = {
2068
+ parts: [],
2069
+ on(l: string, r: string) {
2070
+ this.parts.push({ kind: "and", left: l, right: r });
2071
+ return this;
2072
+ },
2073
+ andOn(l: string, r: string) {
2074
+ this.parts.push({ kind: "and", left: l, right: r });
2075
+ return this;
2076
+ },
2077
+ andOnVal(l: string, _v: unknown) {
2078
+ this.parts.push({ kind: "and", left: l, right: "?" });
2079
+ return this;
2080
+ },
2081
+ };
2082
+ leftOrBuild(jb);
2083
+ const on = jb.parts
2084
+ .map((p, i) => {
2085
+ const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
2086
+ return `${prefix} ${this.#quoteCol(p.left)} = ${p.right === "?" ? "?" : this.#quoteCol(p.right)}`;
2087
+ })
2088
+ .join(" ");
2089
+ this.#joins.push(`${kind} JOIN ${tq} ${on}`);
2090
+ return this;
2091
+ }
2092
+ if (right === undefined)
2093
+ throw new Error(
2094
+ "join() with string form requires both left and right operands",
2095
+ );
2096
+ this.#joins.push(
2097
+ `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
2098
+ );
2099
+ return this;
2100
+ }
2101
+
2102
+ async #runScalar(expr: string): Promise<unknown> {
2103
+ const clone = this.clone();
2104
+ clone.#select = [`${expr} AS __scalar__`];
2105
+ clone.#orderBys = [];
2106
+ const { sql, params } = clone.toSQL();
2107
+ const rows = await this.#db.query<Record<string, unknown>>(sql, params);
2108
+ const row = rows[0];
2109
+ return row ? row.__scalar__ : null;
2110
+ }
2111
+
2112
+ async #runIncDec(
2113
+ op: "increment" | "decrement",
2114
+ colOrPatch: string | Record<string, number>,
2115
+ amount: number,
2116
+ ): Promise<number> {
2117
+ const patch =
2118
+ typeof colOrPatch === "string" ? { [colOrPatch]: amount } : colOrPatch;
2119
+ const setPairs = Object.entries(patch).map(
2120
+ ([k, v]) =>
2121
+ [this.#resolveColumn(k), { op, value: v }] as [
2122
+ string,
2123
+ { op: string; value: number },
2124
+ ],
2125
+ );
2126
+ const spec = {
2127
+ kind: "update",
2128
+ table: this.#tableName,
2129
+ set: setPairs,
2130
+ wheres: this.#wheresForDml(),
2131
+ returning: [],
2132
+ };
2133
+ const compiled = compileStatementNative(spec, this.#dialect);
2134
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
2135
+ return r.rowsAffected ?? 0;
2136
+ }
2137
+
2138
+ /**
2139
+ * Flatten the SELECT wheres to DML-compatible wheres. Standard predicates
2140
+ * and `whereRaw` fragments pass through; `group` / `exists` / `inSub` are
2141
+ * still rejected because the DML compiler's WHERE lowering does not yet
2142
+ * handle nested sub-queries or correlated EXISTS.
2143
+ */
2144
+ #wheresForDml(): Array<Record<string, unknown>> {
2145
+ const out: Array<Record<string, unknown>> = [];
2146
+ for (const w of this.#wheres) {
2147
+ if ("kind" in w) {
2148
+ if (w.kind === "raw") {
2149
+ out.push({
2150
+ kind: "raw",
2151
+ sql: w.sql,
2152
+ bindings: w.bindings,
2153
+ type: w.type,
2154
+ });
2155
+ continue;
2156
+ }
2157
+ throw new Error(
2158
+ `update/delete do not support '${w.kind}' WHERE clauses. ` +
2159
+ `Supported: plain predicates and whereRaw. Use a raw UPDATE/DELETE for complex criteria.`,
2160
+ );
2161
+ }
2162
+ out.push({
2163
+ column: w.column,
2164
+ operator: w.operator,
2165
+ value: w.value,
2166
+ type: w.type,
2167
+ });
2168
+ }
2169
+ return out;
2170
+ }
2171
+
2172
+ /**
2173
+ * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
2174
+ *
2175
+ * Produces a human-readable SQL rendering with bindings inlined. The escape
2176
+ * strategy (double single-quotes) is NOT safe against backslash-based injection
2177
+ * on MySQL or on PostgreSQL with `standard_conforming_strings = off`: the
2178
+ * sequence `\'` closes the string literal and opens an injection vector.
2179
+ *
2180
+ * This function exists ONLY to back `.toQuery()` for copy-paste debugging and
2181
+ * log readability. The production execution path always goes through bound
2182
+ * parameters via the Rust compiler — this escaper is never on the hot path.
2183
+ * If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
2184
+ */
2185
+ #literalEscape(v: unknown): string {
2186
+ if (v === null || v === undefined) return "NULL";
2187
+ if (typeof v === "number") return String(v);
2188
+ if (typeof v === "boolean") return v ? "1" : "0";
2189
+ if (v instanceof Date) return `'${v.toISOString()}'`;
2190
+ // Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
2191
+ return `'${String(v).replace(/'/g, "''")}'`;
2192
+ }
2193
+
2194
+ /**
2195
+ * Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
2196
+ * on the SAME table is used as the scratch builder so the callback can call
2197
+ * any of the usual where* methods, including nested `where(cb)` for deeper
2198
+ * groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
2199
+ */
2200
+ #buildGroup(type: "and" | "or", callback: WhereCallback): GroupWhere {
2201
+ const scratch = new ModelQuery<BaseEntity>(
2202
+ this.#tableName,
2203
+ this.#db,
2204
+ (row) => row as BaseEntity,
2205
+ this.#entityClass as new () => BaseEntity,
2206
+ this.#resolveColumn,
2207
+ false,
2208
+ this.#dialect,
2209
+ );
2210
+ callback(scratch);
2211
+ return { type, kind: "group", conditions: scratch.#wheres };
2212
+ }
2213
+
2214
+ #buildExistsClause(
2215
+ type: "and" | "or",
2216
+ negated: boolean,
2217
+ relationName: string,
2218
+ callback?: (q: ModelQuery<BaseEntity>) => void,
2219
+ countOp?: string,
2220
+ countThreshold?: number,
2221
+ ): ExistsWhere {
2222
+ const sub = this.#makeRelationSub(relationName);
2223
+ if (callback) callback(sub);
2224
+ const spec = sub.#buildSpec();
2225
+ if (countOp !== undefined && countThreshold !== undefined) {
2226
+ spec.having = [
2227
+ {
2228
+ column: "COUNT(*)",
2229
+ operator: countOp,
2230
+ value: countThreshold,
2231
+ type: "and",
2232
+ },
2233
+ ];
2234
+ }
2235
+ return { type, kind: "exists", negated, subquery: spec };
2236
+ }
2237
+
2238
+ #pushWhere(
2239
+ type: "and" | "or",
2240
+ column: string,
2241
+ operatorOrValue: unknown,
2242
+ value: unknown,
2243
+ ): this {
2244
+ const resolved = this.#resolveColumn(column);
2245
+ if (value === undefined) {
2246
+ // 2-arg form: where(col, value). A `null` value means the caller
2247
+ // wants an IS NULL test — `= ?` bound to null never matches in
2248
+ // SQL, silently returning zero rows. Mirror whereNull().
2249
+ if (operatorOrValue === null) {
2250
+ this.#wheres.push({
2251
+ type,
2252
+ column: resolved,
2253
+ operator: "IS NULL",
2254
+ value: null,
2255
+ });
2256
+ return this;
2257
+ }
2258
+ this.#wheres.push({
2259
+ type,
2260
+ column: resolved,
2261
+ operator: "=",
2262
+ value: operatorOrValue,
2263
+ });
2264
+ } else {
2265
+ this.#wheres.push({
2266
+ type,
2267
+ column: resolved,
2268
+ operator: operatorOrValue as string,
2269
+ value,
2270
+ });
2271
+ }
2272
+ return this;
2273
+ }
2274
+
2275
+ /**
2276
+ * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
2277
+ * Used by the nested-preload machinery to recurse without re-running the root select.
2278
+ */
2279
+ async #resolveAgainst(
2280
+ entities: BaseEntity[],
2281
+ entityClass: new () => BaseEntity,
2282
+ ): Promise<void> {
2283
+ // Temporarily swap the entity class so resolvePreloads looks up the right metadata.
2284
+ // Cast is safe because resolvePreloads only reads metadata + writes via setProp.
2285
+ const prevClass = this.#entityClass;
2286
+ this.#entityClass = entityClass as new () => T;
2287
+ try {
2288
+ await this.#resolvePreloads(entities as T[]);
2289
+ } finally {
2290
+ this.#entityClass = prevClass;
2291
+ }
2292
+ }
2293
+ }