@c9up/atlas 0.1.3 → 0.1.5

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 (150) hide show
  1. package/db.darwin-arm64.node +0 -0
  2. package/db.darwin-x64.node +0 -0
  3. package/db.linux-arm64-gnu.node +0 -0
  4. package/db.linux-x64-gnu.node +0 -0
  5. package/db.win32-x64-msvc.node +0 -0
  6. package/dist/AtlasProvider.d.ts +106 -0
  7. package/dist/AtlasProvider.d.ts.map +1 -0
  8. package/dist/AtlasProvider.js +190 -0
  9. package/dist/AtlasProvider.js.map +1 -0
  10. package/dist/BaseEntity.d.ts +255 -0
  11. package/dist/BaseEntity.d.ts.map +1 -0
  12. package/dist/BaseEntity.js +420 -0
  13. package/dist/BaseEntity.js.map +1 -0
  14. package/dist/BaseRepository.d.ts +175 -0
  15. package/dist/BaseRepository.d.ts.map +1 -0
  16. package/dist/BaseRepository.js +1451 -0
  17. package/dist/BaseRepository.js.map +1 -0
  18. package/dist/ModelQuery.d.ts +338 -0
  19. package/dist/ModelQuery.d.ts.map +1 -0
  20. package/dist/ModelQuery.js +1598 -0
  21. package/dist/ModelQuery.js.map +1 -0
  22. package/dist/Transaction.d.ts +15 -0
  23. package/dist/Transaction.d.ts.map +1 -0
  24. package/dist/Transaction.js +67 -0
  25. package/dist/Transaction.js.map +1 -0
  26. package/dist/adapters/NapiDbAdapter.d.ts +34 -0
  27. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -0
  28. package/dist/adapters/NapiDbAdapter.js +96 -0
  29. package/dist/adapters/NapiDbAdapter.js.map +1 -0
  30. package/dist/config.d.ts +4 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +4 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/configure.d.ts +10 -0
  35. package/dist/configure.d.ts.map +1 -0
  36. package/dist/configure.js +25 -0
  37. package/dist/configure.js.map +1 -0
  38. package/dist/decorators/entity.d.ts +235 -0
  39. package/dist/decorators/entity.d.ts.map +1 -0
  40. package/dist/decorators/entity.js +264 -0
  41. package/dist/decorators/entity.js.map +1 -0
  42. package/dist/decorators/hooks.d.ts +70 -0
  43. package/dist/decorators/hooks.d.ts.map +1 -0
  44. package/dist/decorators/hooks.js +107 -0
  45. package/dist/decorators/hooks.js.map +1 -0
  46. package/dist/decorators/scope.d.ts +35 -0
  47. package/dist/decorators/scope.d.ts.map +1 -0
  48. package/dist/decorators/scope.js +29 -0
  49. package/dist/decorators/scope.js.map +1 -0
  50. package/dist/errors.d.ts +54 -0
  51. package/dist/errors.d.ts.map +1 -0
  52. package/dist/errors.js +82 -0
  53. package/dist/errors.js.map +1 -0
  54. package/dist/index.d.ts +39 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +28 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/naming/NamingStrategy.d.ts +52 -0
  59. package/dist/naming/NamingStrategy.d.ts.map +1 -0
  60. package/dist/naming/NamingStrategy.js +65 -0
  61. package/dist/naming/NamingStrategy.js.map +1 -0
  62. package/dist/query/QueryBuilder.d.ts +95 -0
  63. package/dist/query/QueryBuilder.d.ts.map +1 -0
  64. package/dist/query/QueryBuilder.js +310 -0
  65. package/dist/query/QueryBuilder.js.map +1 -0
  66. package/dist/query/native.d.ts +18 -0
  67. package/dist/query/native.d.ts.map +1 -0
  68. package/dist/query/native.js +49 -0
  69. package/dist/query/native.js.map +1 -0
  70. package/dist/schema/Migration.d.ts +55 -0
  71. package/dist/schema/Migration.d.ts.map +1 -0
  72. package/dist/schema/Migration.js +68 -0
  73. package/dist/schema/Migration.js.map +1 -0
  74. package/dist/schema/MigrationRunner.d.ts +115 -0
  75. package/dist/schema/MigrationRunner.d.ts.map +1 -0
  76. package/dist/schema/MigrationRunner.js +406 -0
  77. package/dist/schema/MigrationRunner.js.map +1 -0
  78. package/dist/schema/Schema.d.ts +22 -0
  79. package/dist/schema/Schema.d.ts.map +1 -0
  80. package/dist/schema/Schema.js +51 -0
  81. package/dist/schema/Schema.js.map +1 -0
  82. package/dist/schema/SchemaBuilder.d.ts +14 -0
  83. package/dist/schema/SchemaBuilder.d.ts.map +1 -0
  84. package/dist/schema/SchemaBuilder.js +13 -0
  85. package/dist/schema/SchemaBuilder.js.map +1 -0
  86. package/dist/schema/Seeder.d.ts +54 -0
  87. package/dist/schema/Seeder.d.ts.map +1 -0
  88. package/dist/schema/Seeder.js +89 -0
  89. package/dist/schema/Seeder.js.map +1 -0
  90. package/dist/schema/TableBuilder.d.ts +95 -0
  91. package/dist/schema/TableBuilder.d.ts.map +1 -0
  92. package/dist/schema/TableBuilder.js +234 -0
  93. package/dist/schema/TableBuilder.js.map +1 -0
  94. package/dist/schema/types.d.ts +30 -0
  95. package/dist/schema/types.d.ts.map +1 -0
  96. package/dist/schema/types.js +19 -0
  97. package/dist/schema/types.js.map +1 -0
  98. package/dist/services/db.d.ts +22 -0
  99. package/dist/services/db.d.ts.map +1 -0
  100. package/dist/services/db.js +37 -0
  101. package/dist/services/db.js.map +1 -0
  102. package/dist/testing/DatabaseCleanup.d.ts +19 -0
  103. package/dist/testing/DatabaseCleanup.d.ts.map +1 -0
  104. package/dist/testing/DatabaseCleanup.js +39 -0
  105. package/dist/testing/DatabaseCleanup.js.map +1 -0
  106. package/dist/testing/Factory.d.ts +59 -0
  107. package/dist/testing/Factory.d.ts.map +1 -0
  108. package/dist/testing/Factory.js +105 -0
  109. package/dist/testing/Factory.js.map +1 -0
  110. package/dist/testing/TestDatabase.d.ts +25 -0
  111. package/dist/testing/TestDatabase.d.ts.map +1 -0
  112. package/dist/testing/TestDatabase.js +52 -0
  113. package/dist/testing/TestDatabase.js.map +1 -0
  114. package/dist/testing/index.d.ts +4 -0
  115. package/dist/testing/index.d.ts.map +1 -0
  116. package/dist/testing/index.js +4 -0
  117. package/dist/testing/index.js.map +1 -0
  118. package/dist/utils/casing.d.ts +5 -0
  119. package/dist/utils/casing.d.ts.map +1 -0
  120. package/dist/utils/casing.js +9 -0
  121. package/dist/utils/casing.js.map +1 -0
  122. package/dist/utils/dialectFromUrl.d.ts +10 -0
  123. package/dist/utils/dialectFromUrl.d.ts.map +1 -0
  124. package/dist/utils/dialectFromUrl.js +8 -0
  125. package/dist/utils/dialectFromUrl.js.map +1 -0
  126. package/dist/utils/identifier.d.ts +19 -0
  127. package/dist/utils/identifier.d.ts.map +1 -0
  128. package/dist/utils/identifier.js +27 -0
  129. package/dist/utils/identifier.js.map +1 -0
  130. package/dist/utils/safePath.d.ts +22 -0
  131. package/dist/utils/safePath.d.ts.map +1 -0
  132. package/dist/utils/safePath.js +46 -0
  133. package/dist/utils/safePath.js.map +1 -0
  134. package/dist/utils/transactionBrand.d.ts +5 -0
  135. package/dist/utils/transactionBrand.d.ts.map +1 -0
  136. package/dist/utils/transactionBrand.js +5 -0
  137. package/dist/utils/transactionBrand.js.map +1 -0
  138. package/index.darwin-arm64.node +0 -0
  139. package/index.darwin-x64.node +0 -0
  140. package/index.linux-arm64-gnu.node +0 -0
  141. package/index.linux-x64-gnu.node +0 -0
  142. package/index.win32-x64-msvc.node +0 -0
  143. package/package.json +1 -1
  144. package/src/AtlasProvider.ts +2 -2
  145. package/src/BaseRepository.ts +73 -11
  146. package/src/adapters/NapiDbAdapter.ts +1 -1
  147. package/src/schema/MigrationRunner.ts +16 -12
  148. package/src/schema/TableBuilder.ts +26 -0
  149. package/src/schema/types.ts +2 -0
  150. package/src/testing/DatabaseCleanup.ts +6 -7
