@c9up/atlas 0.1.3 → 0.1.4
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.
- package/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +106 -0
- package/dist/AtlasProvider.d.ts.map +1 -0
- package/dist/AtlasProvider.js +190 -0
- package/dist/AtlasProvider.js.map +1 -0
- package/dist/BaseEntity.d.ts +255 -0
- package/dist/BaseEntity.d.ts.map +1 -0
- package/dist/BaseEntity.js +420 -0
- package/dist/BaseEntity.js.map +1 -0
- package/dist/BaseRepository.d.ts +175 -0
- package/dist/BaseRepository.d.ts.map +1 -0
- package/dist/BaseRepository.js +1394 -0
- package/dist/BaseRepository.js.map +1 -0
- package/dist/ModelQuery.d.ts +338 -0
- package/dist/ModelQuery.d.ts.map +1 -0
- package/dist/ModelQuery.js +1598 -0
- package/dist/ModelQuery.js.map +1 -0
- package/dist/Transaction.d.ts +15 -0
- package/dist/Transaction.d.ts.map +1 -0
- package/dist/Transaction.js +67 -0
- package/dist/Transaction.js.map +1 -0
- package/dist/adapters/NapiDbAdapter.d.ts +34 -0
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -0
- package/dist/adapters/NapiDbAdapter.js +96 -0
- package/dist/adapters/NapiDbAdapter.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +10 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +25 -0
- package/dist/configure.js.map +1 -0
- package/dist/decorators/entity.d.ts +235 -0
- package/dist/decorators/entity.d.ts.map +1 -0
- package/dist/decorators/entity.js +264 -0
- package/dist/decorators/entity.js.map +1 -0
- package/dist/decorators/hooks.d.ts +70 -0
- package/dist/decorators/hooks.d.ts.map +1 -0
- package/dist/decorators/hooks.js +107 -0
- package/dist/decorators/hooks.js.map +1 -0
- package/dist/decorators/scope.d.ts +35 -0
- package/dist/decorators/scope.d.ts.map +1 -0
- package/dist/decorators/scope.js +29 -0
- package/dist/decorators/scope.js.map +1 -0
- package/dist/errors.d.ts +54 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +82 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/naming/NamingStrategy.d.ts +52 -0
- package/dist/naming/NamingStrategy.d.ts.map +1 -0
- package/dist/naming/NamingStrategy.js +65 -0
- package/dist/naming/NamingStrategy.js.map +1 -0
- package/dist/query/QueryBuilder.d.ts +95 -0
- package/dist/query/QueryBuilder.d.ts.map +1 -0
- package/dist/query/QueryBuilder.js +310 -0
- package/dist/query/QueryBuilder.js.map +1 -0
- package/dist/query/native.d.ts +18 -0
- package/dist/query/native.d.ts.map +1 -0
- package/dist/query/native.js +49 -0
- package/dist/query/native.js.map +1 -0
- package/dist/schema/Migration.d.ts +55 -0
- package/dist/schema/Migration.d.ts.map +1 -0
- package/dist/schema/Migration.js +68 -0
- package/dist/schema/Migration.js.map +1 -0
- package/dist/schema/MigrationRunner.d.ts +115 -0
- package/dist/schema/MigrationRunner.d.ts.map +1 -0
- package/dist/schema/MigrationRunner.js +402 -0
- package/dist/schema/MigrationRunner.js.map +1 -0
- package/dist/schema/Schema.d.ts +22 -0
- package/dist/schema/Schema.d.ts.map +1 -0
- package/dist/schema/Schema.js +51 -0
- package/dist/schema/Schema.js.map +1 -0
- package/dist/schema/SchemaBuilder.d.ts +14 -0
- package/dist/schema/SchemaBuilder.d.ts.map +1 -0
- package/dist/schema/SchemaBuilder.js +13 -0
- package/dist/schema/SchemaBuilder.js.map +1 -0
- package/dist/schema/Seeder.d.ts +54 -0
- package/dist/schema/Seeder.d.ts.map +1 -0
- package/dist/schema/Seeder.js +89 -0
- package/dist/schema/Seeder.js.map +1 -0
- package/dist/schema/TableBuilder.d.ts +86 -0
- package/dist/schema/TableBuilder.d.ts.map +1 -0
- package/dist/schema/TableBuilder.js +211 -0
- package/dist/schema/TableBuilder.js.map +1 -0
- package/dist/schema/types.d.ts +28 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/types.js +19 -0
- package/dist/schema/types.js.map +1 -0
- package/dist/services/db.d.ts +22 -0
- package/dist/services/db.d.ts.map +1 -0
- package/dist/services/db.js +37 -0
- package/dist/services/db.js.map +1 -0
- package/dist/testing/DatabaseCleanup.d.ts +19 -0
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -0
- package/dist/testing/DatabaseCleanup.js +40 -0
- package/dist/testing/DatabaseCleanup.js.map +1 -0
- package/dist/testing/Factory.d.ts +59 -0
- package/dist/testing/Factory.d.ts.map +1 -0
- package/dist/testing/Factory.js +105 -0
- package/dist/testing/Factory.js.map +1 -0
- package/dist/testing/TestDatabase.d.ts +25 -0
- package/dist/testing/TestDatabase.d.ts.map +1 -0
- package/dist/testing/TestDatabase.js +52 -0
- package/dist/testing/TestDatabase.js.map +1 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/utils/casing.d.ts +5 -0
- package/dist/utils/casing.d.ts.map +1 -0
- package/dist/utils/casing.js +9 -0
- package/dist/utils/casing.js.map +1 -0
- package/dist/utils/dialectFromUrl.d.ts +10 -0
- package/dist/utils/dialectFromUrl.d.ts.map +1 -0
- package/dist/utils/dialectFromUrl.js +8 -0
- package/dist/utils/dialectFromUrl.js.map +1 -0
- package/dist/utils/identifier.d.ts +19 -0
- package/dist/utils/identifier.d.ts.map +1 -0
- package/dist/utils/identifier.js +27 -0
- package/dist/utils/identifier.js.map +1 -0
- package/dist/utils/safePath.d.ts +22 -0
- package/dist/utils/safePath.d.ts.map +1 -0
- package/dist/utils/safePath.js +46 -0
- package/dist/utils/safePath.js.map +1 -0
- package/dist/utils/transactionBrand.d.ts +5 -0
- package/dist/utils/transactionBrand.d.ts.map +1 -0
- package/dist/utils/transactionBrand.js +5 -0
- package/dist/utils/transactionBrand.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
|
@@ -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
|