@@ -0,0 +1,1598 @@
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
+ var _a;
10
+ import { getEntityMetadata, getPrimaryKey, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
11
+ import { compileStatementNative, getAtlasDialect, } from "./query/native.js";
12
+ import { camelToSnake, snakeToCamel } from "./utils/casing.js";
13
+ /**
14
+ * Comparison operators allowed in `whereExpr`'s raw 4-arg form (where
15
+ * `op` is interpolated into SQL rather than parameterized). Kept tight
16
+ * to operators that take a single bound `?` value — IN / IS NULL etc.
17
+ * have no place in this helper.
18
+ */
19
+ const WHEREEXPR_OPERATORS = new Set([
20
+ "=",
21
+ "!=",
22
+ "<>",
23
+ ">",
24
+ ">=",
25
+ "<",
26
+ "<=",
27
+ "LIKE",
28
+ "NOT LIKE",
29
+ ]);
30
+ /** True when every `(` in `s` has a matching `)` and none closes early. */
31
+ function hasBalancedParens(s) {
32
+ let depth = 0;
33
+ for (const ch of s) {
34
+ if (ch === "(")
35
+ depth++;
36
+ else if (ch === ")") {
37
+ depth--;
38
+ if (depth < 0)
39
+ return false;
40
+ }
41
+ }
42
+ return depth === 0;
43
+ }
44
+ /** Set an empty relation value on every parent and return no related rows. */
45
+ function assignEmptyRelation(entities, relationName, single) {
46
+ for (const e of entities)
47
+ e.setProp(relationName, single ? null : []);
48
+ return [];
49
+ }
50
+ /**
51
+ * Map each intermediate row's `secondLocal` key to its parent id (`firstKey`).
52
+ * Throws when two intermediate rows share a key but point at different parents
53
+ * — a non-unique `secondLocalKey` would otherwise silently drop data.
54
+ */
55
+ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
56
+ const throughToParent = new Map();
57
+ for (const row of throughRows) {
58
+ const key = row[secondLocal];
59
+ if (throughToParent.has(key) &&
60
+ throughToParent.get(key) !== row[firstKey]) {
61
+ throw new Error(`@HasManyThrough/@HasOneThrough '${err.relationName}': duplicate secondLocalKey='${String(key)}' ` +
62
+ `on ${err.throughTable} maps to multiple parents. Either set secondLocalKey to a unique column ` +
63
+ `(default: ${err.throughClass}.${err.throughPk}) or fix the underlying data.`);
64
+ }
65
+ throughToParent.set(key, row[firstKey]);
66
+ }
67
+ return throughToParent;
68
+ }
69
+ /**
70
+ * Process-wide strict mode flag. When enabled, `whereRaw()` and `joinRaw()`
71
+ * throw unconditionally — forcing every call site to use the typed
72
+ * `whereExpr()` / `joinOn()` / structured builder paths. Intended for prod
73
+ * hardening on apps that can't audit every call site manually.
74
+ *
75
+ * Enable via:
76
+ * - `setAtlasStrictMode(true)` at app bootstrap
77
+ * - `ATLAS_STRICT=1` environment variable (picked up lazily on first call)
78
+ *
79
+ * Framework-internal call sites that legitimately need raw SQL (relation
80
+ * resolvers, preload join predicates) bypass strict mode via the private
81
+ * `__internal: true` flag on the call — not exposed in the public types.
82
+ */
83
+ let atlasStrictMode;
84
+ /** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
85
+ export function setAtlasStrictMode(enabled) {
86
+ atlasStrictMode = enabled;
87
+ }
88
+ /** Current strict mode state — lazy env var read on first access. */
89
+ export function isAtlasStrictMode() {
90
+ if (atlasStrictMode === undefined) {
91
+ atlasStrictMode =
92
+ process.env.ATLAS_STRICT === "1" || process.env.ATLAS_STRICT === "true";
93
+ }
94
+ return atlasStrictMode;
95
+ }
96
+ /**
97
+ * Module-local escape hatch. Framework internal code (relation proxies,
98
+ * preload resolvers) sets this to `true` around a section where it legitimately
99
+ * needs to call whereRaw/joinRaw. Reset to `false` in a `finally` block.
100
+ * Not exposed from the package barrel — only accessible to files in this module.
101
+ */
102
+ let atlasInternalBypass = false;
103
+ export function runWithAtlasInternalBypass(fn) {
104
+ const prev = atlasInternalBypass;
105
+ atlasInternalBypass = true;
106
+ try {
107
+ return fn();
108
+ }
109
+ finally {
110
+ atlasInternalBypass = prev;
111
+ }
112
+ }
113
+ function isInternalBypass() {
114
+ return atlasInternalBypass;
115
+ }
116
+ /** Offset-based paginator (Story 29.10). */
117
+ export class Paginator {
118
+ items;
119
+ meta;
120
+ #baseUrl;
121
+ #queryString = {};
122
+ constructor(items, base) {
123
+ this.items = items;
124
+ const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
125
+ this.meta = { ...base, lastPage, firstPage: 1 };
126
+ }
127
+ all() {
128
+ return this.items;
129
+ }
130
+ serialize(opts) {
131
+ const data = this.items.map((item) => {
132
+ if (!opts?.fields)
133
+ return item;
134
+ const picked = {};
135
+ for (const f of opts.fields)
136
+ picked[f] = item[f];
137
+ return picked;
138
+ });
139
+ return { data, meta: this.meta };
140
+ }
141
+ baseUrl(url) {
142
+ this.#baseUrl = url;
143
+ return this;
144
+ }
145
+ queryString(qs) {
146
+ this.#queryString = qs;
147
+ return this;
148
+ }
149
+ toJSON() {
150
+ const meta = {
151
+ ...this.meta,
152
+ };
153
+ if (this.#baseUrl) {
154
+ const build = (page) => {
155
+ const params = new URLSearchParams();
156
+ for (const [k, v] of Object.entries(this.#queryString))
157
+ params.set(k, String(v));
158
+ params.set("page", String(page));
159
+ return `${this.#baseUrl}?${params.toString()}`;
160
+ };
161
+ meta.firstPageUrl = build(1);
162
+ meta.lastPageUrl = build(this.meta.lastPage);
163
+ if (this.meta.currentPage < this.meta.lastPage)
164
+ meta.nextPageUrl = build(this.meta.currentPage + 1);
165
+ if (this.meta.currentPage > 1)
166
+ meta.previousPageUrl = build(this.meta.currentPage - 1);
167
+ }
168
+ return { data: this.items, meta };
169
+ }
170
+ }
171
+ /** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
172
+ function structuredCloneSafe(value) {
173
+ return structuredClone(value);
174
+ }
175
+ export class ModelQuery {
176
+ #tableName;
177
+ #db;
178
+ #hydrateFn;
179
+ #entityClass;
180
+ #resolveColumn;
181
+ #softDeletes;
182
+ #softScope = "default";
183
+ #wheres = [];
184
+ #orderBys = [];
185
+ #select = ["*"];
186
+ #limit;
187
+ #offset;
188
+ #preloads = new Map();
189
+ /** Correlated subquery projections (withCount / withAggregate). */
190
+ #selectSubqueries = [];
191
+ /** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
192
+ #subqueryAlias;
193
+ /** Raw JOIN fragments — Story 29.4. */
194
+ #joins = [];
195
+ /** Row lock mode — Story 30.8. */
196
+ #lockMode = null;
197
+ /** Per-query debug flag — Story 29.11. */
198
+ #debugFlag = false;
199
+ /** Distinct flag — Story 29.5. */
200
+ #distinct = false;
201
+ /** SQL dialect for compilation — inherited from the owning BaseRepository. */
202
+ #dialect;
203
+ constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect()) {
204
+ this.#tableName = tableName;
205
+ this.#db = db;
206
+ this.#hydrateFn = hydrateFn;
207
+ this.#entityClass = entityClass;
208
+ this.#resolveColumn = resolveColumn;
209
+ this.#softDeletes = softDeletes;
210
+ this.#dialect = dialect;
211
+ }
212
+ /** Include soft-deleted rows in the result (default behavior excludes them). */
213
+ withTrashed() {
214
+ this.#softScope = "with-trashed";
215
+ return this;
216
+ }
217
+ /** Return ONLY soft-deleted rows (deleted_at IS NOT NULL). */
218
+ onlyTrashed() {
219
+ this.#softScope = "only-trashed";
220
+ return this;
221
+ }
222
+ /**
223
+ * Eager-load a relation (AdonisJS-style).
224
+ * Relations are never loaded automatically — you must call .preload() explicitly.
225
+ *
226
+ * Usage:
227
+ * repo.query().preload('posts').exec()
228
+ * repo.query().preload('posts', q => q.where('published', true)).exec()
229
+ */
230
+ preload(relationName, callback) {
231
+ this.#preloads.set(relationName, callback);
232
+ return this;
233
+ }
234
+ /** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
235
+ select(columns) {
236
+ this.#select = Array.isArray(columns)
237
+ ? columns
238
+ : columns.split(",").map((c) => c.trim());
239
+ return this;
240
+ }
241
+ where(columnOrCb, operatorOrValue, value) {
242
+ if (typeof columnOrCb === "function") {
243
+ this.#wheres.push(this.#buildGroup("and", columnOrCb));
244
+ return this;
245
+ }
246
+ return this.#pushWhere("and", columnOrCb, operatorOrValue, value);
247
+ }
248
+ orWhere(columnOrCb, operatorOrValue, value) {
249
+ if (typeof columnOrCb === "function") {
250
+ this.#wheres.push(this.#buildGroup("or", columnOrCb));
251
+ return this;
252
+ }
253
+ return this.#pushWhere("or", columnOrCb, operatorOrValue, value);
254
+ }
255
+ whereNull(column) {
256
+ this.#wheres.push({
257
+ type: "and",
258
+ column: this.#resolveColumn(column),
259
+ operator: "IS NULL",
260
+ value: null,
261
+ });
262
+ return this;
263
+ }
264
+ whereNotNull(column) {
265
+ this.#wheres.push({
266
+ type: "and",
267
+ column: this.#resolveColumn(column),
268
+ operator: "IS NOT NULL",
269
+ value: null,
270
+ });
271
+ return this;
272
+ }
273
+ /** `WHERE col != ?` — negation of `where`. */
274
+ whereNot(column, value) {
275
+ this.#wheres.push({
276
+ type: "and",
277
+ column: this.#resolveColumn(column),
278
+ operator: "!=",
279
+ value,
280
+ });
281
+ return this;
282
+ }
283
+ /** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
284
+ whereIn(column, source) {
285
+ if (source instanceof _a) {
286
+ this.#wheres.push({
287
+ type: "and",
288
+ kind: "inSub",
289
+ negated: false,
290
+ column: this.#resolveColumn(column),
291
+ subquery: source.#buildSpec(),
292
+ });
293
+ return this;
294
+ }
295
+ this.#wheres.push({
296
+ type: "and",
297
+ column: this.#resolveColumn(column),
298
+ operator: "IN",
299
+ value: [...source],
300
+ });
301
+ return this;
302
+ }
303
+ /** `WHERE col NOT IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
304
+ whereNotIn(column, source) {
305
+ if (source instanceof _a) {
306
+ this.#wheres.push({
307
+ type: "and",
308
+ kind: "inSub",
309
+ negated: true,
310
+ column: this.#resolveColumn(column),
311
+ subquery: source.#buildSpec(),
312
+ });
313
+ return this;
314
+ }
315
+ this.#wheres.push({
316
+ type: "and",
317
+ column: this.#resolveColumn(column),
318
+ operator: "NOT IN",
319
+ value: [...source],
320
+ });
321
+ return this;
322
+ }
323
+ /** `WHERE col BETWEEN ? AND ?` — inclusive range. */
324
+ whereBetween(column, range) {
325
+ this.#wheres.push({
326
+ type: "and",
327
+ column: this.#resolveColumn(column),
328
+ operator: "BETWEEN",
329
+ value: [...range],
330
+ });
331
+ return this;
332
+ }
333
+ /** `WHERE col NOT BETWEEN ? AND ?` */
334
+ whereNotBetween(column, range) {
335
+ this.#wheres.push({
336
+ type: "and",
337
+ column: this.#resolveColumn(column),
338
+ operator: "NOT BETWEEN",
339
+ value: [...range],
340
+ });
341
+ return this;
342
+ }
343
+ /** `WHERE col LIKE ?` — case-sensitive pattern match. */
344
+ whereLike(column, pattern) {
345
+ this.#wheres.push({
346
+ type: "and",
347
+ column: this.#resolveColumn(column),
348
+ operator: "LIKE",
349
+ value: pattern,
350
+ });
351
+ return this;
352
+ }
353
+ /**
354
+ * `WHERE col ILIKE ?` — case-insensitive pattern match. Uses native ILIKE
355
+ * on PostgreSQL; the Rust compiler rewrites it to `LOWER(col) LIKE LOWER(?)`
356
+ * on SQLite and MySQL at compile time.
357
+ */
358
+ whereILike(column, pattern) {
359
+ this.#wheres.push({
360
+ type: "and",
361
+ column: this.#resolveColumn(column),
362
+ operator: "ILIKE",
363
+ value: pattern,
364
+ });
365
+ return this;
366
+ }
367
+ /**
368
+ * **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
369
+ * `?`-style bindings. The Rust compiler re-indexes the placeholders so they
370
+ * don't clash with other clause params, but everything else in `sql` is
371
+ * trusted verbatim. Caller is responsible for the fragment's safety — all
372
+ * **values** must still go through `bindings`.
373
+ *
374
+ * Prefer `whereExpr()` for the common case of a column-referencing predicate
375
+ * where Atlas can handle the identifier quoting for you. Reach for
376
+ * `whereRaw` only when the SQL is a dialect-specific construct with no
377
+ * typed equivalent (window functions, `DATE_TRUNC`, vendor extensions…).
378
+ *
379
+ * query.whereRaw('total > ? AND created_at < ?', [100, '2026-01-01'])
380
+ *
381
+ * **Strict mode**: when `setAtlasStrictMode(true)` is active (or the
382
+ * `ATLAS_STRICT` env var is set), this method throws unless called via the
383
+ * framework-internal `__unsafeWhereRaw` path. Production apps should enable
384
+ * strict mode and rewrite call sites to use `whereExpr()` / structured
385
+ * builders.
386
+ *
387
+ * @unsafe Raw SQL fragment — never concatenate user input into `sql`.
388
+ */
389
+ whereRaw(sql, bindings = []) {
390
+ if (isAtlasStrictMode() && !isInternalBypass()) {
391
+ throw new Error("whereRaw() is disabled in Atlas strict mode. " +
392
+ "Use whereExpr() or a structured builder method instead. " +
393
+ "Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.");
394
+ }
395
+ return this.#pushWhereRaw(sql, bindings);
396
+ }
397
+ /**
398
+ * Framework-internal raw WHERE path — bypasses strict mode. Used by
399
+ * relation preload resolvers (join predicates, pivot correlations) and by
400
+ * the internal `whereExpr(col, extra, op, value)` helper, which has
401
+ * already validated the fragment against a safe charset.
402
+ *
403
+ * Not exported from the package barrel — only accessible inside the Atlas
404
+ * codebase via direct ModelQuery instance access.
405
+ */
406
+ #pushWhereRaw(sql, bindings = []) {
407
+ this.#wheres.push({
408
+ type: "and",
409
+ kind: "raw",
410
+ sql,
411
+ bindings: [...bindings],
412
+ });
413
+ return this;
414
+ }
415
+ whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
416
+ // 3-arg form: whereExpr(col, op, value)
417
+ // 4-arg form: whereExpr(col, extraExpr, op, value)
418
+ const hasExtra = maybeValue !== undefined;
419
+ const extra = hasExtra ? operatorOrExtra : "";
420
+ const op = hasExtra ? operatorOrValue : operatorOrExtra;
421
+ const value = hasExtra ? maybeValue : operatorOrValue;
422
+ if (hasExtra) {
423
+ if (!/^[A-Za-z0-9_() +\-*/,]+$/.test(extra)) {
424
+ throw new Error(`whereExpr: extraExpression '${extra}' contains forbidden characters. ` +
425
+ `Only [A-Za-z0-9_() +-*/,] are allowed. Use whereRaw() if you need more.`);
426
+ }
427
+ // The charset alone doesn't stop a structural break-out like
428
+ // `) OR (1` — require balanced parentheses so `extra` can't
429
+ // close the column's context and splice a new predicate.
430
+ if (!hasBalancedParens(extra)) {
431
+ throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
432
+ }
433
+ // `op` is interpolated raw into the fragment below, so it MUST be
434
+ // allow-listed — the 3-arg path gets this from the Rust operator
435
+ // validation, but the raw 4-arg path bypasses Rust and would
436
+ // otherwise let `op` inject (e.g. `'> 0 OR 1=1 --'`).
437
+ if (!WHEREEXPR_OPERATORS.has(op)) {
438
+ throw new Error(`whereExpr: operator '${op}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}, or whereRaw() for anything else.`);
439
+ }
440
+ }
441
+ const resolved = this.#resolveColumn(column);
442
+ // Route through the standard WHERE path so the Rust compiler quotes the
443
+ // column and validates the operator. For the extra-expression form we
444
+ // build a raw WHERE internally via #pushWhereRaw (strict-mode exempt) —
445
+ // but only AFTER we've validated the extra charset + paren balance AND
446
+ // the operator against the allow-list above.
447
+ if (hasExtra) {
448
+ const q = this.#quote(resolved);
449
+ return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [value]);
450
+ }
451
+ this.#wheres.push({ type: "and", column: resolved, operator: op, value });
452
+ return this;
453
+ }
454
+ /**
455
+ * `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
456
+ * by the existence of related rows, optionally constrained by a callback.
457
+ *
458
+ * userRepo.query().whereHas('comments', q => q.where('approved', true))
459
+ */
460
+ whereHas(relationName, callback) {
461
+ this.#wheres.push(this.#buildExistsClause("and", false, relationName, callback));
462
+ return this;
463
+ }
464
+ /** `OR WHERE EXISTS (...)` — composes with surrounding WHERE groups. */
465
+ orWhereHas(relationName, callback) {
466
+ this.#wheres.push(this.#buildExistsClause("or", false, relationName, callback));
467
+ return this;
468
+ }
469
+ /** `WHERE NOT EXISTS (...)` — negation of whereHas. */
470
+ whereDoesntHave(relationName, callback) {
471
+ this.#wheres.push(this.#buildExistsClause("and", true, relationName, callback));
472
+ return this;
473
+ }
474
+ orWhereDoesntHave(relationName, callback) {
475
+ this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
476
+ return this;
477
+ }
478
+ /**
479
+ * Short form of `whereHas`. With an operator + count, emits a count threshold:
480
+ * has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
481
+ * has('comments', '>', 2) → EXISTS (... HAVING COUNT(*) > ?)
482
+ */
483
+ has(relationName, countOp, countThreshold) {
484
+ this.#wheres.push(this.#buildExistsClause("and", false, relationName, undefined, countOp, countThreshold));
485
+ return this;
486
+ }
487
+ orHas(relationName, countOp, countThreshold) {
488
+ this.#wheres.push(this.#buildExistsClause("or", false, relationName, undefined, countOp, countThreshold));
489
+ return this;
490
+ }
491
+ /** `WHERE NOT EXISTS (...)` — short form. */
492
+ doesntHave(relationName) {
493
+ this.#wheres.push(this.#buildExistsClause("and", true, relationName));
494
+ return this;
495
+ }
496
+ /**
497
+ * Set this query's projection alias — only meaningful when this ModelQuery
498
+ * is used as the sub-builder callback argument of `withCount` / `withAggregate`.
499
+ * The outer query reads `#subqueryAlias` to rename the `$extras` key.
500
+ *
501
+ * repo.query().withCount('posts', q => q.as('published').where('published', true))
502
+ * // → $extras.published (instead of posts_count)
503
+ */
504
+ as(alias) {
505
+ this.#subqueryAlias = alias;
506
+ return this;
507
+ }
508
+ /** Read-only accessor used by lazy loaders to recover the alias set via `.as()`. */
509
+ get subqueryAlias() {
510
+ return this.#subqueryAlias;
511
+ }
512
+ /** Read-only accessor used by lazy loaders to list the aliases projected by withCount/withAggregate. */
513
+ get projectedAliases() {
514
+ return this.#selectSubqueries.map((s) => s.alias);
515
+ }
516
+ // --- Sub-builder aggregate setters (used inside withCount / withAggregate callbacks) ---
517
+ /** Set this sub-builder's SELECT to an aggregate expression. Used inside `withAggregate` callbacks. */
518
+ selectAggregate(kind, column = "*") {
519
+ const fn = kind.toUpperCase();
520
+ if (column === "*") {
521
+ this.#select = [`${fn}(*)`];
522
+ }
523
+ else {
524
+ this.#select = [`${fn}(${this.#resolveColumn(column)})`];
525
+ }
526
+ return this;
527
+ }
528
+ // --- Top-level scalar executors (Story 29.5) ---
529
+ /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
530
+ async count(column = "*") {
531
+ const expr = column === "*" ? "COUNT(*)" : `COUNT(${this.#quoteCol(column)})`;
532
+ return Number((await this.#runScalar(expr)) ?? 0);
533
+ }
534
+ async sum(column) {
535
+ const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
536
+ return v === null || v === undefined ? null : Number(v);
537
+ }
538
+ async avg(column) {
539
+ const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
540
+ return v === null || v === undefined ? null : Number(v);
541
+ }
542
+ async min(column) {
543
+ const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
544
+ return v === null || v === undefined ? null : Number(v);
545
+ }
546
+ async max(column) {
547
+ const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
548
+ return v === null || v === undefined ? null : Number(v);
549
+ }
550
+ /**
551
+ * Project a correlated `COUNT(*)` of a relation as an extra column. Default
552
+ * alias is `${relationName}_count`; override by calling `.as('alias')` inside
553
+ * the optional callback. The count lands on `entity.$extras[alias]`.
554
+ *
555
+ * userRepo.query().withCount('posts') // → $extras.posts_count
556
+ * userRepo.query().withCount('posts', q => q.where('published', true))
557
+ * userRepo.query().withCount('posts', q => q.as('published_count').where('published', true))
558
+ */
559
+ withCount(relationName, callback) {
560
+ this.#selectSubqueries.push(this.#buildRelationSubquery(relationName, callback, "count", `${relationName}_count`));
561
+ return this;
562
+ }
563
+ /**
564
+ * Project any aggregate (sum/avg/min/max/count) of a relation as an extra column.
565
+ * The callback MUST set the aggregate via `.sum('col')` / `.avg(...)` etc. and
566
+ * typically also set an alias via `.as('name')`.
567
+ *
568
+ * userRepo.query().withAggregate('posts', q => q.sum('views').as('total_views'))
569
+ */
570
+ withAggregate(relationName, callback) {
571
+ this.#selectSubqueries.push(this.#buildRelationSubquery(relationName, callback, "aggregate", relationName));
572
+ return this;
573
+ }
574
+ orderBy(column, direction = "asc") {
575
+ this.#orderBys.push({ column: this.#resolveColumn(column), direction });
576
+ return this;
577
+ }
578
+ limit(n) {
579
+ // Guard here with a clear message — the Rust spec types limit as
580
+ // u64, so a negative/non-integer otherwise surfaces as a cryptic
581
+ // serde deserialization error at compile time. Matches the
582
+ // QueryBuilder.limit guard.
583
+ if (!Number.isInteger(n) || n < 0) {
584
+ throw new Error(`limit must be a non-negative integer, got ${n}`);
585
+ }
586
+ this.#limit = n;
587
+ return this;
588
+ }
589
+ offset(n) {
590
+ if (!Number.isInteger(n) || n < 0) {
591
+ throw new Error(`offset must be a non-negative integer, got ${n}`);
592
+ }
593
+ this.#offset = n;
594
+ return this;
595
+ }
596
+ /** Execute and return the first matching entity or null. */
597
+ async first() {
598
+ this.#limit = 1;
599
+ const results = await this.exec();
600
+ return results[0] ?? null;
601
+ }
602
+ /** Execute and return the first matching entity or throw. */
603
+ async firstOrFail() {
604
+ const result = await this.first();
605
+ if (!result)
606
+ throw new Error(`No ${this.#tableName} found matching query`);
607
+ return result;
608
+ }
609
+ /**
610
+ * Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
611
+ * A chain like `await repo.query().where('active', true).orderBy('id')`
612
+ * works without an explicit `.exec()` thanks to this method.
613
+ *
614
+ * Idempotent: `exec()` memoizes its promise, so awaiting the same builder
615
+ * twice — or any Promise-like assimilation (Promise.resolve, Promise.all,
616
+ * vitest's `.resolves` matcher, instrumentation libs that probe `.then`,
617
+ * dynamic-import unwrap) — shares one SQL round-trip. Call `.clone()` to
618
+ * get a fresh builder that re-executes.
619
+ */
620
+ // 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.
621
+ then(onfulfilled, onrejected) {
622
+ return this.exec().then(onfulfilled, onrejected);
623
+ }
624
+ /** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
625
+ #buildSpec() {
626
+ const wheres = [...this.#wheres];
627
+ // Auto-apply soft-delete scope when the entity opts in via @SoftDeletes
628
+ if (this.#softDeletes) {
629
+ if (this.#softScope === "default") {
630
+ wheres.push({
631
+ type: "and",
632
+ column: "deleted_at",
633
+ operator: "IS NULL",
634
+ value: null,
635
+ });
636
+ }
637
+ else if (this.#softScope === "only-trashed") {
638
+ wheres.push({
639
+ type: "and",
640
+ column: "deleted_at",
641
+ operator: "IS NOT NULL",
642
+ value: null,
643
+ });
644
+ }
645
+ // 'with-trashed' adds no filter
646
+ }
647
+ return {
648
+ kind: "select",
649
+ table: this.#tableName,
650
+ select: this.#select,
651
+ selectSubqueries: this.#selectSubqueries,
652
+ wheres,
653
+ orderBy: this.#orderBys,
654
+ groupBy: [],
655
+ having: [],
656
+ limit: this.#limit ?? null,
657
+ offset: this.#offset ?? null,
658
+ distinct: this.#distinct,
659
+ ctes: [],
660
+ unions: [],
661
+ joins: this.#joins,
662
+ lockMode: this.#lockMode,
663
+ };
664
+ }
665
+ /** Build SQL + params via the Rust query compiler. */
666
+ toSQL() {
667
+ const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
668
+ return { sql: compiled.statements[0], params: compiled.params };
669
+ }
670
+ /**
671
+ * Cached exec result. Memoizing the promise makes the builder a one-shot
672
+ * Promise-like: multiple awaits / `Promise.resolve(query)` / `then` probes
673
+ * by instrumentation libraries / `expect().resolves` / dynamic-import
674
+ * unwrap — all share the same SQL round-trip. Pre-memoization, any
675
+ * Promise-like assimilation silently triggered the query a second time.
676
+ *
677
+ * Callers that want a fresh query result must `.clone()` the builder.
678
+ */
679
+ #cachedExec;
680
+ /** Execute and return all matching entities, with preloaded relations. */
681
+ exec() {
682
+ this.#cachedExec ??= this.#doExec();
683
+ return this.#cachedExec;
684
+ }
685
+ async #doExec() {
686
+ const { sql, params } = this.toSQL();
687
+ const rawRows = await this.#db.query(sql, params);
688
+ // Peel withCount / withAggregate alias columns off the raw row into $extras
689
+ // BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
690
+ const extraKeys = this.#selectSubqueries.map((s) => s.alias);
691
+ const entities = rawRows.map((row) => {
692
+ const picked = {};
693
+ for (const key of extraKeys) {
694
+ if (key in row) {
695
+ picked[key] = row[key];
696
+ delete row[key];
697
+ }
698
+ }
699
+ const entity = this.#hydrateFn(row);
700
+ for (const [k, v] of Object.entries(picked))
701
+ entity.setExtra(k, v);
702
+ return entity;
703
+ });
704
+ // Resolve preloads (eager loading)
705
+ if (this.#preloads.size > 0 && this.#entityClass && entities.length > 0) {
706
+ await this.#resolvePreloads(entities);
707
+ }
708
+ return entities;
709
+ }
710
+ /** Resolve preloaded relations via batched subqueries (no N+1). */
711
+ async #resolvePreloads(entities) {
712
+ if (!this.#entityClass)
713
+ return;
714
+ const relations = getRelationMetadata(this.#entityClass);
715
+ for (const relationName of this.#preloads.keys()) {
716
+ const relation = relations.find((r) => r.propertyKey === relationName);
717
+ if (!relation)
718
+ continue;
719
+ const ctx = this.#buildPreloadContext(relation, relationName);
720
+ if (!ctx)
721
+ continue;
722
+ const allRelated = await this.#resolveOneRelation(entities, relationName, relation.type, ctx);
723
+ await this.#applyNestedPreloads(allRelated, ctx);
724
+ }
725
+ }
726
+ /** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
727
+ #buildPreloadContext(relation, relationName) {
728
+ const relatedClass = relation.target();
729
+ const relatedMeta = getEntityMetadata(relatedClass);
730
+ if (!relatedMeta)
731
+ return null;
732
+ const hydrate = (row) => {
733
+ const entity = new relatedClass();
734
+ for (const [key, value] of Object.entries(row)) {
735
+ const camelKey = snakeToCamel(key);
736
+ const targetKey = camelKey in entity ? camelKey : key in entity ? key : null;
737
+ if (targetKey !== null)
738
+ entity.setProp(targetKey, value);
739
+ }
740
+ return entity;
741
+ };
742
+ return {
743
+ relation,
744
+ relationName,
745
+ relatedClass,
746
+ relatedTable: relatedMeta.tableName,
747
+ relatedPk: getPrimaryKey(relatedClass) ?? "id",
748
+ hydrate,
749
+ runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
750
+ runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
751
+ nestedCallback: this.#preloads.get(relationName),
752
+ };
753
+ }
754
+ /** Dispatch to the appropriate relation resolver based on the relation type. */
755
+ async #resolveOneRelation(entities, relationName, type, ctx) {
756
+ switch (type) {
757
+ case "hasMany":
758
+ return this.#resolveHasMany(entities, relationName, ctx);
759
+ case "hasOne":
760
+ return this.#resolveHasOne(entities, relationName, ctx);
761
+ case "belongsTo":
762
+ return this.#resolveBelongsTo(entities, relationName, ctx);
763
+ case "manyToMany":
764
+ return this.#resolveManyToMany(entities, relationName, ctx);
765
+ case "hasOneThrough":
766
+ case "hasManyThrough":
767
+ return this.#resolveThrough(entities, relationName, ctx, type === "hasOneThrough");
768
+ }
769
+ }
770
+ /**
771
+ * Two-hop relations (Story 31.2). Walks parent → intermediate → related in
772
+ * two SELECTs (N+1 would be worse) and groups the final rows by the parent
773
+ * id discovered through the intermediate join.
774
+ */
775
+ async #resolveThrough(entities, relationName, ctx, single) {
776
+ const relation = ctx.relation;
777
+ if (!relation.through) {
778
+ throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
779
+ }
780
+ const throughClass = relation.through();
781
+ const throughMeta = getEntityMetadata(throughClass);
782
+ if (!throughMeta)
783
+ throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
784
+ const throughTable = throughMeta.tableName;
785
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
786
+ const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
787
+ const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
788
+ const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
789
+ const secondLocal = relation.secondLocalKey ?? throughPk;
790
+ const parentIds = entities
791
+ .map((e) => e[parentLocal])
792
+ .filter((v) => v != null);
793
+ if (parentIds.length === 0) {
794
+ return assignEmptyRelation(entities, relationName, single);
795
+ }
796
+ // Step 1 — intermediate rows: (throughPk, firstKey)
797
+ const throughRows = await ctx.runInQuery(throughTable, firstKey, parentIds);
798
+ if (throughRows.length === 0) {
799
+ return assignEmptyRelation(entities, relationName, single);
800
+ }
801
+ // Map secondLocal (= through PK by default) → parentId, throwing on a
802
+ // non-unique key that would silently drop data.
803
+ const throughToParent = buildThroughToParent(throughRows, secondLocal, firstKey, {
804
+ relationName,
805
+ throughTable,
806
+ throughClass: throughClass.name,
807
+ throughPk,
808
+ });
809
+ // Step 2 — related rows where secondKey IN (throughPk)
810
+ const throughIds = [...throughToParent.keys()];
811
+ const relRows = await ctx.runRelationQuery(secondKey, throughIds);
812
+ const grouped = new Map();
813
+ const allRelated = [];
814
+ for (const row of relRows) {
815
+ const hydrated = ctx.hydrate(row);
816
+ const parentId = throughToParent.get(row[secondKey]);
817
+ if (!grouped.has(parentId))
818
+ grouped.set(parentId, []);
819
+ grouped.get(parentId)?.push(hydrated);
820
+ allRelated.push(hydrated);
821
+ }
822
+ for (const entity of entities) {
823
+ const matches = grouped.get(entity[parentLocal]) ?? [];
824
+ entity.setProp(relationName, single ? (matches[0] ?? null) : matches);
825
+ }
826
+ return allRelated;
827
+ }
828
+ async #resolveHasOne(entities, relationName, ctx) {
829
+ const fk = ctx.relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
830
+ const pk = ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
831
+ const ids = entities.map((e) => e[pk]).filter((v) => v != null);
832
+ if (ids.length === 0) {
833
+ for (const e of entities)
834
+ e.setProp(relationName, null);
835
+ return [];
836
+ }
837
+ const relRows = await ctx.runRelationQuery(fk, ids);
838
+ // Track how many rows match each parent id. More than one = invariant
839
+ // violation on a `@HasOne` relation — throw instead of silently dropping
840
+ // the extras (which would hide real data integrity bugs).
841
+ const indexed = new Map();
842
+ const counts = new Map();
843
+ const allRelated = [];
844
+ for (const row of relRows) {
845
+ const key = row[fk];
846
+ const next = (counts.get(key) ?? 0) + 1;
847
+ counts.set(key, next);
848
+ if (next > 1) {
849
+ throw new Error(`@HasOne invariant violated: ${this.#entityClass.name}.${relationName} ` +
850
+ `found ${next} rows in "${ctx.relatedTable}" for parent ${pk}=${String(key)}. ` +
851
+ `Use @HasMany if multiple rows are expected, or add a unique index on "${fk}".`);
852
+ }
853
+ const hydrated = ctx.hydrate(row);
854
+ indexed.set(key, hydrated);
855
+ allRelated.push(hydrated);
856
+ }
857
+ for (const entity of entities) {
858
+ entity.setProp(relationName, indexed.get(entity[pk]) ?? null);
859
+ }
860
+ return allRelated;
861
+ }
862
+ async #resolveHasMany(entities, relationName, ctx) {
863
+ const fk = ctx.relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
864
+ const pk = ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
865
+ const ids = entities.map((e) => e[pk]).filter((v) => v != null);
866
+ if (ids.length === 0)
867
+ return [];
868
+ const relRows = await ctx.runRelationQuery(fk, ids);
869
+ const grouped = new Map();
870
+ const allRelated = [];
871
+ for (const row of relRows) {
872
+ const key = row[fk];
873
+ const hydrated = ctx.hydrate(row);
874
+ if (!grouped.has(key))
875
+ grouped.set(key, []);
876
+ grouped.get(key)?.push(hydrated);
877
+ allRelated.push(hydrated);
878
+ }
879
+ for (const entity of entities) {
880
+ entity.setProp(relationName, grouped.get(entity[pk]) ?? []);
881
+ }
882
+ return allRelated;
883
+ }
884
+ async #resolveBelongsTo(entities, relationName, ctx) {
885
+ const fk = ctx.relation.foreignKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
886
+ const fkProp = `${relationName}Id`;
887
+ const ids = entities
888
+ .map((e) => e[fkProp] ?? e[fk])
889
+ .filter((v) => v != null);
890
+ const uniqueIds = [...new Set(ids)];
891
+ if (uniqueIds.length === 0)
892
+ return [];
893
+ const relRows = await ctx.runRelationQuery(ctx.relatedPk, uniqueIds);
894
+ const indexed = new Map();
895
+ const allRelated = [];
896
+ for (const row of relRows) {
897
+ const hydrated = ctx.hydrate(row);
898
+ indexed.set(row[ctx.relatedPk], hydrated);
899
+ allRelated.push(hydrated);
900
+ }
901
+ for (const entity of entities) {
902
+ const fkValue = entity[fkProp] ?? entity[fk];
903
+ entity.setProp(relationName, indexed.get(fkValue) ?? null);
904
+ }
905
+ return allRelated;
906
+ }
907
+ async #resolveManyToMany(entities, relationName, ctx) {
908
+ if (!ctx.relation.pivot) {
909
+ throw new Error(`@ManyToMany on ${this.#entityClass.name}.${relationName} requires pivot options`);
910
+ }
911
+ const pivot = ctx.relation.pivot;
912
+ const foreignKey = pivot.foreignKey ??
913
+ `${camelToSnake(this.#tableName.replace(/s$/, ""))}_id`;
914
+ const otherKey = pivot.otherKey ??
915
+ `${camelToSnake(ctx.relatedTable.replace(/s$/, ""))}_id`;
916
+ const pk = getPrimaryKey(this.#entityClass) ?? "id";
917
+ const ids = entities.map((e) => e[pk]).filter((v) => v != null);
918
+ if (ids.length === 0)
919
+ return [];
920
+ // Step 1 — pivot table: find (foreignKey → otherKey) pairs
921
+ const pivotRows = await ctx.runInQuery(pivot.pivotTable, foreignKey, ids);
922
+ if (pivotRows.length === 0) {
923
+ for (const entity of entities)
924
+ entity.setProp(relationName, []);
925
+ return [];
926
+ }
927
+ const otherIds = [
928
+ ...new Set(pivotRows.map((r) => r[otherKey]).filter((v) => v != null)),
929
+ ];
930
+ // Step 2 — load all related entities in one query
931
+ const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
932
+ const byRelatedPk = new Map();
933
+ const allRelated = [];
934
+ for (const row of relRows) {
935
+ const hydrated = ctx.hydrate(row);
936
+ byRelatedPk.set(row[ctx.relatedPk], hydrated);
937
+ allRelated.push(hydrated);
938
+ }
939
+ // Step 3 — group via the pivot
940
+ const grouped = new Map();
941
+ for (const pivotRow of pivotRows) {
942
+ const related = byRelatedPk.get(pivotRow[otherKey]);
943
+ if (!related)
944
+ continue;
945
+ const parentId = pivotRow[foreignKey];
946
+ if (!grouped.has(parentId))
947
+ grouped.set(parentId, []);
948
+ grouped.get(parentId)?.push(related);
949
+ }
950
+ for (const entity of entities) {
951
+ entity.setProp(relationName, grouped.get(entity[pk]) ?? []);
952
+ }
953
+ return allRelated;
954
+ }
955
+ /** Recursively resolve preloads declared by the nested callback. */
956
+ async #applyNestedPreloads(relatedEntities, ctx) {
957
+ if (!ctx.nestedCallback || relatedEntities.length === 0)
958
+ return;
959
+ const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass);
960
+ ctx.nestedCallback(sub);
961
+ if (sub.#preloads.size > 0) {
962
+ await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
963
+ }
964
+ }
965
+ /** Compile + execute a `SELECT * FROM <table> WHERE <column> IN (...)` via the Rust compiler. */
966
+ async #runInQuery(table, column, values) {
967
+ const spec = {
968
+ kind: "select",
969
+ table,
970
+ select: ["*"],
971
+ selectSubqueries: [],
972
+ wheres: [{ column, operator: "IN", value: values, type: "and" }],
973
+ orderBy: [],
974
+ groupBy: [],
975
+ having: [],
976
+ limit: null,
977
+ offset: null,
978
+ distinct: false,
979
+ ctes: [],
980
+ unions: [],
981
+ joins: [],
982
+ lockMode: null,
983
+ };
984
+ const compiled = compileStatementNative(spec, this.#dialect);
985
+ return this.#db.query(compiled.statements[0], compiled.params);
986
+ }
987
+ /**
988
+ * Run a relation preload against the related table, applying the relation's
989
+ * declared `onQuery` constraint (Story 31.4) AND the user-supplied preload
990
+ * callback (e.g. `preload('posts', q => q.where('published', true))`) —
991
+ * which, prior to this helper, was silently dropped for the primary-level
992
+ * row set and only applied on nested preloads.
993
+ *
994
+ * Returns raw rows (snake_case keys) so existing resolvers can continue to
995
+ * index/group by FK without a hydration round-trip. Nested preloads declared
996
+ * inside the callback are re-collected later by `#applyNestedPreloads`.
997
+ */
998
+ async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
999
+ const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c,
1000
+ // Propagate the RELATED entity's soft-delete flag — hardcoding
1001
+ // false here meant `preload('posts')` returned soft-deleted
1002
+ // posts even when Post is @SoftDeletes (a data leak). The
1003
+ // related query now applies its own `deleted_at IS NULL` filter,
1004
+ // matching a direct query on that entity. (with-trashed on the
1005
+ // related set, if ever needed, would be opted-in via the
1006
+ // preload callback.)
1007
+ hasSoftDeletes(relatedClass), this.#dialect);
1008
+ sub.whereIn(column, values);
1009
+ if (relation.onQuery)
1010
+ relation.onQuery(sub);
1011
+ if (userCallback)
1012
+ userCallback(sub);
1013
+ const { sql, params } = sub.toSQL();
1014
+ return this.#db.query(sql, params);
1015
+ }
1016
+ /**
1017
+ * Build a correlated subquery over a relation. Returns `SubqueryProjection`
1018
+ * used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
1019
+ * mode; `'aggregate'` mode requires the callback to set the select itself via
1020
+ * `.sum()` / `.avg()` / `.min()` / `.max()` / `.count()`.
1021
+ */
1022
+ #buildRelationSubquery(relationName, callback, mode, defaultAlias) {
1023
+ const sub = this.#makeRelationSub(relationName);
1024
+ if (mode === "count")
1025
+ sub.selectAggregate("count", "*");
1026
+ if (callback)
1027
+ callback(sub);
1028
+ if (mode === "aggregate" &&
1029
+ (sub.#select.length !== 1 || sub.#select[0] === "*")) {
1030
+ throw new Error(`withAggregate('${relationName}') callback must set an aggregate via .sum/.avg/.min/.max/.count`);
1031
+ }
1032
+ const alias = sub.#subqueryAlias ?? defaultAlias;
1033
+ return { alias, subquery: sub.#buildSpec() };
1034
+ }
1035
+ /**
1036
+ * Shared helper for whereHas + withCount + withAggregate: build a sub ModelQuery
1037
+ * on the related table with the correlated join predicate already injected.
1038
+ */
1039
+ #makeRelationSub(relationName) {
1040
+ const relations = getRelationMetadata(this.#entityClass);
1041
+ const relation = relations.find((r) => r.propertyKey === relationName);
1042
+ if (!relation) {
1043
+ throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
1044
+ }
1045
+ const relatedClass = relation.target();
1046
+ const relatedMeta = getEntityMetadata(relatedClass);
1047
+ if (!relatedMeta) {
1048
+ throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
1049
+ }
1050
+ const relatedTable = relatedMeta.tableName;
1051
+ const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
1052
+ const parentTable = this.#tableName;
1053
+ const q = this.#dialect === "mysql"
1054
+ ? (name) => `\`${name}\``
1055
+ : (name) => `"${name}"`;
1056
+ const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass, (c) => c, false, this.#dialect);
1057
+ switch (relation.type) {
1058
+ case "hasOne":
1059
+ case "hasMany": {
1060
+ const fk = `${camelToSnake(this.#entityClass.name)}_id`;
1061
+ sub.#pushWhereRaw(`${q(relatedTable)}.${q(fk)} = ${q(parentTable)}.${q(parentPk)}`);
1062
+ break;
1063
+ }
1064
+ case "belongsTo": {
1065
+ const fk = `${camelToSnake(relatedClass.name)}_id`;
1066
+ const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1067
+ sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} = ${q(parentTable)}.${q(fk)}`);
1068
+ break;
1069
+ }
1070
+ case "manyToMany": {
1071
+ if (!relation.pivot) {
1072
+ throw new Error(`@ManyToMany on ${this.#entityClass.name}.${relationName} requires pivot options`);
1073
+ }
1074
+ const pivot = relation.pivot;
1075
+ const foreignKey = pivot.foreignKey ??
1076
+ `${camelToSnake(parentTable.replace(/s$/, ""))}_id`;
1077
+ const otherKey = pivot.otherKey ??
1078
+ `${camelToSnake(relatedTable.replace(/s$/, ""))}_id`;
1079
+ const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1080
+ sub.#pushWhereRaw(`${q(relatedTable)}.${q(relatedPk)} IN ` +
1081
+ `(SELECT ${q(otherKey)} FROM ${q(pivot.pivotTable)} ` +
1082
+ `WHERE ${q(pivot.pivotTable)}.${q(foreignKey)} = ${q(parentTable)}.${q(parentPk)})`);
1083
+ break;
1084
+ }
1085
+ }
1086
+ return sub;
1087
+ }
1088
+ innerJoin(table, leftOrBuild, right) {
1089
+ return this.#pushJoin("INNER", table, leftOrBuild, right);
1090
+ }
1091
+ leftJoin(table, leftOrBuild, right) {
1092
+ return this.#pushJoin("LEFT", table, leftOrBuild, right);
1093
+ }
1094
+ rightJoin(table, leftOrBuild, right) {
1095
+ return this.#pushJoin("RIGHT", table, leftOrBuild, right);
1096
+ }
1097
+ crossJoin(table) {
1098
+ const tq = this.#quote(table);
1099
+ this.#joins.push(`CROSS JOIN ${tq}`);
1100
+ return this;
1101
+ }
1102
+ /**
1103
+ * **⚠ UNSAFE** — append a raw JOIN fragment verbatim. No identifier quoting,
1104
+ * no operator validation. Caller is fully responsible for safety.
1105
+ *
1106
+ * Prefer `joinOn()` for the common two-column equi-join case where Atlas
1107
+ * can quote the identifiers for you. Reach for `joinRaw` only when you
1108
+ * need a dialect-specific construct (`LATERAL`, `USING`, index hints…).
1109
+ *
1110
+ * query.joinRaw('LEFT JOIN LATERAL (SELECT ... FROM ...) t ON true')
1111
+ *
1112
+ * **Strict mode**: throws when `setAtlasStrictMode(true)` is active.
1113
+ * Use `joinOn()` or the callback form of `innerJoin`/`leftJoin`/`rightJoin`
1114
+ * instead.
1115
+ *
1116
+ * @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
1117
+ */
1118
+ joinRaw(fragment) {
1119
+ if (isAtlasStrictMode() && !isInternalBypass()) {
1120
+ throw new Error("joinRaw() is disabled in Atlas strict mode. " +
1121
+ "Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
1122
+ }
1123
+ this.#joins.push(fragment);
1124
+ return this;
1125
+ }
1126
+ /**
1127
+ * **SAFE** helper that builds an `INNER JOIN <table> ON <left> = <right>`
1128
+ * with dialect-correct identifier quoting on both sides. Thin sugar over
1129
+ * `innerJoin(table, left, right)` for symmetry with `whereExpr` — both
1130
+ * are the "don't reach for *Raw" entry points.
1131
+ *
1132
+ * query.joinOn('users', 'users.id', 'orders.user_id')
1133
+ *
1134
+ * Use the callback form of `innerJoin` / `leftJoin` / `rightJoin` when
1135
+ * you need multiple join conditions.
1136
+ */
1137
+ joinOn(table, left, right) {
1138
+ return this.innerJoin(table, left, right);
1139
+ }
1140
+ // === Story 29.5 — aggregates / exists / pluck =====================================================
1141
+ distinct() {
1142
+ this.#distinct = true;
1143
+ return this;
1144
+ }
1145
+ /** `SELECT COUNT(DISTINCT col)`. */
1146
+ async countDistinct(column) {
1147
+ return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0);
1148
+ }
1149
+ /** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
1150
+ async exists() {
1151
+ const clone = this.clone();
1152
+ clone.#select = ["1"];
1153
+ clone.#limit = 1;
1154
+ const { sql, params } = clone.toSQL();
1155
+ const rows = await this.#db.query(sql, params);
1156
+ return rows.length > 0;
1157
+ }
1158
+ async doesntExist() {
1159
+ return !(await this.exists());
1160
+ }
1161
+ /** Flat column projection. Rejects object/relation columns. */
1162
+ async pluck(column) {
1163
+ const col = this.#resolveColumn(column);
1164
+ const clone = this.clone();
1165
+ clone.#select = [col];
1166
+ const { sql, params } = clone.toSQL();
1167
+ const rows = await this.#db.query(sql, params);
1168
+ return rows.map((row) => {
1169
+ const v = row[col];
1170
+ if (v !== null && typeof v === "object") {
1171
+ throw new Error(`pluck('${column}') rejected — column is an object/relation`);
1172
+ }
1173
+ return v;
1174
+ });
1175
+ }
1176
+ // === Story 29.8 — scopes ===========================================================================
1177
+ /** Apply scopes declared on the entity class via `static scopes = {...}`. */
1178
+ apply(callback) {
1179
+ const scopes = this.#entityClass.scopes;
1180
+ if (!scopes)
1181
+ throw new Error(`${this.#entityClass.name} declares no static scopes`);
1182
+ const proxy = {};
1183
+ for (const [name, fn] of Object.entries(scopes)) {
1184
+ proxy[name] = (...args) => {
1185
+ fn(this, ...args);
1186
+ return this;
1187
+ };
1188
+ }
1189
+ const wrapper = new Proxy(proxy, {
1190
+ get: (target, prop) => {
1191
+ if (prop in target)
1192
+ return target[prop];
1193
+ throw new Error(`Unknown scope '${String(prop)}' on ${this.#entityClass.name}`);
1194
+ },
1195
+ });
1196
+ callback(wrapper);
1197
+ return this;
1198
+ }
1199
+ /** Alias for `apply` — Lucid compatibility. */
1200
+ withScopes(callback) {
1201
+ return this.apply(callback);
1202
+ }
1203
+ // === Story 29.9 — if / unless ======================================================================
1204
+ if(condition, ifFn, elseFn) {
1205
+ if (condition)
1206
+ ifFn(this, condition);
1207
+ else if (elseFn)
1208
+ elseFn(this);
1209
+ return this;
1210
+ }
1211
+ unless(condition, fn) {
1212
+ if (!condition)
1213
+ fn(this);
1214
+ return this;
1215
+ }
1216
+ // === Story 29.10 — pagination =====================================================================
1217
+ /** Offset-based paginator. */
1218
+ async paginate(page, perPage) {
1219
+ const p = Math.max(1, Math.floor(page));
1220
+ const pp = Math.max(1, Math.floor(perPage));
1221
+ // Parallel COUNT(*) + data fetch
1222
+ const countQ = this.clone();
1223
+ countQ.#select = ["COUNT(*) AS count"];
1224
+ countQ.#limit = undefined;
1225
+ countQ.#offset = undefined;
1226
+ countQ.#orderBys = [];
1227
+ const { sql: cSql, params: cParams } = countQ.toSQL();
1228
+ const cRows = await this.#db.query(cSql, cParams);
1229
+ const total = Number(cRows[0]?.count ?? 0);
1230
+ const dataQ = this.clone();
1231
+ dataQ.#limit = pp;
1232
+ dataQ.#offset = (p - 1) * pp;
1233
+ const items = await dataQ.exec();
1234
+ return new Paginator(items, { total, perPage: pp, currentPage: p });
1235
+ }
1236
+ /**
1237
+ * Cursor-based pagination — base64 opaque keyset, multi-column aware.
1238
+ *
1239
+ * `orderBy` can be a single column (`'created_at'`) or a tuple
1240
+ * (`['created_at', 'id']`) for stable tie-breaking. The cursor encodes
1241
+ * the last row's values for every ordering column, and the next page
1242
+ * query uses a lexicographic tuple predicate:
1243
+ *
1244
+ * (col1, col2) > (?, ?) ≡ col1 > ? OR (col1 = ? AND col2 > ?)
1245
+ *
1246
+ * Expanded into a disjunctive form because not every supported dialect
1247
+ * accepts row-value comparisons.
1248
+ */
1249
+ async cursorPaginate(opts) {
1250
+ const cols = (Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy]).map((c) => this.#resolveColumn(c));
1251
+ if (cols.length === 0)
1252
+ throw new Error("cursorPaginate requires at least one orderBy column");
1253
+ const lim = Math.max(1, Math.floor(opts.limit));
1254
+ const clone = this.clone();
1255
+ if (opts.cursor) {
1256
+ // Cursors arrive from the API boundary (often a query string). Wrap the
1257
+ // decode so a malformed cursor produces a controlled user-facing error
1258
+ // instead of a raw `SyntaxError` from JSON.parse.
1259
+ let decoded;
1260
+ try {
1261
+ const raw = Buffer.from(opts.cursor, "base64").toString("utf-8");
1262
+ decoded = JSON.parse(raw);
1263
+ }
1264
+ catch {
1265
+ throw new Error(`cursorPaginate: malformed cursor '${opts.cursor.slice(0, 32)}…' — ` +
1266
+ `must be a base64-encoded JSON object of shape { v: unknown[] }`);
1267
+ }
1268
+ if (!Array.isArray(decoded.v) || decoded.v.length !== cols.length) {
1269
+ throw new Error(`cursor tuple length mismatch (expected ${cols.length}, got ${decoded.v?.length ?? 0})`);
1270
+ }
1271
+ // Build the disjunctive tuple comparison as a nested group of WHEREs.
1272
+ clone.where((q) => {
1273
+ for (let i = 0; i < cols.length; i++) {
1274
+ q.orWhere((inner) => {
1275
+ for (let j = 0; j < i; j++)
1276
+ inner.where(cols[j], decoded.v[j]);
1277
+ inner.where(cols[i], ">", decoded.v[i]);
1278
+ });
1279
+ }
1280
+ });
1281
+ }
1282
+ clone.#orderBys = cols.map((column) => ({
1283
+ column,
1284
+ direction: "asc",
1285
+ }));
1286
+ clone.#limit = lim + 1;
1287
+ const rows = await clone.exec();
1288
+ const hasMore = rows.length > lim;
1289
+ const items = hasMore ? rows.slice(0, lim) : rows;
1290
+ const last = items[items.length - 1];
1291
+ const nextCursor = hasMore && last
1292
+ ? Buffer.from(JSON.stringify({ v: cols.map((c) => last[c]) })).toString("base64")
1293
+ : null;
1294
+ return { items, nextCursor, hasMore };
1295
+ }
1296
+ /** Thin alias for `offset((page-1)*perPage).limit(perPage)`. */
1297
+ forPage(page, perPage) {
1298
+ const p = Math.max(1, Math.floor(page));
1299
+ const pp = Math.max(1, Math.floor(perPage));
1300
+ this.#offset = (p - 1) * pp;
1301
+ this.#limit = pp;
1302
+ return this;
1303
+ }
1304
+ // === Story 29.11 — debug / toQuery / clone ========================================================
1305
+ debug(flag = true) {
1306
+ this.#debugFlag = flag;
1307
+ return this;
1308
+ }
1309
+ /** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
1310
+ toQuery() {
1311
+ const { sql, params } = this.toSQL();
1312
+ let i = 0;
1313
+ return sql.replace(/\?|\$\d+/g, () => {
1314
+ const v = params[i++];
1315
+ return this.#literalEscape(v);
1316
+ });
1317
+ }
1318
+ /** Deep clone of this query — mutations on the clone never affect the original. */
1319
+ clone() {
1320
+ const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect);
1321
+ c.#softScope = this.#softScope;
1322
+ c.#wheres = structuredCloneSafe(this.#wheres);
1323
+ c.#orderBys = [...this.#orderBys];
1324
+ c.#select = [...this.#select];
1325
+ c.#limit = this.#limit;
1326
+ c.#offset = this.#offset;
1327
+ c.#preloads = new Map(this.#preloads);
1328
+ c.#selectSubqueries = structuredClone(this.#selectSubqueries);
1329
+ c.#joins = [...this.#joins];
1330
+ c.#lockMode = this.#lockMode;
1331
+ c.#distinct = this.#distinct;
1332
+ c.#debugFlag = this.#debugFlag;
1333
+ return c;
1334
+ }
1335
+ // === Story 30.2 — update / delete fluent ===========================================================
1336
+ /** Execute a fluent UPDATE. Returns affected rows (or rows when `returning` is set). */
1337
+ async update(patch, returning) {
1338
+ if (!patch || Object.keys(patch).length === 0) {
1339
+ throw new Error("update() requires a non-empty payload");
1340
+ }
1341
+ const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), v]);
1342
+ const spec = {
1343
+ kind: "update",
1344
+ table: this.#tableName,
1345
+ set: setPairs,
1346
+ wheres: this.#wheresForDml(),
1347
+ returning: returning ?? [],
1348
+ };
1349
+ const compiled = compileStatementNative(spec, this.#dialect);
1350
+ if (returning && returning.length > 0) {
1351
+ return this.#db.query(compiled.statements[0], compiled.params);
1352
+ }
1353
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
1354
+ return r.rowsAffected ?? 0;
1355
+ }
1356
+ /** Execute a fluent DELETE. Returns affected rows (or rows when `returning` is set). */
1357
+ async delete(returning) {
1358
+ const spec = {
1359
+ kind: "delete",
1360
+ table: this.#tableName,
1361
+ wheres: this.#wheresForDml(),
1362
+ returning: returning ?? [],
1363
+ };
1364
+ const compiled = compileStatementNative(spec, this.#dialect);
1365
+ if (returning && returning.length > 0) {
1366
+ return this.#db.query(compiled.statements[0], compiled.params);
1367
+ }
1368
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
1369
+ return r.rowsAffected ?? 0;
1370
+ }
1371
+ increment(colOrPatch, amount = 1) {
1372
+ return this.#runIncDec("increment", colOrPatch, amount);
1373
+ }
1374
+ decrement(colOrPatch, amount = 1) {
1375
+ return this.#runIncDec("decrement", colOrPatch, amount);
1376
+ }
1377
+ // === Story 30.8 — forUpdate / forShare =============================================================
1378
+ forUpdate() {
1379
+ if (this.#dialect === "sqlite") {
1380
+ console.warn("[atlas] forUpdate ignored on sqlite (no row-level lock support)");
1381
+ }
1382
+ else {
1383
+ this.#lockMode = "FOR UPDATE";
1384
+ }
1385
+ return this;
1386
+ }
1387
+ forShare() {
1388
+ if (this.#dialect === "sqlite") {
1389
+ console.warn("[atlas] forShare ignored on sqlite (no row-level lock support)");
1390
+ }
1391
+ else {
1392
+ this.#lockMode = "FOR SHARE";
1393
+ }
1394
+ return this;
1395
+ }
1396
+ // === Private helpers ==============================================================================
1397
+ #quote(name) {
1398
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1399
+ }
1400
+ /** Quote a `table.column` reference on both sides of the dot. */
1401
+ #quoteCol(ref) {
1402
+ if (ref.includes(".")) {
1403
+ const [t, c] = ref.split(".", 2);
1404
+ return `${this.#quote(t)}.${this.#quote(c)}`;
1405
+ }
1406
+ return this.#quote(ref);
1407
+ }
1408
+ #pushJoin(kind, table, leftOrBuild, right) {
1409
+ const tq = this.#quote(table);
1410
+ if (typeof leftOrBuild === "function") {
1411
+ const jb = {
1412
+ parts: [],
1413
+ on(l, r) {
1414
+ this.parts.push({ kind: "and", left: l, right: r });
1415
+ return this;
1416
+ },
1417
+ andOn(l, r) {
1418
+ this.parts.push({ kind: "and", left: l, right: r });
1419
+ return this;
1420
+ },
1421
+ andOnVal(l, _v) {
1422
+ this.parts.push({ kind: "and", left: l, right: "?" });
1423
+ return this;
1424
+ },
1425
+ };
1426
+ leftOrBuild(jb);
1427
+ const on = jb.parts
1428
+ .map((p, i) => {
1429
+ const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
1430
+ return `${prefix} ${this.#quoteCol(p.left)} = ${p.right === "?" ? "?" : this.#quoteCol(p.right)}`;
1431
+ })
1432
+ .join(" ");
1433
+ this.#joins.push(`${kind} JOIN ${tq} ${on}`);
1434
+ return this;
1435
+ }
1436
+ if (right === undefined)
1437
+ throw new Error("join() with string form requires both left and right operands");
1438
+ this.#joins.push(`${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`);
1439
+ return this;
1440
+ }
1441
+ async #runScalar(expr) {
1442
+ const clone = this.clone();
1443
+ clone.#select = [`${expr} AS __scalar__`];
1444
+ clone.#orderBys = [];
1445
+ const { sql, params } = clone.toSQL();
1446
+ const rows = await this.#db.query(sql, params);
1447
+ const row = rows[0];
1448
+ return row ? row.__scalar__ : null;
1449
+ }
1450
+ async #runIncDec(op, colOrPatch, amount) {
1451
+ const patch = typeof colOrPatch === "string" ? { [colOrPatch]: amount } : colOrPatch;
1452
+ const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), { op, value: v }]);
1453
+ const spec = {
1454
+ kind: "update",
1455
+ table: this.#tableName,
1456
+ set: setPairs,
1457
+ wheres: this.#wheresForDml(),
1458
+ returning: [],
1459
+ };
1460
+ const compiled = compileStatementNative(spec, this.#dialect);
1461
+ const r = await this.#db.execute(compiled.statements[0], compiled.params);
1462
+ return r.rowsAffected ?? 0;
1463
+ }
1464
+ /**
1465
+ * Flatten the SELECT wheres to DML-compatible wheres. Standard predicates
1466
+ * and `whereRaw` fragments pass through; `group` / `exists` / `inSub` are
1467
+ * still rejected because the DML compiler's WHERE lowering does not yet
1468
+ * handle nested sub-queries or correlated EXISTS.
1469
+ */
1470
+ #wheresForDml() {
1471
+ const out = [];
1472
+ for (const w of this.#wheres) {
1473
+ if ("kind" in w) {
1474
+ if (w.kind === "raw") {
1475
+ out.push({
1476
+ kind: "raw",
1477
+ sql: w.sql,
1478
+ bindings: w.bindings,
1479
+ type: w.type,
1480
+ });
1481
+ continue;
1482
+ }
1483
+ throw new Error(`update/delete do not support '${w.kind}' WHERE clauses. ` +
1484
+ `Supported: plain predicates and whereRaw. Use a raw UPDATE/DELETE for complex criteria.`);
1485
+ }
1486
+ out.push({
1487
+ column: w.column,
1488
+ operator: w.operator,
1489
+ value: w.value,
1490
+ type: w.type,
1491
+ });
1492
+ }
1493
+ return out;
1494
+ }
1495
+ /**
1496
+ * !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
1497
+ *
1498
+ * Produces a human-readable SQL rendering with bindings inlined. The escape
1499
+ * strategy (double single-quotes) is NOT safe against backslash-based injection
1500
+ * on MySQL or on PostgreSQL with `standard_conforming_strings = off`: the
1501
+ * sequence `\'` closes the string literal and opens an injection vector.
1502
+ *
1503
+ * This function exists ONLY to back `.toQuery()` for copy-paste debugging and
1504
+ * log readability. The production execution path always goes through bound
1505
+ * parameters via the Rust compiler — this escaper is never on the hot path.
1506
+ * If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
1507
+ */
1508
+ #literalEscape(v) {
1509
+ if (v === null || v === undefined)
1510
+ return "NULL";
1511
+ if (typeof v === "number")
1512
+ return String(v);
1513
+ if (typeof v === "boolean")
1514
+ return v ? "1" : "0";
1515
+ if (v instanceof Date)
1516
+ return `'${v.toISOString()}'`;
1517
+ // Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
1518
+ return `'${String(v).replace(/'/g, "''")}'`;
1519
+ }
1520
+ /**
1521
+ * Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
1522
+ * on the SAME table is used as the scratch builder so the callback can call
1523
+ * any of the usual where* methods, including nested `where(cb)` for deeper
1524
+ * groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
1525
+ */
1526
+ #buildGroup(type, callback) {
1527
+ const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect);
1528
+ callback(scratch);
1529
+ return { type, kind: "group", conditions: scratch.#wheres };
1530
+ }
1531
+ #buildExistsClause(type, negated, relationName, callback, countOp, countThreshold) {
1532
+ const sub = this.#makeRelationSub(relationName);
1533
+ if (callback)
1534
+ callback(sub);
1535
+ const spec = sub.#buildSpec();
1536
+ if (countOp !== undefined && countThreshold !== undefined) {
1537
+ spec.having = [
1538
+ {
1539
+ column: "COUNT(*)",
1540
+ operator: countOp,
1541
+ value: countThreshold,
1542
+ type: "and",
1543
+ },
1544
+ ];
1545
+ }
1546
+ return { type, kind: "exists", negated, subquery: spec };
1547
+ }
1548
+ #pushWhere(type, column, operatorOrValue, value) {
1549
+ const resolved = this.#resolveColumn(column);
1550
+ if (value === undefined) {
1551
+ // 2-arg form: where(col, value). A `null` value means the caller
1552
+ // wants an IS NULL test — `= ?` bound to null never matches in
1553
+ // SQL, silently returning zero rows. Mirror whereNull().
1554
+ if (operatorOrValue === null) {
1555
+ this.#wheres.push({
1556
+ type,
1557
+ column: resolved,
1558
+ operator: "IS NULL",
1559
+ value: null,
1560
+ });
1561
+ return this;
1562
+ }
1563
+ this.#wheres.push({
1564
+ type,
1565
+ column: resolved,
1566
+ operator: "=",
1567
+ value: operatorOrValue,
1568
+ });
1569
+ }
1570
+ else {
1571
+ this.#wheres.push({
1572
+ type,
1573
+ column: resolved,
1574
+ operator: operatorOrValue,
1575
+ value,
1576
+ });
1577
+ }
1578
+ return this;
1579
+ }
1580
+ /**
1581
+ * Resolve this ModelQuery's preloads against a pre-loaded set of entities.
1582
+ * Used by the nested-preload machinery to recurse without re-running the root select.
1583
+ */
1584
+ async #resolveAgainst(entities, entityClass) {
1585
+ // Temporarily swap the entity class so resolvePreloads looks up the right metadata.
1586
+ // Cast is safe because resolvePreloads only reads metadata + writes via setProp.
1587
+ const prevClass = this.#entityClass;
1588
+ this.#entityClass = entityClass;
1589
+ try {
1590
+ await this.#resolvePreloads(entities);
1591
+ }
1592
+ finally {
1593
+ this.#entityClass = prevClass;
1594
+ }
1595
+ }
1596
+ }
1597
+ _a = ModelQuery;
1598
+ //# sourceMappingURL=ModelQuery.js.map