@c9up/atlas 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -14
- 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 +6 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +2 -2
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +171 -7
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -31
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +91 -0
- package/dist/BaseModel.d.ts.map +1 -0
- package/dist/BaseModel.js +193 -0
- package/dist/BaseModel.js.map +1 -0
- package/dist/BaseRepository.d.ts +77 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1423 -354
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +429 -11
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +1733 -145
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +17 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +57 -5
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +33 -4
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +101 -11
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/migrationCommands.d.ts +48 -0
- package/dist/console/migrationCommands.d.ts.map +1 -0
- package/dist/console/migrationCommands.js +220 -0
- package/dist/console/migrationCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +37 -6
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +32 -2
- package/dist/decorators/entity.js.map +1 -1
- package/dist/events.d.ts +64 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +82 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata-keys.d.ts +3 -2
- package/dist/metadata-keys.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.d.ts +7 -0
- package/dist/naming/NamingStrategy.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.js +16 -0
- package/dist/naming/NamingStrategy.js.map +1 -1
- package/dist/schema/Migration.d.ts +26 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +33 -24
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +43 -32
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +211 -26
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +57 -0
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +138 -3
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts.map +1 -1
- package/dist/schema/SchemaCheck.js +3 -1
- package/dist/schema/SchemaCheck.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +247 -8
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +607 -41
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +47 -0
- package/dist/schema/catalog.d.ts.map +1 -0
- package/dist/schema/catalog.js +111 -0
- package/dist/schema/catalog.js.map +1 -0
- package/dist/schema/introspect.js.map +1 -1
- package/dist/schema/types.d.ts +150 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +11 -0
- package/dist/schema/types.js.map +1 -1
- package/dist/services/db.d.ts +6 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +17 -0
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +7 -4
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +21 -18
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/Factory.d.ts +70 -5
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +209 -10
- package/dist/testing/Factory.js.map +1 -1
- 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 +4 -1
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +8 -1
- package/src/BaseEntity.ts +449 -40
- package/src/BaseModel.ts +324 -0
- package/src/BaseRepository.ts +1659 -371
- package/src/ModelQuery.ts +2290 -203
- package/src/Transaction.ts +68 -5
- package/src/adapters/NapiDbAdapter.ts +159 -10
- package/src/console/migrationCommands.ts +258 -0
- package/src/decorators/entity.ts +53 -6
- package/src/events.ts +112 -0
- package/src/index.ts +19 -0
- package/src/metadata-keys.ts +3 -2
- package/src/naming/NamingStrategy.ts +23 -0
- package/src/schema/Migration.ts +42 -3
- package/src/schema/MigrationRunner.ts +270 -27
- package/src/schema/Schema.ts +210 -3
- package/src/schema/SchemaCheck.ts +7 -2
- package/src/schema/TableBuilder.ts +735 -41
- package/src/schema/catalog.ts +166 -0
- package/src/schema/introspect.ts +3 -4
- package/src/schema/types.ts +137 -2
- package/src/services/db.ts +28 -0
- package/src/testing/DatabaseCleanup.ts +23 -22
- package/src/testing/Factory.ts +332 -15
package/dist/ModelQuery.js
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
* Builds SQL fluently and executes against the database connection.
|
|
8
8
|
*/
|
|
9
9
|
var _a;
|
|
10
|
-
import {
|
|
10
|
+
import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
|
|
11
|
+
import { REPO_REF } from "./BaseEntity.js";
|
|
12
|
+
// Value import used only inside method bodies (preload hydration) — the
|
|
13
|
+
// BaseRepository ↔ ModelQuery cycle resolves at runtime, after both are defined.
|
|
14
|
+
import { assertNotPromise, BaseRepository, wrapAdapterError, } from "./BaseRepository.js";
|
|
15
|
+
import { ensureEntityMetadata, getColumnMetadata, getDateColumnConfig, getPrimaryKey, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
|
|
11
16
|
import { fireHooks } from "./decorators/hooks.js";
|
|
17
|
+
import { getNamingStrategy } from "./naming/NamingStrategy.js";
|
|
12
18
|
import { compileStatementNative, getAtlasDialect, } from "./query/native.js";
|
|
13
19
|
import { camelToSnake, snakeToCamel } from "./utils/casing.js";
|
|
14
20
|
/**
|
|
@@ -28,6 +34,49 @@ const WHEREEXPR_OPERATORS = new Set([
|
|
|
28
34
|
"LIKE",
|
|
29
35
|
"NOT LIKE",
|
|
30
36
|
]);
|
|
37
|
+
/**
|
|
38
|
+
* SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
|
|
39
|
+
* They are just letters (pass the charset guard) but would let the fragment alter
|
|
40
|
+
* the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
|
|
41
|
+
* alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
|
|
42
|
+
*/
|
|
43
|
+
const WHEREEXPR_FORBIDDEN_WORDS = new Set([
|
|
44
|
+
"OR",
|
|
45
|
+
"AND",
|
|
46
|
+
"NOT",
|
|
47
|
+
"IS",
|
|
48
|
+
"NULL",
|
|
49
|
+
"IN",
|
|
50
|
+
"LIKE",
|
|
51
|
+
"ILIKE",
|
|
52
|
+
"BETWEEN",
|
|
53
|
+
"EXISTS",
|
|
54
|
+
"ANY",
|
|
55
|
+
"ALL",
|
|
56
|
+
"SOME",
|
|
57
|
+
"CASE",
|
|
58
|
+
"WHEN",
|
|
59
|
+
"THEN",
|
|
60
|
+
"ELSE",
|
|
61
|
+
"END",
|
|
62
|
+
"SELECT",
|
|
63
|
+
"FROM",
|
|
64
|
+
"WHERE",
|
|
65
|
+
"JOIN",
|
|
66
|
+
"UNION",
|
|
67
|
+
"INTERSECT",
|
|
68
|
+
"EXCEPT",
|
|
69
|
+
"HAVING",
|
|
70
|
+
"GROUP",
|
|
71
|
+
"ORDER",
|
|
72
|
+
"BY",
|
|
73
|
+
"LIMIT",
|
|
74
|
+
"OFFSET",
|
|
75
|
+
"AS",
|
|
76
|
+
"DISTINCT",
|
|
77
|
+
"TRUE",
|
|
78
|
+
"FALSE",
|
|
79
|
+
]);
|
|
31
80
|
/** True when every `(` in `s` has a matching `)` and none closes early. */
|
|
32
81
|
function hasBalancedParens(s) {
|
|
33
82
|
let depth = 0;
|
|
@@ -42,6 +91,97 @@ function hasBalancedParens(s) {
|
|
|
42
91
|
}
|
|
43
92
|
return depth === 0;
|
|
44
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
|
|
96
|
+
* and the snake_case convention. Used to build correlated/preload subqueries on a
|
|
97
|
+
* RELATED model so their WHERE/join columns resolve like a direct query would.
|
|
98
|
+
*/
|
|
99
|
+
function buildColumnResolver(entityClass) {
|
|
100
|
+
const map = new Map();
|
|
101
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
102
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
103
|
+
map.set(col.propertyKey, db);
|
|
104
|
+
map.set(db, db);
|
|
105
|
+
}
|
|
106
|
+
return (col) => map.get(col) ?? camelToSnake(col);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
|
|
110
|
+
* (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
|
|
111
|
+
* preload/whereHas constraint on a RELATED model prepares its values like a direct query.
|
|
112
|
+
*/
|
|
113
|
+
function buildValuePreparer(entityClass) {
|
|
114
|
+
const prepares = new Map();
|
|
115
|
+
// Reverse map (db column → property) so a caller passing a DB name or an
|
|
116
|
+
// explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
|
|
117
|
+
// still routes through the property-keyed prepare/date maps — mirrors
|
|
118
|
+
// BaseRepository.#applyPrepare.
|
|
119
|
+
const byDbName = new Map();
|
|
120
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
121
|
+
if (col.prepare)
|
|
122
|
+
prepares.set(col.propertyKey, col.prepare);
|
|
123
|
+
byDbName.set(col.columnName ?? camelToSnake(col.propertyKey), col.propertyKey);
|
|
124
|
+
}
|
|
125
|
+
const dateCols = getDateColumnConfig(entityClass);
|
|
126
|
+
return (key, value) => {
|
|
127
|
+
const prop = byDbName.get(key) ?? key;
|
|
128
|
+
const p = prepares.get(prop);
|
|
129
|
+
// Query-builder value transform — no model instance, but the attribute is
|
|
130
|
+
// known (Adonis Lucid signature: value, attribute, model).
|
|
131
|
+
if (p)
|
|
132
|
+
return p(value, prop, undefined);
|
|
133
|
+
if (dateCols[prop] && value != null) {
|
|
134
|
+
if (value instanceof Date)
|
|
135
|
+
return value.toISOString();
|
|
136
|
+
return dateTimeAtlasAdapter.prepare(value);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
|
|
142
|
+
function joinValueHasToISO(v) {
|
|
143
|
+
return (typeof v === "object" &&
|
|
144
|
+
v !== null &&
|
|
145
|
+
"toISO" in v &&
|
|
146
|
+
typeof v.toISO === "function");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
|
|
150
|
+
* `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
|
|
151
|
+
* column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
|
|
152
|
+
* borrow the root model's adapter for a same-named column on a different table
|
|
153
|
+
* (Knex binds join values model-agnostically; we add only safe universal
|
|
154
|
+
* serialization so a DateTime still lowers to ISO like `where()`).
|
|
155
|
+
*/
|
|
156
|
+
function lowerJoinValue(value) {
|
|
157
|
+
if (value instanceof Date)
|
|
158
|
+
return value.toISOString();
|
|
159
|
+
if (joinValueHasToISO(value))
|
|
160
|
+
return value.toISO();
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Does a join column's table reference (`ref`) denote the root model's own table
|
|
165
|
+
* (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
|
|
166
|
+
* model declares (default schema) — `orders` matches a `public.orders` model — but
|
|
167
|
+
* it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
|
|
168
|
+
* accepts `orders.col`, while an unqualified `orders` model rejects
|
|
169
|
+
* `archive.orders.col` (a different schema the model never named) — keeping it
|
|
170
|
+
* foreign so the root model's `@Column` adapters aren't misapplied to it.
|
|
171
|
+
*/
|
|
172
|
+
function sameTableRef(ref, modelTable) {
|
|
173
|
+
const rs = ref.split(".");
|
|
174
|
+
const ms = modelTable.split(".");
|
|
175
|
+
// The reference cannot be MORE qualified than the model (it can only drop the
|
|
176
|
+
// schema, never assert a new one) — otherwise treat it as a foreign table.
|
|
177
|
+
if (rs.length > ms.length)
|
|
178
|
+
return false;
|
|
179
|
+
for (let i = 1; i <= rs.length; i++) {
|
|
180
|
+
if (rs[rs.length - i] !== ms[ms.length - i])
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
45
185
|
/** Set an empty relation value on every parent and return no related rows. */
|
|
46
186
|
function assignEmptyRelation(entities, relationName, single) {
|
|
47
187
|
for (const e of entities)
|
|
@@ -68,10 +208,12 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
|
|
|
68
208
|
return throughToParent;
|
|
69
209
|
}
|
|
70
210
|
/**
|
|
71
|
-
* Process-wide strict mode flag. When enabled, `whereRaw()
|
|
72
|
-
* throw unconditionally — forcing every
|
|
73
|
-
* `whereExpr()` / `joinOn()` /
|
|
74
|
-
*
|
|
211
|
+
* Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
|
|
212
|
+
* `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
|
|
213
|
+
* call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
|
|
214
|
+
* builder paths. The connection-level `db.query()` / `db.execute()` stay available
|
|
215
|
+
* as the explicit, parameterised break-glass. Intended for prod hardening on apps
|
|
216
|
+
* that can't audit every call site manually.
|
|
75
217
|
*
|
|
76
218
|
* Enable via:
|
|
77
219
|
* - `setAtlasStrictMode(true)` at app bootstrap
|
|
@@ -82,7 +224,7 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
|
|
|
82
224
|
* `__internal: true` flag on the call — not exposed in the public types.
|
|
83
225
|
*/
|
|
84
226
|
let atlasStrictMode;
|
|
85
|
-
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
|
|
227
|
+
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
|
|
86
228
|
export function setAtlasStrictMode(enabled) {
|
|
87
229
|
atlasStrictMode = enabled;
|
|
88
230
|
}
|
|
@@ -120,14 +262,46 @@ export class Paginator {
|
|
|
120
262
|
meta;
|
|
121
263
|
#baseUrl;
|
|
122
264
|
#queryString = {};
|
|
123
|
-
|
|
265
|
+
#metaKeys;
|
|
266
|
+
constructor(items, base, metaKeys) {
|
|
124
267
|
this.items = items;
|
|
125
268
|
const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
|
|
126
269
|
this.meta = { ...base, lastPage, firstPage: 1 };
|
|
270
|
+
this.#metaKeys = metaKeys;
|
|
127
271
|
}
|
|
128
272
|
all() {
|
|
129
273
|
return this.items;
|
|
130
274
|
}
|
|
275
|
+
// Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
|
|
276
|
+
// carried in `.meta`, exposed directly on the instance for convenience.
|
|
277
|
+
/** Total row count across all pages. */
|
|
278
|
+
get total() {
|
|
279
|
+
return this.meta.total;
|
|
280
|
+
}
|
|
281
|
+
/** Rows per page. */
|
|
282
|
+
get perPage() {
|
|
283
|
+
return this.meta.perPage;
|
|
284
|
+
}
|
|
285
|
+
/** The current page number. */
|
|
286
|
+
get currentPage() {
|
|
287
|
+
return this.meta.currentPage;
|
|
288
|
+
}
|
|
289
|
+
/** The last page number. */
|
|
290
|
+
get lastPage() {
|
|
291
|
+
return this.meta.lastPage;
|
|
292
|
+
}
|
|
293
|
+
/** The first page number (always 1). */
|
|
294
|
+
get firstPage() {
|
|
295
|
+
return this.meta.firstPage;
|
|
296
|
+
}
|
|
297
|
+
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
298
|
+
get hasPages() {
|
|
299
|
+
return this.meta.lastPage > 1;
|
|
300
|
+
}
|
|
301
|
+
/** True when there is at least one more page after the current one (AdonisJS `hasMorePages`). */
|
|
302
|
+
get hasMorePages() {
|
|
303
|
+
return this.meta.currentPage < this.meta.lastPage;
|
|
304
|
+
}
|
|
131
305
|
serialize(opts) {
|
|
132
306
|
const data = this.items.map((item) => {
|
|
133
307
|
if (!opts?.fields)
|
|
@@ -137,7 +311,9 @@ export class Paginator {
|
|
|
137
311
|
picked[f] = item[f];
|
|
138
312
|
return picked;
|
|
139
313
|
});
|
|
140
|
-
|
|
314
|
+
// Same meta shape as toJSON — snake_case keys via the naming strategy's
|
|
315
|
+
// paginationMetaKeys, plus page URLs when a baseUrl is set.
|
|
316
|
+
return { data, meta: this.#buildMeta() };
|
|
141
317
|
}
|
|
142
318
|
baseUrl(url) {
|
|
143
319
|
this.#baseUrl = url;
|
|
@@ -147,26 +323,70 @@ export class Paginator {
|
|
|
147
323
|
this.#queryString = qs;
|
|
148
324
|
return this;
|
|
149
325
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Build the URL for a page number, honouring `baseUrl` + `queryString`.
|
|
328
|
+
* Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
|
|
329
|
+
*/
|
|
330
|
+
getUrl(page) {
|
|
331
|
+
if (!this.#baseUrl)
|
|
332
|
+
return "";
|
|
333
|
+
const params = new URLSearchParams();
|
|
334
|
+
for (const [k, v] of Object.entries(this.#queryString))
|
|
335
|
+
params.set(k, String(v));
|
|
336
|
+
params.set("page", String(page));
|
|
337
|
+
return `${this.#baseUrl}?${params.toString()}`;
|
|
338
|
+
}
|
|
339
|
+
/** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
|
|
340
|
+
getNextPageUrl() {
|
|
341
|
+
return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
|
|
342
|
+
}
|
|
343
|
+
/** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
|
|
344
|
+
getPreviousPageUrl() {
|
|
345
|
+
return this.meta.currentPage > 1
|
|
346
|
+
? this.getUrl(this.meta.currentPage - 1)
|
|
347
|
+
: null;
|
|
348
|
+
}
|
|
349
|
+
/** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
|
|
350
|
+
getUrlsForRange(start, end) {
|
|
351
|
+
const lo = Math.max(1, start);
|
|
352
|
+
const hi = Math.min(this.meta.lastPage, end);
|
|
353
|
+
const range = [];
|
|
354
|
+
for (let page = lo; page <= hi; page++)
|
|
355
|
+
range.push({
|
|
356
|
+
page,
|
|
357
|
+
url: this.getUrl(page),
|
|
358
|
+
isActive: page === this.meta.currentPage,
|
|
359
|
+
});
|
|
360
|
+
return range;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Build the serialized `meta` object: the raw camelCase fields plus page URLs
|
|
364
|
+
* (when a baseUrl is set), remapped through the naming strategy's
|
|
365
|
+
* `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
|
|
366
|
+
* Shared by {@link toJSON} and {@link serialize} so they never diverge.
|
|
367
|
+
*/
|
|
368
|
+
#buildMeta() {
|
|
369
|
+
const raw = { ...this.meta };
|
|
154
370
|
if (this.#baseUrl) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
meta.lastPageUrl = build(this.meta.lastPage);
|
|
164
|
-
if (this.meta.currentPage < this.meta.lastPage)
|
|
165
|
-
meta.nextPageUrl = build(this.meta.currentPage + 1);
|
|
166
|
-
if (this.meta.currentPage > 1)
|
|
167
|
-
meta.previousPageUrl = build(this.meta.currentPage - 1);
|
|
371
|
+
raw.firstPageUrl = this.getUrl(1);
|
|
372
|
+
raw.lastPageUrl = this.getUrl(this.meta.lastPage);
|
|
373
|
+
const next = this.getNextPageUrl();
|
|
374
|
+
const prev = this.getPreviousPageUrl();
|
|
375
|
+
if (next)
|
|
376
|
+
raw.nextPageUrl = next;
|
|
377
|
+
if (prev)
|
|
378
|
+
raw.previousPageUrl = prev;
|
|
168
379
|
}
|
|
169
|
-
|
|
380
|
+
const keys = this.#metaKeys;
|
|
381
|
+
if (!keys)
|
|
382
|
+
return raw;
|
|
383
|
+
const meta = {};
|
|
384
|
+
for (const [k, v] of Object.entries(raw))
|
|
385
|
+
meta[keys[k] ?? k] = v;
|
|
386
|
+
return meta;
|
|
387
|
+
}
|
|
388
|
+
toJSON() {
|
|
389
|
+
return { data: this.items, meta: this.#buildMeta() };
|
|
170
390
|
}
|
|
171
391
|
}
|
|
172
392
|
/** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
|
|
@@ -193,15 +413,38 @@ export class ModelQuery {
|
|
|
193
413
|
#subqueryAlias;
|
|
194
414
|
/** Raw JOIN fragments — Story 29.4. */
|
|
195
415
|
#joins = [];
|
|
196
|
-
/** Row lock mode — Story 30.8. */
|
|
416
|
+
/** Row lock base mode — Story 30.8. */
|
|
197
417
|
#lockMode = null;
|
|
418
|
+
/** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
|
|
419
|
+
#lockModifier = null;
|
|
420
|
+
/** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
|
|
421
|
+
#sideloaded = null;
|
|
198
422
|
/** Per-query debug flag — Story 29.11. */
|
|
199
423
|
#debugFlag = false;
|
|
200
424
|
/** Distinct flag — Story 29.5. */
|
|
201
425
|
#distinct = false;
|
|
426
|
+
#distinctOn = [];
|
|
427
|
+
/** GROUP BY columns (Lucid parity). */
|
|
428
|
+
#groupBy = [];
|
|
429
|
+
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
430
|
+
#having = [];
|
|
431
|
+
/** CTEs registered via `.with()` (Lucid parity). */
|
|
432
|
+
#ctes = [];
|
|
433
|
+
/** UNION / UNION ALL branches (Lucid parity). */
|
|
434
|
+
#unions = [];
|
|
435
|
+
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
436
|
+
#pivotWheres = [];
|
|
437
|
+
/**
|
|
438
|
+
* Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
|
|
439
|
+
* the relation proxy's scoped query; invoked at `#buildSpec()` time with the
|
|
440
|
+
* CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
|
|
441
|
+
* back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
|
|
442
|
+
* time would freeze the predicate before those calls and silently drop them).
|
|
443
|
+
*/
|
|
444
|
+
#pivotExists;
|
|
202
445
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
203
446
|
#dialect;
|
|
204
|
-
constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect()) {
|
|
447
|
+
constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect(), prepareValue = (_c, v) => v, onDomainEvents) {
|
|
205
448
|
this.#tableName = tableName;
|
|
206
449
|
this.#db = db;
|
|
207
450
|
this.#hydrateFn = hydrateFn;
|
|
@@ -209,7 +452,13 @@ export class ModelQuery {
|
|
|
209
452
|
this.#resolveColumn = resolveColumn;
|
|
210
453
|
this.#softDeletes = softDeletes;
|
|
211
454
|
this.#dialect = dialect;
|
|
455
|
+
this.#prepareValue = prepareValue;
|
|
456
|
+
this.#onDomainEvents = onDomainEvents;
|
|
212
457
|
}
|
|
458
|
+
/** @see ValuePreparer — identity unless the owning repository wires prepare in. */
|
|
459
|
+
#prepareValue;
|
|
460
|
+
/** Domain-event bus threaded from the owning repository — propagated to preload repos. */
|
|
461
|
+
#onDomainEvents;
|
|
213
462
|
/** Include soft-deleted rows in the result (default behavior excludes them). */
|
|
214
463
|
withTrashed() {
|
|
215
464
|
this.#softScope = "with-trashed";
|
|
@@ -234,11 +483,29 @@ export class ModelQuery {
|
|
|
234
483
|
}
|
|
235
484
|
/** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
|
|
236
485
|
select(columns) {
|
|
237
|
-
|
|
486
|
+
const list = Array.isArray(columns)
|
|
238
487
|
? columns
|
|
239
488
|
: columns.split(",").map((c) => c.trim());
|
|
489
|
+
this.#select = list.map((c) => this.#resolveSelect(c));
|
|
240
490
|
return this;
|
|
241
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* Resolve a bare model-property select/returning target to its DB column
|
|
494
|
+
* (honouring `@Column({ columnName })`), leaving expressions / aliases /
|
|
495
|
+
* qualified names / `*` untouched. A bare identifier IS validated through the
|
|
496
|
+
* column resolver — so a typo like `select('lable')` raises the same Atlas
|
|
497
|
+
* error as `where`/`orderBy`, rather than reaching the DB.
|
|
498
|
+
*/
|
|
499
|
+
#resolveSelect(col) {
|
|
500
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col))
|
|
501
|
+
return this.#resolveColumn(col);
|
|
502
|
+
// `col as alias` — resolve the (bare) column part to its DB name, keep the
|
|
503
|
+
// alias verbatim, so `select('label as name')` honours a columnName override.
|
|
504
|
+
const aliased = col.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
|
505
|
+
if (aliased)
|
|
506
|
+
return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
|
|
507
|
+
return col;
|
|
508
|
+
}
|
|
242
509
|
where(columnOrCb, operatorOrValue, value) {
|
|
243
510
|
if (typeof columnOrCb === "function") {
|
|
244
511
|
this.#wheres.push(this.#buildGroup("and", columnOrCb));
|
|
@@ -271,13 +538,63 @@ export class ModelQuery {
|
|
|
271
538
|
});
|
|
272
539
|
return this;
|
|
273
540
|
}
|
|
541
|
+
andWhere(columnOrCb, operatorOrValue, value) {
|
|
542
|
+
// The 2-arg overload must not forward a phantom third argument: `where`
|
|
543
|
+
// switches on `value === undefined` to tell `(col, value)` from
|
|
544
|
+
// `(col, operator, value)`.
|
|
545
|
+
return typeof columnOrCb === "function"
|
|
546
|
+
? this.where(columnOrCb)
|
|
547
|
+
: value === undefined
|
|
548
|
+
? this.where(columnOrCb, operatorOrValue)
|
|
549
|
+
: this.where(columnOrCb, operatorOrValue, value);
|
|
550
|
+
}
|
|
551
|
+
/** Alias of {@link whereNot} (Lucid parity). */
|
|
552
|
+
andWhereNot(column, value) {
|
|
553
|
+
return this.whereNot(column, value);
|
|
554
|
+
}
|
|
555
|
+
/** Alias of {@link whereIn} (Lucid parity). */
|
|
556
|
+
andWhereIn(column, values) {
|
|
557
|
+
return this.whereIn(column, values);
|
|
558
|
+
}
|
|
559
|
+
/** Alias of {@link whereNotIn} (Lucid parity). */
|
|
560
|
+
andWhereNotIn(column, values) {
|
|
561
|
+
return this.whereNotIn(column, values);
|
|
562
|
+
}
|
|
563
|
+
/** Alias of {@link whereNull} (Lucid parity). */
|
|
564
|
+
andWhereNull(column) {
|
|
565
|
+
return this.whereNull(column);
|
|
566
|
+
}
|
|
567
|
+
/** Alias of {@link whereNotNull} (Lucid parity). */
|
|
568
|
+
andWhereNotNull(column) {
|
|
569
|
+
return this.whereNotNull(column);
|
|
570
|
+
}
|
|
571
|
+
/** Alias of {@link whereBetween} (Lucid parity). */
|
|
572
|
+
andWhereBetween(column, range) {
|
|
573
|
+
return this.whereBetween(column, range);
|
|
574
|
+
}
|
|
575
|
+
/** Alias of {@link whereNotBetween} (Lucid parity). */
|
|
576
|
+
andWhereNotBetween(column, range) {
|
|
577
|
+
return this.whereNotBetween(column, range);
|
|
578
|
+
}
|
|
579
|
+
/** Alias of {@link whereLike} (Lucid parity). */
|
|
580
|
+
andWhereLike(column, pattern) {
|
|
581
|
+
return this.whereLike(column, pattern);
|
|
582
|
+
}
|
|
583
|
+
/** Alias of {@link whereILike} (Lucid parity). */
|
|
584
|
+
andWhereILike(column, pattern) {
|
|
585
|
+
return this.whereILike(column, pattern);
|
|
586
|
+
}
|
|
587
|
+
/** Alias of {@link whereColumn} (Lucid parity). */
|
|
588
|
+
andWhereColumn(left, operator, right) {
|
|
589
|
+
return this.whereColumn(left, operator, right);
|
|
590
|
+
}
|
|
274
591
|
/** `WHERE col != ?` — negation of `where`. */
|
|
275
592
|
whereNot(column, value) {
|
|
276
593
|
this.#wheres.push({
|
|
277
594
|
type: "and",
|
|
278
595
|
column: this.#resolveColumn(column),
|
|
279
596
|
operator: "!=",
|
|
280
|
-
value,
|
|
597
|
+
value: this.#prep(column, value),
|
|
281
598
|
});
|
|
282
599
|
return this;
|
|
283
600
|
}
|
|
@@ -297,7 +614,7 @@ export class ModelQuery {
|
|
|
297
614
|
type: "and",
|
|
298
615
|
column: this.#resolveColumn(column),
|
|
299
616
|
operator: "IN",
|
|
300
|
-
value: [...source],
|
|
617
|
+
value: this.#prep(column, [...source]),
|
|
301
618
|
});
|
|
302
619
|
return this;
|
|
303
620
|
}
|
|
@@ -317,7 +634,7 @@ export class ModelQuery {
|
|
|
317
634
|
type: "and",
|
|
318
635
|
column: this.#resolveColumn(column),
|
|
319
636
|
operator: "NOT IN",
|
|
320
|
-
value: [...source],
|
|
637
|
+
value: this.#prep(column, [...source]),
|
|
321
638
|
});
|
|
322
639
|
return this;
|
|
323
640
|
}
|
|
@@ -327,7 +644,7 @@ export class ModelQuery {
|
|
|
327
644
|
type: "and",
|
|
328
645
|
column: this.#resolveColumn(column),
|
|
329
646
|
operator: "BETWEEN",
|
|
330
|
-
value: [...range],
|
|
647
|
+
value: this.#prep(column, [...range]),
|
|
331
648
|
});
|
|
332
649
|
return this;
|
|
333
650
|
}
|
|
@@ -337,7 +654,7 @@ export class ModelQuery {
|
|
|
337
654
|
type: "and",
|
|
338
655
|
column: this.#resolveColumn(column),
|
|
339
656
|
operator: "NOT BETWEEN",
|
|
340
|
-
value: [...range],
|
|
657
|
+
value: this.#prep(column, [...range]),
|
|
341
658
|
});
|
|
342
659
|
return this;
|
|
343
660
|
}
|
|
@@ -365,6 +682,119 @@ export class ModelQuery {
|
|
|
365
682
|
});
|
|
366
683
|
return this;
|
|
367
684
|
}
|
|
685
|
+
// ─── OR-combined variants (AdonisJS orWhere* family) ─────────
|
|
686
|
+
// Same predicates as the whereX methods above, combined with OR instead of
|
|
687
|
+
// AND — the named ergonomics Lucid exposes (vs emulating with `orWhere(cb)`).
|
|
688
|
+
/** `OR col IS NULL`. */
|
|
689
|
+
orWhereNull(column) {
|
|
690
|
+
this.#wheres.push({
|
|
691
|
+
type: "or",
|
|
692
|
+
column: this.#resolveColumn(column),
|
|
693
|
+
operator: "IS NULL",
|
|
694
|
+
value: null,
|
|
695
|
+
});
|
|
696
|
+
return this;
|
|
697
|
+
}
|
|
698
|
+
/** `OR col IS NOT NULL`. */
|
|
699
|
+
orWhereNotNull(column) {
|
|
700
|
+
this.#wheres.push({
|
|
701
|
+
type: "or",
|
|
702
|
+
column: this.#resolveColumn(column),
|
|
703
|
+
operator: "IS NOT NULL",
|
|
704
|
+
value: null,
|
|
705
|
+
});
|
|
706
|
+
return this;
|
|
707
|
+
}
|
|
708
|
+
/** `OR col != ?`. */
|
|
709
|
+
orWhereNot(column, value) {
|
|
710
|
+
this.#wheres.push({
|
|
711
|
+
type: "or",
|
|
712
|
+
column: this.#resolveColumn(column),
|
|
713
|
+
operator: "!=",
|
|
714
|
+
value: this.#prep(column, value),
|
|
715
|
+
});
|
|
716
|
+
return this;
|
|
717
|
+
}
|
|
718
|
+
/** `OR col IN (...)` — array or `ModelQuery` subquery source. */
|
|
719
|
+
orWhereIn(column, source) {
|
|
720
|
+
if (source instanceof _a) {
|
|
721
|
+
this.#wheres.push({
|
|
722
|
+
type: "or",
|
|
723
|
+
kind: "inSub",
|
|
724
|
+
negated: false,
|
|
725
|
+
column: this.#resolveColumn(column),
|
|
726
|
+
subquery: source.#buildSpec(),
|
|
727
|
+
});
|
|
728
|
+
return this;
|
|
729
|
+
}
|
|
730
|
+
this.#wheres.push({
|
|
731
|
+
type: "or",
|
|
732
|
+
column: this.#resolveColumn(column),
|
|
733
|
+
operator: "IN",
|
|
734
|
+
value: this.#prep(column, [...source]),
|
|
735
|
+
});
|
|
736
|
+
return this;
|
|
737
|
+
}
|
|
738
|
+
/** `OR col NOT IN (...)` — array or `ModelQuery` subquery source. */
|
|
739
|
+
orWhereNotIn(column, source) {
|
|
740
|
+
if (source instanceof _a) {
|
|
741
|
+
this.#wheres.push({
|
|
742
|
+
type: "or",
|
|
743
|
+
kind: "inSub",
|
|
744
|
+
negated: true,
|
|
745
|
+
column: this.#resolveColumn(column),
|
|
746
|
+
subquery: source.#buildSpec(),
|
|
747
|
+
});
|
|
748
|
+
return this;
|
|
749
|
+
}
|
|
750
|
+
this.#wheres.push({
|
|
751
|
+
type: "or",
|
|
752
|
+
column: this.#resolveColumn(column),
|
|
753
|
+
operator: "NOT IN",
|
|
754
|
+
value: this.#prep(column, [...source]),
|
|
755
|
+
});
|
|
756
|
+
return this;
|
|
757
|
+
}
|
|
758
|
+
/** `OR col BETWEEN ? AND ?`. */
|
|
759
|
+
orWhereBetween(column, range) {
|
|
760
|
+
this.#wheres.push({
|
|
761
|
+
type: "or",
|
|
762
|
+
column: this.#resolveColumn(column),
|
|
763
|
+
operator: "BETWEEN",
|
|
764
|
+
value: this.#prep(column, [...range]),
|
|
765
|
+
});
|
|
766
|
+
return this;
|
|
767
|
+
}
|
|
768
|
+
/** `OR col NOT BETWEEN ? AND ?`. */
|
|
769
|
+
orWhereNotBetween(column, range) {
|
|
770
|
+
this.#wheres.push({
|
|
771
|
+
type: "or",
|
|
772
|
+
column: this.#resolveColumn(column),
|
|
773
|
+
operator: "NOT BETWEEN",
|
|
774
|
+
value: this.#prep(column, [...range]),
|
|
775
|
+
});
|
|
776
|
+
return this;
|
|
777
|
+
}
|
|
778
|
+
/** `OR col LIKE ?`. */
|
|
779
|
+
orWhereLike(column, pattern) {
|
|
780
|
+
this.#wheres.push({
|
|
781
|
+
type: "or",
|
|
782
|
+
column: this.#resolveColumn(column),
|
|
783
|
+
operator: "LIKE",
|
|
784
|
+
value: pattern,
|
|
785
|
+
});
|
|
786
|
+
return this;
|
|
787
|
+
}
|
|
788
|
+
/** `OR col ILIKE ?` (rewritten to LOWER() LIKE LOWER() on sqlite/mysql). */
|
|
789
|
+
orWhereILike(column, pattern) {
|
|
790
|
+
this.#wheres.push({
|
|
791
|
+
type: "or",
|
|
792
|
+
column: this.#resolveColumn(column),
|
|
793
|
+
operator: "ILIKE",
|
|
794
|
+
value: pattern,
|
|
795
|
+
});
|
|
796
|
+
return this;
|
|
797
|
+
}
|
|
368
798
|
/**
|
|
369
799
|
* **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
|
|
370
800
|
* `?`-style bindings. The Rust compiler re-indexes the placeholders so they
|
|
@@ -404,15 +834,47 @@ export class ModelQuery {
|
|
|
404
834
|
* Not exported from the package barrel — only accessible inside the Atlas
|
|
405
835
|
* codebase via direct ModelQuery instance access.
|
|
406
836
|
*/
|
|
407
|
-
#pushWhereRaw(sql, bindings = []) {
|
|
837
|
+
#pushWhereRaw(sql, bindings = [], type = "and") {
|
|
408
838
|
this.#wheres.push({
|
|
409
|
-
type
|
|
839
|
+
type,
|
|
410
840
|
kind: "raw",
|
|
411
841
|
sql,
|
|
412
842
|
bindings: [...bindings],
|
|
413
843
|
});
|
|
414
844
|
return this;
|
|
415
845
|
}
|
|
846
|
+
/** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
|
|
847
|
+
andWhereRaw(sql, bindings = []) {
|
|
848
|
+
return this.whereRaw(sql, bindings);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* `OR <raw fragment>` (Lucid parity).
|
|
852
|
+
*
|
|
853
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
854
|
+
* Subject to the same strict-mode gate as {@link whereRaw}.
|
|
855
|
+
*/
|
|
856
|
+
orWhereRaw(sql, bindings = []) {
|
|
857
|
+
this.#assertRawAllowed("orWhereRaw");
|
|
858
|
+
return this.#pushWhereRaw(sql, bindings, "or");
|
|
859
|
+
}
|
|
860
|
+
/** Shared strict-mode gate for the raw WHERE entry points. */
|
|
861
|
+
#assertRawAllowed(method) {
|
|
862
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
863
|
+
throw new Error(`${method}() is disabled in Atlas strict mode. ` +
|
|
864
|
+
"Use whereExpr() or a structured builder method instead. " +
|
|
865
|
+
"Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Framework-internal: register the deferred m2m EXISTS predicate for a lazy
|
|
870
|
+
* `related().query()`. The builder is re-invoked on every `#buildSpec()` with
|
|
871
|
+
* the pivot constraints known at that moment, so `.wherePivot()` added after
|
|
872
|
+
* the proxy returned still applies. Not exported from the barrel.
|
|
873
|
+
*/
|
|
874
|
+
setPivotExistsBuilder(builder) {
|
|
875
|
+
this.#pivotExists = builder;
|
|
876
|
+
return this;
|
|
877
|
+
}
|
|
416
878
|
whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
|
|
417
879
|
// 3-arg form: whereExpr(col, op, value)
|
|
418
880
|
// 4-arg form: whereExpr(col, extraExpr, op, value)
|
|
@@ -431,6 +893,18 @@ export class ModelQuery {
|
|
|
431
893
|
if (!hasBalancedParens(extra)) {
|
|
432
894
|
throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
|
|
433
895
|
}
|
|
896
|
+
// The charset blocks comparison/quote symbols, but bare SQL keywords
|
|
897
|
+
// (OR / AND / IS / NOT / SELECT …) are just letters and would slip
|
|
898
|
+
// through, letting `extra` alter the predicate's logical structure
|
|
899
|
+
// (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
|
|
900
|
+
// SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
|
|
901
|
+
// token — arithmetic on columns/numbers/functions only.
|
|
902
|
+
for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
|
903
|
+
if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
|
|
904
|
+
throw new Error(`whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
|
|
905
|
+
"whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.");
|
|
906
|
+
}
|
|
907
|
+
}
|
|
434
908
|
// `op` is interpolated raw into the fragment below, so it MUST be
|
|
435
909
|
// allow-listed — the 3-arg path gets this from the Rust operator
|
|
436
910
|
// validation, but the raw 4-arg path bypasses Rust and would
|
|
@@ -447,11 +921,250 @@ export class ModelQuery {
|
|
|
447
921
|
// the operator against the allow-list above.
|
|
448
922
|
if (hasExtra) {
|
|
449
923
|
const q = this.#quote(resolved);
|
|
450
|
-
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
924
|
+
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
925
|
+
this.#prep(column, value),
|
|
926
|
+
]);
|
|
451
927
|
}
|
|
452
|
-
this.#wheres.push({
|
|
928
|
+
this.#wheres.push({
|
|
929
|
+
type: "and",
|
|
930
|
+
column: resolved,
|
|
931
|
+
operator: op,
|
|
932
|
+
value: this.#prep(column, value),
|
|
933
|
+
});
|
|
453
934
|
return this;
|
|
454
935
|
}
|
|
936
|
+
/**
|
|
937
|
+
* Compare two COLUMNS (AdonisJS/Knex `whereColumn`) — `WHERE "a" op "b"`.
|
|
938
|
+
* Both sides go through the identifier quoter (injection-safe) and the
|
|
939
|
+
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
940
|
+
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
941
|
+
*/
|
|
942
|
+
// ─── EXISTS ───────────────────────────────────────────────
|
|
943
|
+
//
|
|
944
|
+
// `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
|
|
945
|
+
// builder `repo.query()` actually hands back, so it was unreachable from
|
|
946
|
+
// normal use. The subquery is another `ModelQuery`; correlate it to the
|
|
947
|
+
// outer table with `whereColumn`:
|
|
948
|
+
//
|
|
949
|
+
// userRepo.query().whereExists(
|
|
950
|
+
// postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
|
|
951
|
+
// )
|
|
952
|
+
//
|
|
953
|
+
// For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
|
|
954
|
+
// join predicate from the relation metadata.
|
|
955
|
+
/** `WHERE EXISTS (subquery)` (Lucid parity). */
|
|
956
|
+
whereExists(subquery) {
|
|
957
|
+
return this.#pushExists("and", false, subquery);
|
|
958
|
+
}
|
|
959
|
+
/** Alias of {@link whereExists} (Lucid parity). */
|
|
960
|
+
andWhereExists(subquery) {
|
|
961
|
+
return this.#pushExists("and", false, subquery);
|
|
962
|
+
}
|
|
963
|
+
/** `OR EXISTS (subquery)` (Lucid parity). */
|
|
964
|
+
orWhereExists(subquery) {
|
|
965
|
+
return this.#pushExists("or", false, subquery);
|
|
966
|
+
}
|
|
967
|
+
/** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
|
|
968
|
+
whereNotExists(subquery) {
|
|
969
|
+
return this.#pushExists("and", true, subquery);
|
|
970
|
+
}
|
|
971
|
+
/** Alias of {@link whereNotExists} (Lucid parity). */
|
|
972
|
+
andWhereNotExists(subquery) {
|
|
973
|
+
return this.#pushExists("and", true, subquery);
|
|
974
|
+
}
|
|
975
|
+
/** `OR NOT EXISTS (subquery)` (Lucid parity). */
|
|
976
|
+
orWhereNotExists(subquery) {
|
|
977
|
+
return this.#pushExists("or", true, subquery);
|
|
978
|
+
}
|
|
979
|
+
// ─── JSON ─────────────────────────────────────────────────
|
|
980
|
+
//
|
|
981
|
+
// Every value crosses the boundary as a bound param — the path and the
|
|
982
|
+
// compared value both. Only the column is a quoted identifier. Path access
|
|
983
|
+
// and containment are each spelled per dialect, and SQLite has no
|
|
984
|
+
// containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
|
|
985
|
+
/**
|
|
986
|
+
* `WHERE <col at path> <op> ?` — compare a value inside a JSON column
|
|
987
|
+
* (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
|
|
988
|
+
*
|
|
989
|
+
* query.whereJsonPath('data', '$.address.city', '=', 'Paris')
|
|
990
|
+
*/
|
|
991
|
+
whereJsonPath(column, path, operator, value) {
|
|
992
|
+
return this.#pushJson("and", false, "path", column, value, path, operator);
|
|
993
|
+
}
|
|
994
|
+
/** Alias of {@link whereJsonPath} (Lucid parity). */
|
|
995
|
+
andWhereJsonPath(column, path, operator, value) {
|
|
996
|
+
return this.#pushJson("and", false, "path", column, value, path, operator);
|
|
997
|
+
}
|
|
998
|
+
/** `OR <col at path> <op> ?` (Lucid parity). */
|
|
999
|
+
orWhereJsonPath(column, path, operator, value) {
|
|
1000
|
+
return this.#pushJson("or", false, "path", column, value, path, operator);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
|
|
1004
|
+
* `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
|
|
1005
|
+
*
|
|
1006
|
+
* Postgres and MySQL only — SQLite has no JSON containment operator and the
|
|
1007
|
+
* compiler raises `E_UNSUPPORTED` there.
|
|
1008
|
+
*/
|
|
1009
|
+
whereJsonSupersetOf(column, value) {
|
|
1010
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1011
|
+
}
|
|
1012
|
+
/** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
|
|
1013
|
+
andWhereJsonSupersetOf(column, value) {
|
|
1014
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1015
|
+
}
|
|
1016
|
+
/** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
|
|
1017
|
+
orWhereJsonSupersetOf(column, value) {
|
|
1018
|
+
return this.#pushJson("or", false, "superset", column, value);
|
|
1019
|
+
}
|
|
1020
|
+
/** `WHERE NOT (<col> @> ?)` (Lucid parity). */
|
|
1021
|
+
whereNotJsonSupersetOf(column, value) {
|
|
1022
|
+
return this.#pushJson("and", true, "superset", column, value);
|
|
1023
|
+
}
|
|
1024
|
+
/** `OR NOT (<col> @> ?)` (Lucid parity). */
|
|
1025
|
+
orWhereNotJsonSupersetOf(column, value) {
|
|
1026
|
+
return this.#pushJson("or", true, "superset", column, value);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
|
|
1030
|
+
* `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
|
|
1031
|
+
*/
|
|
1032
|
+
whereJsonSubsetOf(column, value) {
|
|
1033
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1034
|
+
}
|
|
1035
|
+
/** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
|
|
1036
|
+
andWhereJsonSubsetOf(column, value) {
|
|
1037
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1038
|
+
}
|
|
1039
|
+
/** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
|
|
1040
|
+
orWhereJsonSubsetOf(column, value) {
|
|
1041
|
+
return this.#pushJson("or", false, "subset", column, value);
|
|
1042
|
+
}
|
|
1043
|
+
/** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
|
|
1044
|
+
whereNotJsonSubsetOf(column, value) {
|
|
1045
|
+
return this.#pushJson("and", true, "subset", column, value);
|
|
1046
|
+
}
|
|
1047
|
+
/** `OR NOT (<col> <@ ?)` (Lucid parity). */
|
|
1048
|
+
orWhereNotJsonSubsetOf(column, value) {
|
|
1049
|
+
return this.#pushJson("or", true, "subset", column, value);
|
|
1050
|
+
}
|
|
1051
|
+
#pushJson(type, negated, jsonOp, column, value, path, operator) {
|
|
1052
|
+
// A JSONPath is bound, not interpolated, so injection is not the concern
|
|
1053
|
+
// here — a clear early error for a malformed path is. Lucid/Knex paths
|
|
1054
|
+
// start at the document root.
|
|
1055
|
+
if (path !== undefined && !path.startsWith("$")) {
|
|
1056
|
+
throw new Error(`whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`);
|
|
1057
|
+
}
|
|
1058
|
+
// Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
|
|
1059
|
+
// and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
|
|
1060
|
+
// as-is would not cast. A path comparison keeps its scalar value.
|
|
1061
|
+
const bound = jsonOp === "path"
|
|
1062
|
+
? value
|
|
1063
|
+
: typeof value === "string"
|
|
1064
|
+
? value
|
|
1065
|
+
: JSON.stringify(value);
|
|
1066
|
+
this.#wheres.push({
|
|
1067
|
+
type,
|
|
1068
|
+
kind: "json",
|
|
1069
|
+
jsonOp,
|
|
1070
|
+
column: this.#resolveColumn(column),
|
|
1071
|
+
negated,
|
|
1072
|
+
path,
|
|
1073
|
+
operator,
|
|
1074
|
+
value: bound,
|
|
1075
|
+
});
|
|
1076
|
+
return this;
|
|
1077
|
+
}
|
|
1078
|
+
#pushExists(type, negated, subquery) {
|
|
1079
|
+
// `#buildSpec` is private, but private access is per-class, not per
|
|
1080
|
+
// instance: another ModelQuery's spec is reachable from here.
|
|
1081
|
+
this.#wheres.push({
|
|
1082
|
+
type,
|
|
1083
|
+
kind: "exists",
|
|
1084
|
+
negated,
|
|
1085
|
+
subquery: subquery.#buildSpec(),
|
|
1086
|
+
});
|
|
1087
|
+
return this;
|
|
1088
|
+
}
|
|
1089
|
+
whereColumn(left, operator, right) {
|
|
1090
|
+
return this.#whereColumn("and", left, operator, right);
|
|
1091
|
+
}
|
|
1092
|
+
/** `OR`-combined {@link whereColumn}. */
|
|
1093
|
+
orWhereColumn(left, operator, right) {
|
|
1094
|
+
return this.#whereColumn("or", left, operator, right);
|
|
1095
|
+
}
|
|
1096
|
+
/** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
|
|
1097
|
+
whereNotColumn(left, operator, right) {
|
|
1098
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1099
|
+
}
|
|
1100
|
+
/** Alias of {@link whereNotColumn} (Lucid parity). */
|
|
1101
|
+
andWhereNotColumn(left, operator, right) {
|
|
1102
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1103
|
+
}
|
|
1104
|
+
/** `OR NOT (left <op> right)` (Lucid parity). */
|
|
1105
|
+
orWhereNotColumn(left, operator, right) {
|
|
1106
|
+
return this.#whereColumn("or", left, operator, right, true);
|
|
1107
|
+
}
|
|
1108
|
+
#whereColumn(type, left, operator, right, negated = false) {
|
|
1109
|
+
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
1110
|
+
throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
|
|
1111
|
+
}
|
|
1112
|
+
// Both operands are interpolated as raw identifiers (no value binding for a
|
|
1113
|
+
// column reference), and #quote is a plain wrapper that does NOT escape an
|
|
1114
|
+
// embedded quote — so validate each RESOLVED identifier against a strict
|
|
1115
|
+
// `[table.]column` charset. This closes the injection surface regardless of
|
|
1116
|
+
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
1117
|
+
const safe = (name) => {
|
|
1118
|
+
const resolved = this.#resolveColumnReference(name);
|
|
1119
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
|
|
1120
|
+
throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
|
|
1121
|
+
}
|
|
1122
|
+
// Quote each dotted segment separately → `"table"."column"`, never a
|
|
1123
|
+
// single mis-quoted `"table.column"`.
|
|
1124
|
+
return resolved
|
|
1125
|
+
.split(".")
|
|
1126
|
+
.map((part) => this.#quote(part))
|
|
1127
|
+
.join(".");
|
|
1128
|
+
};
|
|
1129
|
+
const predicate = `${safe(left)} ${operator} ${safe(right)}`;
|
|
1130
|
+
// Both operands are already validated identifiers and the operator is
|
|
1131
|
+
// allow-listed, so wrapping in NOT(...) adds no new surface.
|
|
1132
|
+
const sql = negated ? `NOT (${predicate})` : predicate;
|
|
1133
|
+
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
1134
|
+
return this;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Resolve a column reference that may legitimately point at a table other
|
|
1138
|
+
* than this query's own.
|
|
1139
|
+
*
|
|
1140
|
+
* `#resolveColumn` only knows the entity's own columns, so it rejects
|
|
1141
|
+
* anything qualified. That is right for a value predicate, but wrong for a
|
|
1142
|
+
* column-to-column one: a correlated subquery
|
|
1143
|
+
* (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
|
|
1144
|
+
* and a joined query both have to name another table, and atlas cannot know
|
|
1145
|
+
* that table's columns. So: an unqualified name resolves as usual (typos
|
|
1146
|
+
* still get the helpful error), and a `table.column` naming a different
|
|
1147
|
+
* table passes through — validated against the identifier charset here and
|
|
1148
|
+
* quoted segment by segment by the caller, never interpolated loose. A typo
|
|
1149
|
+
* in that case surfaces as a database error rather than an atlas one, which
|
|
1150
|
+
* is the unavoidable cost of referencing a table we have no metadata for.
|
|
1151
|
+
*/
|
|
1152
|
+
#resolveColumnReference(name) {
|
|
1153
|
+
const qualified = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
|
|
1154
|
+
if (!qualified)
|
|
1155
|
+
return this.#resolveColumn(name);
|
|
1156
|
+
const [, table, column] = qualified;
|
|
1157
|
+
// Our own table: resolve the column half so `@Column({ columnName })` and
|
|
1158
|
+
// the camel→snake convention still apply.
|
|
1159
|
+
if (table === this.#tableName) {
|
|
1160
|
+
return `${table}.${this.#resolveColumn(column)}`;
|
|
1161
|
+
}
|
|
1162
|
+
// Another table in scope (outer query or JOIN). Charset-checked by the
|
|
1163
|
+
// regex above and quoted segment by segment by the caller — strict mode
|
|
1164
|
+
// does not apply, since its concern is unvalidated SQL reaching the
|
|
1165
|
+
// compiler and this identifier is validated.
|
|
1166
|
+
return `${table}.${column}`;
|
|
1167
|
+
}
|
|
455
1168
|
/**
|
|
456
1169
|
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
457
1170
|
* by the existence of related rows, optionally constrained by a callback.
|
|
@@ -476,6 +1189,14 @@ export class ModelQuery {
|
|
|
476
1189
|
this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
|
|
477
1190
|
return this;
|
|
478
1191
|
}
|
|
1192
|
+
/** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
|
|
1193
|
+
andWhereHas(relationName, callback) {
|
|
1194
|
+
return this.whereHas(relationName, callback);
|
|
1195
|
+
}
|
|
1196
|
+
/** Alias of {@link whereDoesntHave} (Lucid parity). */
|
|
1197
|
+
andWhereDoesntHave(relationName, callback) {
|
|
1198
|
+
return this.whereDoesntHave(relationName, callback);
|
|
1199
|
+
}
|
|
479
1200
|
/**
|
|
480
1201
|
* Short form of `whereHas`. With an operator + count, emits a count threshold:
|
|
481
1202
|
* has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
|
|
@@ -494,6 +1215,19 @@ export class ModelQuery {
|
|
|
494
1215
|
this.#wheres.push(this.#buildExistsClause("and", true, relationName));
|
|
495
1216
|
return this;
|
|
496
1217
|
}
|
|
1218
|
+
/** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
|
|
1219
|
+
orDoesntHave(relationName) {
|
|
1220
|
+
this.#wheres.push(this.#buildExistsClause("or", true, relationName));
|
|
1221
|
+
return this;
|
|
1222
|
+
}
|
|
1223
|
+
/** Alias of {@link has} (Lucid parity) — `has` is already AND. */
|
|
1224
|
+
andHas(relationName, countOp, countThreshold) {
|
|
1225
|
+
return this.has(relationName, countOp, countThreshold);
|
|
1226
|
+
}
|
|
1227
|
+
/** Alias of {@link doesntHave} (Lucid parity). */
|
|
1228
|
+
andDoesntHave(relationName) {
|
|
1229
|
+
return this.doesntHave(relationName);
|
|
1230
|
+
}
|
|
497
1231
|
/**
|
|
498
1232
|
* Set this query's projection alias — only meaningful when this ModelQuery
|
|
499
1233
|
* is used as the sub-builder callback argument of `withCount` / `withAggregate`.
|
|
@@ -529,23 +1263,25 @@ export class ModelQuery {
|
|
|
529
1263
|
// --- Top-level scalar executors (Story 29.5) ---
|
|
530
1264
|
/** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
|
|
531
1265
|
async count(column = "*") {
|
|
532
|
-
const expr = column === "*"
|
|
1266
|
+
const expr = column === "*"
|
|
1267
|
+
? "COUNT(*)"
|
|
1268
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
|
|
533
1269
|
return Number((await this.#runScalar(expr)) ?? 0);
|
|
534
1270
|
}
|
|
535
1271
|
async sum(column) {
|
|
536
|
-
const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
|
|
1272
|
+
const v = await this.#runScalar(`SUM(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
537
1273
|
return v === null || v === undefined ? null : Number(v);
|
|
538
1274
|
}
|
|
539
1275
|
async avg(column) {
|
|
540
|
-
const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
|
|
1276
|
+
const v = await this.#runScalar(`AVG(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
541
1277
|
return v === null || v === undefined ? null : Number(v);
|
|
542
1278
|
}
|
|
543
1279
|
async min(column) {
|
|
544
|
-
const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
|
|
1280
|
+
const v = await this.#runScalar(`MIN(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
545
1281
|
return v === null || v === undefined ? null : Number(v);
|
|
546
1282
|
}
|
|
547
1283
|
async max(column) {
|
|
548
|
-
const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
|
|
1284
|
+
const v = await this.#runScalar(`MAX(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
549
1285
|
return v === null || v === undefined ? null : Number(v);
|
|
550
1286
|
}
|
|
551
1287
|
/**
|
|
@@ -576,6 +1312,290 @@ export class ModelQuery {
|
|
|
576
1312
|
this.#orderBys.push({ column: this.#resolveColumn(column), direction });
|
|
577
1313
|
return this;
|
|
578
1314
|
}
|
|
1315
|
+
/**
|
|
1316
|
+
* `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
|
|
1317
|
+
* no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
|
|
1318
|
+
* alias.
|
|
1319
|
+
*
|
|
1320
|
+
* query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
|
|
1321
|
+
*
|
|
1322
|
+
* The fragment keeps its position among the plain `orderBy` terms.
|
|
1323
|
+
*
|
|
1324
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
1325
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
1326
|
+
*
|
|
1327
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1328
|
+
*/
|
|
1329
|
+
orderByRaw(sql) {
|
|
1330
|
+
this.#assertRawAllowed("orderByRaw");
|
|
1331
|
+
this.#orderBys.push({ raw: sql });
|
|
1332
|
+
return this;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
1336
|
+
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
1337
|
+
* For a grouping expression with no typed form, see {@link groupByRaw}.
|
|
1338
|
+
*/
|
|
1339
|
+
groupBy(...columns) {
|
|
1340
|
+
for (const c of columns)
|
|
1341
|
+
this.#groupBy.push(this.#resolveColumn(c));
|
|
1342
|
+
return this;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
|
|
1346
|
+
* no typed form, e.g. `DATE_TRUNC('day', created_at)`.
|
|
1347
|
+
*
|
|
1348
|
+
* The fragment keeps its position among the plain `groupBy` terms.
|
|
1349
|
+
*
|
|
1350
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
1351
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
1352
|
+
*
|
|
1353
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1354
|
+
*/
|
|
1355
|
+
groupByRaw(sql) {
|
|
1356
|
+
this.#assertRawAllowed("groupByRaw");
|
|
1357
|
+
this.#groupBy.push({ raw: sql });
|
|
1358
|
+
return this;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
1362
|
+
* A bare model property is resolved through the entity column map (honouring
|
|
1363
|
+
* `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
|
|
1364
|
+
* expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
|
|
1365
|
+
* `having` can still reference `withCount`/`withAggregate` aliases.
|
|
1366
|
+
*/
|
|
1367
|
+
having(column, operator, value) {
|
|
1368
|
+
this.#having.push({
|
|
1369
|
+
column: this.#resolveHavingCol(column),
|
|
1370
|
+
operator,
|
|
1371
|
+
value: this.#prep(column, value),
|
|
1372
|
+
type: "and",
|
|
1373
|
+
});
|
|
1374
|
+
return this;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Resolve a HAVING column: a bare model property maps to its DB column
|
|
1378
|
+
* (honouring `@Column({ columnName })`), but an aggregate expression
|
|
1379
|
+
* (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
|
|
1380
|
+
* so `having` can still reference `withCount`/`withAggregate` aliases.
|
|
1381
|
+
*/
|
|
1382
|
+
#resolveHavingCol(column) {
|
|
1383
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column))
|
|
1384
|
+
return column;
|
|
1385
|
+
try {
|
|
1386
|
+
return this.#resolveColumn(column);
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
return column;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
1393
|
+
orHaving(column, operator, value) {
|
|
1394
|
+
this.#having.push({
|
|
1395
|
+
column: this.#resolveHavingCol(column),
|
|
1396
|
+
operator,
|
|
1397
|
+
value: this.#prep(column, value),
|
|
1398
|
+
type: "or",
|
|
1399
|
+
});
|
|
1400
|
+
return this;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* **⚠ UNSAFE** — append a raw SQL `HAVING` fragment with `?` bindings
|
|
1404
|
+
* (AdonisJS/Lucid `havingRaw`). The Rust compiler re-indexes the placeholders;
|
|
1405
|
+
* everything else in `sql` is trusted verbatim. All values must go through
|
|
1406
|
+
* `bindings`.
|
|
1407
|
+
*
|
|
1408
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1409
|
+
*/
|
|
1410
|
+
havingRaw(sql, bindings = []) {
|
|
1411
|
+
// Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
|
|
1412
|
+
// surface, so prod hardening must be able to neutralise it too.
|
|
1413
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1414
|
+
throw new Error("havingRaw() is disabled in Atlas strict mode. " +
|
|
1415
|
+
"Use having(column, operator, value) instead.");
|
|
1416
|
+
}
|
|
1417
|
+
this.#having.push({
|
|
1418
|
+
kind: "raw",
|
|
1419
|
+
sql,
|
|
1420
|
+
bindings: [...bindings],
|
|
1421
|
+
type: "and",
|
|
1422
|
+
});
|
|
1423
|
+
return this;
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* `UNION (<query>)` (AdonisJS/Lucid `union`). The other query is compiled and
|
|
1427
|
+
* appended as a parenthesised UNION branch; its bindings are re-indexed into
|
|
1428
|
+
* the outer parameter list.
|
|
1429
|
+
*/
|
|
1430
|
+
union(query) {
|
|
1431
|
+
this.#unions.push({ query, all: false });
|
|
1432
|
+
return this;
|
|
1433
|
+
}
|
|
1434
|
+
/** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
|
|
1435
|
+
unionAll(query) {
|
|
1436
|
+
this.#unions.push({ query, all: true });
|
|
1437
|
+
return this;
|
|
1438
|
+
}
|
|
1439
|
+
/** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
|
|
1440
|
+
intersect(query) {
|
|
1441
|
+
this.#unions.push({ query, all: false, op: "intersect" });
|
|
1442
|
+
return this;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
|
|
1446
|
+
*
|
|
1447
|
+
* Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
|
|
1448
|
+
* INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
|
|
1449
|
+
* `E_UNSUPPORTED` there rather than emitting a syntax error.
|
|
1450
|
+
*/
|
|
1451
|
+
intersectAll(query) {
|
|
1452
|
+
this.#unions.push({ query, all: true, op: "intersect" });
|
|
1453
|
+
return this;
|
|
1454
|
+
}
|
|
1455
|
+
/** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
|
|
1456
|
+
except(query) {
|
|
1457
|
+
this.#unions.push({ query, all: false, op: "except" });
|
|
1458
|
+
return this;
|
|
1459
|
+
}
|
|
1460
|
+
/** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
|
|
1461
|
+
exceptAll(query) {
|
|
1462
|
+
this.#unions.push({ query, all: true, op: "except" });
|
|
1463
|
+
return this;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
1467
|
+
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
1468
|
+
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
1469
|
+
*/
|
|
1470
|
+
with(name, query) {
|
|
1471
|
+
return this.#pushCte("with", name, query, {});
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
|
|
1475
|
+
* (Lucid/Knex `withRecursive`), for trees and graph walks.
|
|
1476
|
+
*
|
|
1477
|
+
* RECURSIVE is a property of the WITH clause rather than of one CTE, so a
|
|
1478
|
+
* single recursive entry makes the whole clause recursive — which is what
|
|
1479
|
+
* all three dialects require. Mixing `with()` and `withRecursive()` is fine.
|
|
1480
|
+
*
|
|
1481
|
+
* The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
|
|
1482
|
+
* an anchor `SELECT` unioned with a select that references `<name>`.
|
|
1483
|
+
*/
|
|
1484
|
+
withRecursive(name, query) {
|
|
1485
|
+
return this.#pushCte("withRecursive", name, query, { recursive: true });
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
|
|
1489
|
+
* once and stashed (Lucid/Knex `withMaterialized`).
|
|
1490
|
+
*
|
|
1491
|
+
* Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
|
|
1492
|
+
* compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
|
|
1493
|
+
*/
|
|
1494
|
+
withMaterialized(name, query) {
|
|
1495
|
+
return this.#pushCte("withMaterialized", name, query, {
|
|
1496
|
+
materialized: true,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
/** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
|
|
1500
|
+
withNotMaterialized(name, query) {
|
|
1501
|
+
return this.#pushCte("withNotMaterialized", name, query, {
|
|
1502
|
+
materialized: false,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
#pushCte(method, name, query, options) {
|
|
1506
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1507
|
+
throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
|
|
1508
|
+
}
|
|
1509
|
+
this.#ctes.push({ name, query, ...options });
|
|
1510
|
+
return this;
|
|
1511
|
+
}
|
|
1512
|
+
wherePivot(column, operatorOrValue, value) {
|
|
1513
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
1514
|
+
}
|
|
1515
|
+
andWherePivot(column, operatorOrValue, value) {
|
|
1516
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
1517
|
+
}
|
|
1518
|
+
orWherePivot(column, operatorOrValue, value) {
|
|
1519
|
+
return this.#pushPivot("or", column, operatorOrValue, value);
|
|
1520
|
+
}
|
|
1521
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
|
|
1522
|
+
whereInPivot(column, values) {
|
|
1523
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
1524
|
+
}
|
|
1525
|
+
/** Alias of {@link whereInPivot} (Lucid parity). */
|
|
1526
|
+
andWhereInPivot(column, values) {
|
|
1527
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
1528
|
+
}
|
|
1529
|
+
/** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
|
|
1530
|
+
orWhereInPivot(column, values) {
|
|
1531
|
+
return this.#pushPivotOp("or", column, "IN", [...values]);
|
|
1532
|
+
}
|
|
1533
|
+
/** Alias of {@link whereInPivot} kept for the earlier atlas name. */
|
|
1534
|
+
wherePivotIn(column, values) {
|
|
1535
|
+
return this.whereInPivot(column, values);
|
|
1536
|
+
}
|
|
1537
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> != <value>` on the pivot table (AdonisJS Lucid `whereNotPivot`). */
|
|
1538
|
+
whereNotPivot(column, value) {
|
|
1539
|
+
return this.#pushPivotOp("and", column, "!=", value);
|
|
1540
|
+
}
|
|
1541
|
+
/** Alias of {@link whereNotPivot} (Lucid parity). */
|
|
1542
|
+
andWhereNotPivot(column, value) {
|
|
1543
|
+
return this.#pushPivotOp("and", column, "!=", value);
|
|
1544
|
+
}
|
|
1545
|
+
/** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
|
|
1546
|
+
orWhereNotPivot(column, value) {
|
|
1547
|
+
return this.#pushPivotOp("or", column, "!=", value);
|
|
1548
|
+
}
|
|
1549
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
|
|
1550
|
+
whereNotInPivot(column, values) {
|
|
1551
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
1552
|
+
}
|
|
1553
|
+
/** Alias of {@link whereNotInPivot} (Lucid parity). */
|
|
1554
|
+
andWhereNotInPivot(column, values) {
|
|
1555
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
1556
|
+
}
|
|
1557
|
+
/** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
|
|
1558
|
+
orWhereNotInPivot(column, values) {
|
|
1559
|
+
return this.#pushPivotOp("or", column, "NOT IN", [...values]);
|
|
1560
|
+
}
|
|
1561
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
|
|
1562
|
+
whereNullPivot(column) {
|
|
1563
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
1564
|
+
}
|
|
1565
|
+
/** Alias of {@link whereNullPivot} (Lucid parity). */
|
|
1566
|
+
andWhereNullPivot(column) {
|
|
1567
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
1568
|
+
}
|
|
1569
|
+
/** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
|
|
1570
|
+
orWhereNullPivot(column) {
|
|
1571
|
+
return this.#pushPivotOp("or", column, "IS NULL", null);
|
|
1572
|
+
}
|
|
1573
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
|
|
1574
|
+
whereNotNullPivot(column) {
|
|
1575
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
1576
|
+
}
|
|
1577
|
+
/** Alias of {@link whereNotNullPivot} (Lucid parity). */
|
|
1578
|
+
andWhereNotNullPivot(column) {
|
|
1579
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
1580
|
+
}
|
|
1581
|
+
/** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
|
|
1582
|
+
orWhereNotNullPivot(column) {
|
|
1583
|
+
return this.#pushPivotOp("or", column, "IS NOT NULL", null);
|
|
1584
|
+
}
|
|
1585
|
+
/** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
|
|
1586
|
+
#pushPivot(type, column, operatorOrValue, value) {
|
|
1587
|
+
return value === undefined
|
|
1588
|
+
? this.#pushPivotOp(type, column, "=", operatorOrValue)
|
|
1589
|
+
: this.#pushPivotOp(type, column, operatorOrValue, value);
|
|
1590
|
+
}
|
|
1591
|
+
#pushPivotOp(type, column, operator, value) {
|
|
1592
|
+
this.#pivotWheres.push({ column, operator, value, type });
|
|
1593
|
+
return this;
|
|
1594
|
+
}
|
|
1595
|
+
/** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
|
|
1596
|
+
get pivotConstraints() {
|
|
1597
|
+
return this.#pivotWheres;
|
|
1598
|
+
}
|
|
579
1599
|
limit(n) {
|
|
580
1600
|
// Guard here with a clear message — the Rust spec types limit as
|
|
581
1601
|
// u64, so a negative/non-integer otherwise surfaces as a cryptic
|
|
@@ -611,6 +1631,21 @@ export class ModelQuery {
|
|
|
611
1631
|
throw new Error(`No ${this.#tableName} found matching query`);
|
|
612
1632
|
return result;
|
|
613
1633
|
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Return the single matching row, or throw if there are zero OR more than one
|
|
1636
|
+
* (AdonisJS/Laravel `sole`). Use when exactly one row is a correctness
|
|
1637
|
+
* invariant — a second match signals a bug the silent `first()` would hide.
|
|
1638
|
+
*/
|
|
1639
|
+
async sole() {
|
|
1640
|
+
const rows = await this.limit(2).exec();
|
|
1641
|
+
if (rows.length === 0) {
|
|
1642
|
+
throw new Error(`No ${this.#tableName} found matching query (sole()).`);
|
|
1643
|
+
}
|
|
1644
|
+
if (rows.length > 1) {
|
|
1645
|
+
throw new Error(`Expected exactly one ${this.#tableName} but the query matched multiple rows (sole()).`);
|
|
1646
|
+
}
|
|
1647
|
+
return rows[0];
|
|
1648
|
+
}
|
|
614
1649
|
/**
|
|
615
1650
|
* Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
|
|
616
1651
|
* A chain like `await repo.query().where('active', true).orderBy('id')`
|
|
@@ -627,14 +1662,79 @@ export class ModelQuery {
|
|
|
627
1662
|
return this.exec().then(onfulfilled, onrejected);
|
|
628
1663
|
}
|
|
629
1664
|
/** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
|
|
1665
|
+
/**
|
|
1666
|
+
* DB column backing the soft-delete `deletedAt` property — honours a
|
|
1667
|
+
* `@Column({ columnName })` override, read straight from the entity metadata
|
|
1668
|
+
* (not the resolver callback, which is identity for subqueries/preloads).
|
|
1669
|
+
*/
|
|
1670
|
+
#deletedAtColumn() {
|
|
1671
|
+
const col = this.#entityClass
|
|
1672
|
+
? getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === "deletedAt")
|
|
1673
|
+
: undefined;
|
|
1674
|
+
return col?.columnName ?? "deleted_at";
|
|
1675
|
+
}
|
|
630
1676
|
#buildSpec() {
|
|
1677
|
+
// `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
|
|
1678
|
+
// compiler emits the lock clause only when a base mode is set, so a lone
|
|
1679
|
+
// modifier would be a SILENT no-op (dangerous for job-queue polling that
|
|
1680
|
+
// believes it skips locked rows). Fail loud instead. Order-independent: this
|
|
1681
|
+
// fires whether the modifier was chained before or after the base lock.
|
|
1682
|
+
if (this.#lockModifier && !this.#lockMode) {
|
|
1683
|
+
throw new Error(`${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`);
|
|
1684
|
+
}
|
|
1685
|
+
// With a JOIN and the default `SELECT *`, scope the projection to the base
|
|
1686
|
+
// table's declared columns so joined columns can't clobber the model's fields
|
|
1687
|
+
// (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
|
|
1688
|
+
// AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
|
|
1689
|
+
let selectCols = this.#select;
|
|
1690
|
+
if (this.#joins.length > 0 &&
|
|
1691
|
+
this.#select.length === 1 &&
|
|
1692
|
+
this.#select[0] === "*") {
|
|
1693
|
+
const cols = getColumnMetadata(this.#entityClass).map((c) => `${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`);
|
|
1694
|
+
if (cols.length > 0)
|
|
1695
|
+
selectCols = cols;
|
|
1696
|
+
}
|
|
1697
|
+
else if (!(selectCols.length === 1 && selectCols[0] === "*") &&
|
|
1698
|
+
selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))) {
|
|
1699
|
+
// A partial `select()` of PLAIN columns that omits the primary key would
|
|
1700
|
+
// hydrate a persisted entity with no PK — a later save() would then INSERT
|
|
1701
|
+
// instead of UPDATE (double-write / unique violation / spurious
|
|
1702
|
+
// beforeCreate). Auto-include the (base-table-qualified) PK so model
|
|
1703
|
+
// entities stay saveable. Aggregate/alias/expression selects are left
|
|
1704
|
+
// untouched — use `.pojo()` for those.
|
|
1705
|
+
const pkProp = getPrimaryKey(this.#entityClass);
|
|
1706
|
+
if (pkProp) {
|
|
1707
|
+
const pkCol = getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === pkProp)?.columnName ?? camelToSnake(pkProp);
|
|
1708
|
+
// The PK counts as present ONLY as the bare column or the BASE-table-
|
|
1709
|
+
// qualified column. A joined `other.id` must NOT satisfy it (its leaf
|
|
1710
|
+
// collides with the PK name but it's a different table's row) — otherwise
|
|
1711
|
+
// we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
|
|
1712
|
+
// save(). Appended last, `base.id` also wins the duplicate result key
|
|
1713
|
+
// (rows collect in column order, last-wins) so the base row's PK hydrates.
|
|
1714
|
+
const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
|
|
1715
|
+
if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
|
|
1716
|
+
selectCols = [...selectCols, baseQualifiedPk];
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
631
1720
|
const wheres = [...this.#wheres];
|
|
632
|
-
//
|
|
1721
|
+
// Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
|
|
1722
|
+
// `.wherePivot()` recorded since the proxy handed back this query (pushed to
|
|
1723
|
+
// the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
|
|
1724
|
+
if (this.#pivotExists) {
|
|
1725
|
+
const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
|
|
1726
|
+
wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
|
|
1727
|
+
}
|
|
1728
|
+
// Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
|
|
1729
|
+
// Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
|
|
1730
|
+
// override on the soft-delete column is honoured on the read side too — matching
|
|
1731
|
+
// the write side (delete/restore go through #dbColumn).
|
|
633
1732
|
if (this.#softDeletes) {
|
|
1733
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
634
1734
|
if (this.#softScope === "default") {
|
|
635
1735
|
wheres.push({
|
|
636
1736
|
type: "and",
|
|
637
|
-
column:
|
|
1737
|
+
column: deletedAtCol,
|
|
638
1738
|
operator: "IS NULL",
|
|
639
1739
|
value: null,
|
|
640
1740
|
});
|
|
@@ -642,7 +1742,7 @@ export class ModelQuery {
|
|
|
642
1742
|
else if (this.#softScope === "only-trashed") {
|
|
643
1743
|
wheres.push({
|
|
644
1744
|
type: "and",
|
|
645
|
-
column:
|
|
1745
|
+
column: deletedAtCol,
|
|
646
1746
|
operator: "IS NOT NULL",
|
|
647
1747
|
value: null,
|
|
648
1748
|
});
|
|
@@ -652,19 +1752,36 @@ export class ModelQuery {
|
|
|
652
1752
|
return {
|
|
653
1753
|
kind: "select",
|
|
654
1754
|
table: this.#tableName,
|
|
655
|
-
select:
|
|
1755
|
+
select: selectCols,
|
|
656
1756
|
selectSubqueries: this.#selectSubqueries,
|
|
657
1757
|
wheres,
|
|
658
1758
|
orderBy: this.#orderBys,
|
|
659
|
-
groupBy:
|
|
660
|
-
having:
|
|
1759
|
+
groupBy: this.#groupBy,
|
|
1760
|
+
having: this.#having,
|
|
661
1761
|
limit: this.#limit ?? null,
|
|
662
1762
|
offset: this.#offset ?? null,
|
|
663
1763
|
distinct: this.#distinct,
|
|
664
|
-
|
|
665
|
-
|
|
1764
|
+
distinctOn: this.#distinctOn,
|
|
1765
|
+
ctes: this.#ctes.map((c) => {
|
|
1766
|
+
const { sql, params } = c.query.toSQL();
|
|
1767
|
+
return {
|
|
1768
|
+
name: c.name,
|
|
1769
|
+
sql,
|
|
1770
|
+
params,
|
|
1771
|
+
recursive: c.recursive ?? false,
|
|
1772
|
+
materialized: c.materialized ?? null,
|
|
1773
|
+
};
|
|
1774
|
+
}),
|
|
1775
|
+
unions: this.#unions.map((u) => {
|
|
1776
|
+
const { sql, params } = u.query.toSQL();
|
|
1777
|
+
return { sql, params, all: u.all, op: u.op ?? null };
|
|
1778
|
+
}),
|
|
666
1779
|
joins: this.#joins,
|
|
667
|
-
lockMode: this.#lockMode
|
|
1780
|
+
lockMode: this.#lockMode
|
|
1781
|
+
? this.#lockModifier
|
|
1782
|
+
? `${this.#lockMode} ${this.#lockModifier}`
|
|
1783
|
+
: this.#lockMode
|
|
1784
|
+
: null,
|
|
668
1785
|
};
|
|
669
1786
|
}
|
|
670
1787
|
/** Build SQL + params via the Rust query compiler. */
|
|
@@ -695,7 +1812,7 @@ export class ModelQuery {
|
|
|
695
1812
|
}
|
|
696
1813
|
async #doExec() {
|
|
697
1814
|
const { sql, params } = this.toSQL();
|
|
698
|
-
const rawRows = await this.#db.query(sql, params);
|
|
1815
|
+
const rawRows = await this.#db.query(sql, params, this.#meta("exec"));
|
|
699
1816
|
// Peel withCount / withAggregate alias columns off the raw row into $extras
|
|
700
1817
|
// BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
|
|
701
1818
|
const extraKeys = this.#selectSubqueries.map((s) => s.alias);
|
|
@@ -710,6 +1827,9 @@ export class ModelQuery {
|
|
|
710
1827
|
const entity = this.#hydrateFn(row);
|
|
711
1828
|
for (const [k, v] of Object.entries(picked))
|
|
712
1829
|
entity.setExtra(k, v);
|
|
1830
|
+
// Thread query-level sideloaded context onto each hydrated instance.
|
|
1831
|
+
if (this.#sideloaded)
|
|
1832
|
+
entity.$sideloaded = { ...this.#sideloaded };
|
|
713
1833
|
return entity;
|
|
714
1834
|
});
|
|
715
1835
|
// Resolve preloads (eager loading)
|
|
@@ -718,6 +1838,25 @@ export class ModelQuery {
|
|
|
718
1838
|
}
|
|
719
1839
|
return entities;
|
|
720
1840
|
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Execute and return PLAIN row objects (raw snake_case DB columns), skipping
|
|
1843
|
+
* model hydration, `@column({ consume })`, dirty-tracking and preloads —
|
|
1844
|
+
* AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
|
|
1845
|
+
* instances aren't needed.
|
|
1846
|
+
*/
|
|
1847
|
+
async pojo() {
|
|
1848
|
+
const { sql, params } = this.toSQL();
|
|
1849
|
+
return this.#db.query(sql, params);
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Thread arbitrary context onto every instance this query hydrates, exposed as
|
|
1853
|
+
* `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
|
|
1854
|
+
* user, so hooks/computed can read it. Merges across calls. Chainable.
|
|
1855
|
+
*/
|
|
1856
|
+
sideload(values) {
|
|
1857
|
+
this.#sideloaded = { ...this.#sideloaded, ...values };
|
|
1858
|
+
return this;
|
|
1859
|
+
}
|
|
721
1860
|
/** Resolve preloaded relations via batched subqueries (no N+1). */
|
|
722
1861
|
async #resolvePreloads(entities) {
|
|
723
1862
|
if (!this.#entityClass)
|
|
@@ -737,33 +1876,84 @@ export class ModelQuery {
|
|
|
737
1876
|
/** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
|
|
738
1877
|
#buildPreloadContext(relation, relationName) {
|
|
739
1878
|
const relatedClass = relation.target();
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1879
|
+
// Boot the related model's metadata on demand (Lucid parity): a preload
|
|
1880
|
+
// must not silently no-op just because the related class hasn't been
|
|
1881
|
+
// touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
|
|
1882
|
+
// static table / naming strategy when the decorator hasn't run.
|
|
1883
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
743
1884
|
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
744
1885
|
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
745
1886
|
// on a freshly constructed instance, so `key in entity` is always false and
|
|
746
1887
|
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
747
1888
|
const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
|
|
748
1889
|
const validColumns = new Set();
|
|
1890
|
+
// Reverse map (db column → property) so an explicit `@Column({ columnName })`
|
|
1891
|
+
// on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
|
|
1892
|
+
const byDbName = new Map();
|
|
1893
|
+
// Capture the related model's `@Column({ consume })` adapters + its date
|
|
1894
|
+
// columns so preloaded rows hydrate identically to a direct query — dates
|
|
1895
|
+
// become Chronos DateTime, decimal/etc adapters run. Without this, a
|
|
1896
|
+
// preloaded relation left column values raw (Lucid parity bug + a runtime
|
|
1897
|
+
// footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
|
|
1898
|
+
const consumes = new Map();
|
|
1899
|
+
let relatedPkDb = camelToSnake(relatedPkName);
|
|
749
1900
|
for (const col of getColumnMetadata(relatedClass)) {
|
|
1901
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
750
1902
|
validColumns.add(col.propertyKey);
|
|
751
|
-
validColumns.add(
|
|
1903
|
+
validColumns.add(db);
|
|
1904
|
+
byDbName.set(db, col.propertyKey);
|
|
1905
|
+
if (col.consume)
|
|
1906
|
+
consumes.set(col.propertyKey, col.consume);
|
|
1907
|
+
// The related PK may be multi-word (postId→post_id) or columnName-mapped;
|
|
1908
|
+
// its DB column name is what the WHERE + row indexing must use.
|
|
1909
|
+
if (col.propertyKey === relatedPkName)
|
|
1910
|
+
relatedPkDb = db;
|
|
752
1911
|
}
|
|
753
1912
|
validColumns.add(relatedPkName);
|
|
754
1913
|
validColumns.add(camelToSnake(relatedPkName));
|
|
1914
|
+
const dateCols = getDateColumnConfig(relatedClass);
|
|
1915
|
+
const consumeValue = (prop, value, model) => {
|
|
1916
|
+
const c = consumes.get(prop);
|
|
1917
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
1918
|
+
if (c)
|
|
1919
|
+
return c(value, prop, model);
|
|
1920
|
+
if (dateCols[prop] && value != null)
|
|
1921
|
+
return dateTimeAtlasAdapter.consume(value);
|
|
1922
|
+
return value;
|
|
1923
|
+
};
|
|
1924
|
+
// A repository for the related model so preloaded instances are hydrated with
|
|
1925
|
+
// the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
|
|
1926
|
+
// not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
|
|
1927
|
+
// refresh()/fresh()/load()/related(). Without this a preloaded relation
|
|
1928
|
+
// looked $isNew/$isLocal/$dirty and a later save() over-updated it.
|
|
1929
|
+
const relatedRepo = new BaseRepository(relatedClass, this.#db, {
|
|
1930
|
+
dialect: this.#dialect,
|
|
1931
|
+
});
|
|
1932
|
+
// Propagate the domain-event bus so save()/create() from a preloaded relation
|
|
1933
|
+
// still dispatch events (a fresh repo has none by default).
|
|
1934
|
+
relatedRepo.onDomainEvents = this.#onDomainEvents;
|
|
755
1935
|
const hydrate = (row) => {
|
|
756
1936
|
const entity = new relatedClass();
|
|
757
1937
|
for (const [key, value] of Object.entries(row)) {
|
|
758
1938
|
const camelKey = snakeToCamel(key);
|
|
759
|
-
const targetKey =
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1939
|
+
const targetKey = byDbName.get(key) ??
|
|
1940
|
+
(validColumns.has(camelKey)
|
|
1941
|
+
? camelKey
|
|
1942
|
+
: validColumns.has(key)
|
|
1943
|
+
? key
|
|
1944
|
+
: null);
|
|
764
1945
|
if (targetKey !== null)
|
|
765
|
-
entity.setProp(targetKey, value);
|
|
1946
|
+
entity.setProp(targetKey, consumeValue(targetKey, value, entity));
|
|
766
1947
|
}
|
|
1948
|
+
// Freeze the clean snapshot + mark persisted/from-DB, and back-reference
|
|
1949
|
+
// the related repo (mirrors BaseRepository.#hydrate).
|
|
1950
|
+
entity.markAsPersisted();
|
|
1951
|
+
entity.markAsFromDatabase();
|
|
1952
|
+
Object.defineProperty(entity, REPO_REF, {
|
|
1953
|
+
value: relatedRepo,
|
|
1954
|
+
enumerable: false,
|
|
1955
|
+
configurable: true,
|
|
1956
|
+
});
|
|
767
1957
|
return entity;
|
|
768
1958
|
};
|
|
769
1959
|
return {
|
|
@@ -771,7 +1961,9 @@ export class ModelQuery {
|
|
|
771
1961
|
relationName,
|
|
772
1962
|
relatedClass,
|
|
773
1963
|
relatedTable: relatedMeta.tableName,
|
|
774
|
-
|
|
1964
|
+
// DB column name (not property) — used as the WHERE column in the related
|
|
1965
|
+
// query AND to index the returned DB rows by their PK value.
|
|
1966
|
+
relatedPk: relatedPkDb,
|
|
775
1967
|
hydrate,
|
|
776
1968
|
runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
|
|
777
1969
|
runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
|
|
@@ -805,15 +1997,17 @@ export class ModelQuery {
|
|
|
805
1997
|
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
806
1998
|
}
|
|
807
1999
|
const throughClass = relation.through();
|
|
808
|
-
const throughMeta =
|
|
809
|
-
if (!throughMeta)
|
|
810
|
-
throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
|
|
2000
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
811
2001
|
const throughTable = throughMeta.tableName;
|
|
812
2002
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
813
2003
|
const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
814
2004
|
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
815
2005
|
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
816
|
-
|
|
2006
|
+
// secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
|
|
2007
|
+
// DB column — resolve the through model's key (default: its PK), honouring a
|
|
2008
|
+
// multi-word / columnName PK. (parentLocal stays a property: it's read off
|
|
2009
|
+
// the parent ENTITY, not a row.)
|
|
2010
|
+
const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
|
|
817
2011
|
const parentIds = entities
|
|
818
2012
|
.map((e) => e[parentLocal])
|
|
819
2013
|
.filter((v) => v != null);
|
|
@@ -942,12 +2136,53 @@ export class ModelQuery {
|
|
|
942
2136
|
// on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
|
|
943
2137
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
944
2138
|
const otherKey = pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
945
|
-
|
|
2139
|
+
// The pivot FK stores `parent[localKey]` (default PK) — attach() writes it,
|
|
2140
|
+
// so preload MUST read back with the SAME key, else a custom-localKey m2m
|
|
2141
|
+
// writes `user_code = code` but reads `user_code IN (id)` and never matches.
|
|
2142
|
+
const pk = ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
946
2143
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
947
2144
|
if (ids.length === 0)
|
|
948
2145
|
return [];
|
|
949
|
-
//
|
|
950
|
-
|
|
2146
|
+
// Extract PIVOT-table constraints (wherePivot / wherePivotIn) from the
|
|
2147
|
+
// preload callback by replaying it on a throwaway builder. The callback
|
|
2148
|
+
// also runs (again) inside runRelationQuery against the related table; both
|
|
2149
|
+
// runs are pure builder mutations, and pivot constraints are inert there.
|
|
2150
|
+
const pivotWheres = [];
|
|
2151
|
+
if (ctx.nestedCallback) {
|
|
2152
|
+
const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
|
|
2153
|
+
ctx.nestedCallback(scratch);
|
|
2154
|
+
// Apply the pivot column adapters' `prepare` to wherePivot values, so a
|
|
2155
|
+
// filter like wherePivot('amount', new Money(1)) matches what attach()/
|
|
2156
|
+
// sync() stored (they prepare the same extras on write).
|
|
2157
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
2158
|
+
for (const c of scratch.pivotConstraints) {
|
|
2159
|
+
const prep = pivotAdapters[c.column]?.prepare;
|
|
2160
|
+
// Same guards as the attach()/sync() write path: wrap a throwing
|
|
2161
|
+
// adapter with a column-annotated error and reject async adapters,
|
|
2162
|
+
// so filter and write agree on the adapter contract.
|
|
2163
|
+
const apply = (v) => {
|
|
2164
|
+
if (!prep)
|
|
2165
|
+
return v;
|
|
2166
|
+
let out;
|
|
2167
|
+
try {
|
|
2168
|
+
// Adonis Lucid signature: (value, attribute, model). wherePivot is
|
|
2169
|
+
// a query filter — attribute known, no model instance.
|
|
2170
|
+
out = prep(v, c.column, undefined);
|
|
2171
|
+
}
|
|
2172
|
+
catch (err) {
|
|
2173
|
+
throw wrapAdapterError("prepare", c.column, err);
|
|
2174
|
+
}
|
|
2175
|
+
assertNotPromise("prepare", c.column, out);
|
|
2176
|
+
return out;
|
|
2177
|
+
};
|
|
2178
|
+
const value = Array.isArray(c.value)
|
|
2179
|
+
? c.value.map(apply)
|
|
2180
|
+
: apply(c.value);
|
|
2181
|
+
pivotWheres.push({ ...c, value });
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
2185
|
+
const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
|
|
951
2186
|
if (pivotRows.length === 0) {
|
|
952
2187
|
for (const entity of entities)
|
|
953
2188
|
entity.setProp(relationName, []);
|
|
@@ -958,17 +2193,46 @@ export class ModelQuery {
|
|
|
958
2193
|
];
|
|
959
2194
|
// Step 2 — load all related entities in one query
|
|
960
2195
|
const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
|
|
2196
|
+
const pivotCols = pivot.pivotColumns ?? [];
|
|
2197
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
2198
|
+
// When pivot extras are projected, each (parent, related) edge gets its OWN
|
|
2199
|
+
// hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
|
|
2200
|
+
// across parents (Lucid gives distinct pivot-bearing instances). Otherwise a
|
|
2201
|
+
// single shared instance per related PK is reused (cheaper, current behaviour).
|
|
2202
|
+
const projectPivot = pivotCols.length > 0;
|
|
2203
|
+
const rawByRelatedPk = new Map();
|
|
961
2204
|
const byRelatedPk = new Map();
|
|
962
2205
|
const allRelated = [];
|
|
963
2206
|
for (const row of relRows) {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
2207
|
+
rawByRelatedPk.set(row[ctx.relatedPk], row);
|
|
2208
|
+
if (!projectPivot) {
|
|
2209
|
+
const hydrated = ctx.hydrate(row);
|
|
2210
|
+
byRelatedPk.set(row[ctx.relatedPk], hydrated);
|
|
2211
|
+
allRelated.push(hydrated);
|
|
2212
|
+
}
|
|
967
2213
|
}
|
|
968
|
-
// Step 3 — group via the pivot
|
|
2214
|
+
// Step 3 — group via the pivot, projecting declared pivotColumns into
|
|
2215
|
+
// `$extras.pivot_<col>` (running each column's `consume` adapter if any).
|
|
969
2216
|
const grouped = new Map();
|
|
970
2217
|
for (const pivotRow of pivotRows) {
|
|
971
|
-
|
|
2218
|
+
let related;
|
|
2219
|
+
if (projectPivot) {
|
|
2220
|
+
const raw = rawByRelatedPk.get(pivotRow[otherKey]);
|
|
2221
|
+
if (!raw)
|
|
2222
|
+
continue;
|
|
2223
|
+
related = ctx.hydrate(raw);
|
|
2224
|
+
for (const col of pivotCols) {
|
|
2225
|
+
const rawVal = pivotRow[col];
|
|
2226
|
+
const adapter = pivotAdapters[col];
|
|
2227
|
+
related.setExtra(`pivot_${col}`,
|
|
2228
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
2229
|
+
adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal);
|
|
2230
|
+
}
|
|
2231
|
+
allRelated.push(related);
|
|
2232
|
+
}
|
|
2233
|
+
else {
|
|
2234
|
+
related = byRelatedPk.get(pivotRow[otherKey]);
|
|
2235
|
+
}
|
|
972
2236
|
if (!related)
|
|
973
2237
|
continue;
|
|
974
2238
|
const parentId = pivotRow[foreignKey];
|
|
@@ -985,20 +2249,41 @@ export class ModelQuery {
|
|
|
985
2249
|
async #applyNestedPreloads(relatedEntities, ctx) {
|
|
986
2250
|
if (!ctx.nestedCallback || relatedEntities.length === 0)
|
|
987
2251
|
return;
|
|
988
|
-
const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass);
|
|
2252
|
+
const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass, buildColumnResolver(ctx.relatedClass), hasSoftDeletes(ctx.relatedClass), this.#dialect, buildValuePreparer(ctx.relatedClass));
|
|
989
2253
|
ctx.nestedCallback(sub);
|
|
990
2254
|
if (sub.#preloads.size > 0) {
|
|
991
2255
|
await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
|
|
992
2256
|
}
|
|
993
2257
|
}
|
|
994
2258
|
/** Compile + execute a `SELECT * FROM <table> WHERE <column> IN (...)` via the Rust compiler. */
|
|
995
|
-
async #runInQuery(table, column, values) {
|
|
2259
|
+
async #runInQuery(table, column, values, extraWheres = []) {
|
|
2260
|
+
const wheres = [
|
|
2261
|
+
{ column, operator: "IN", value: values, type: "and" },
|
|
2262
|
+
];
|
|
2263
|
+
// The caller's filters go in a parenthesised group, never flat beside the
|
|
2264
|
+
// `IN`. Flat, an `orWherePivot` would read as
|
|
2265
|
+
// `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
|
|
2266
|
+
// other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
|
|
2267
|
+
// With every filter ANDed the two forms are equivalent, so this changes
|
|
2268
|
+
// no existing query.
|
|
2269
|
+
if (extraWheres.length > 0) {
|
|
2270
|
+
wheres.push({
|
|
2271
|
+
kind: "group",
|
|
2272
|
+
type: "and",
|
|
2273
|
+
conditions: extraWheres.map((w) => ({
|
|
2274
|
+
column: w.column,
|
|
2275
|
+
operator: w.operator,
|
|
2276
|
+
value: w.value,
|
|
2277
|
+
type: w.type ?? "and",
|
|
2278
|
+
})),
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
996
2281
|
const spec = {
|
|
997
2282
|
kind: "select",
|
|
998
2283
|
table,
|
|
999
2284
|
select: ["*"],
|
|
1000
2285
|
selectSubqueries: [],
|
|
1001
|
-
wheres
|
|
2286
|
+
wheres,
|
|
1002
2287
|
orderBy: [],
|
|
1003
2288
|
groupBy: [],
|
|
1004
2289
|
having: [],
|
|
@@ -1025,7 +2310,11 @@ export class ModelQuery {
|
|
|
1025
2310
|
* inside the callback are re-collected later by `#applyNestedPreloads`.
|
|
1026
2311
|
*/
|
|
1027
2312
|
async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
|
|
1028
|
-
const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
|
|
2313
|
+
const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
|
|
2314
|
+
// Resolve columns + prepare values against the RELATED model so a preload
|
|
2315
|
+
// constraint (onQuery / callback) targeting a columnName-mapped or date
|
|
2316
|
+
// column compiles/binds like a direct query on that model.
|
|
2317
|
+
buildColumnResolver(relatedClass),
|
|
1029
2318
|
// Propagate the RELATED entity's soft-delete flag — hardcoding
|
|
1030
2319
|
// false here meant `preload('posts')` returned soft-deleted
|
|
1031
2320
|
// posts even when Post is @SoftDeletes (a data leak). The
|
|
@@ -1033,7 +2322,7 @@ export class ModelQuery {
|
|
|
1033
2322
|
// matching a direct query on that entity. (with-trashed on the
|
|
1034
2323
|
// related set, if ever needed, would be opted-in via the
|
|
1035
2324
|
// preload callback.)
|
|
1036
|
-
hasSoftDeletes(relatedClass), this.#dialect);
|
|
2325
|
+
hasSoftDeletes(relatedClass), this.#dialect, buildValuePreparer(relatedClass));
|
|
1037
2326
|
sub.whereIn(column, values);
|
|
1038
2327
|
if (relation.onQuery)
|
|
1039
2328
|
relation.onQuery(sub);
|
|
@@ -1072,28 +2361,50 @@ export class ModelQuery {
|
|
|
1072
2361
|
throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
|
|
1073
2362
|
}
|
|
1074
2363
|
const relatedClass = relation.target();
|
|
1075
|
-
const relatedMeta =
|
|
1076
|
-
if (!relatedMeta) {
|
|
1077
|
-
throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
|
|
1078
|
-
}
|
|
2364
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1079
2365
|
const relatedTable = relatedMeta.tableName;
|
|
1080
2366
|
const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
1081
2367
|
const parentTable = this.#tableName;
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
2368
|
+
// Strict single-segment identifier quote. This builds a RAW correlated
|
|
2369
|
+
// subquery fragment (no bind params for identifiers), so every segment must
|
|
2370
|
+
// be validated — a table/key from relation metadata carrying a quote/backtick
|
|
2371
|
+
// would otherwise emit invalid or injectable SQL. Same policy as
|
|
2372
|
+
// BaseRepository's lazy m2m path.
|
|
2373
|
+
const q = (name) => {
|
|
2374
|
+
if (!/^[A-Za-z0-9_]+$/.test(name)) {
|
|
2375
|
+
throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
|
|
2376
|
+
}
|
|
2377
|
+
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
2378
|
+
};
|
|
2379
|
+
// Table identifiers may be schema-qualified (`schema.table`) — quote each
|
|
2380
|
+
// dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
|
|
2381
|
+
// `public.users_roles` gets wrapped as ONE identifier and silently targets a
|
|
2382
|
+
// table literally named with a dot. Each segment still passes the strict
|
|
2383
|
+
// guard above. Columns stay single-segment via `q`.
|
|
2384
|
+
const qTable = (name) => name.split(".").map(q).join(".");
|
|
2385
|
+
const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
|
|
2386
|
+
// whereHas/withCount constraints run against the RELATED model — resolve
|
|
2387
|
+
// its columns (columnName/multi-word) and prepare its values like a direct query.
|
|
2388
|
+
buildColumnResolver(relatedClass), false, this.#dialect, buildValuePreparer(relatedClass));
|
|
2389
|
+
// `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
|
|
2390
|
+
// PK); resolve each to its DB column via the owning model so a multi-word or
|
|
2391
|
+
// `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
|
|
2392
|
+
// `firstKey`/`secondKey` are DB column names already — left as-is.
|
|
2393
|
+
const resolveParent = buildColumnResolver(this.#entityClass);
|
|
1086
2394
|
switch (relation.type) {
|
|
1087
2395
|
case "hasOne":
|
|
1088
2396
|
case "hasMany": {
|
|
1089
|
-
|
|
1090
|
-
|
|
2397
|
+
// Honour custom foreignKey/localKey exactly like the eager loader —
|
|
2398
|
+
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
2399
|
+
const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2400
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2401
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`);
|
|
1091
2402
|
break;
|
|
1092
2403
|
}
|
|
1093
2404
|
case "belongsTo": {
|
|
1094
|
-
const fk = `${camelToSnake(relatedClass.name)}_id`;
|
|
1095
|
-
const
|
|
1096
|
-
sub.#pushWhereRaw(`${
|
|
2405
|
+
const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
2406
|
+
const ownerKey = buildColumnResolver(relatedClass)(relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id");
|
|
2407
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`);
|
|
1097
2408
|
break;
|
|
1098
2409
|
}
|
|
1099
2410
|
case "manyToMany": {
|
|
@@ -1105,20 +2416,35 @@ export class ModelQuery {
|
|
|
1105
2416
|
// name stripped of a trailing `s` — see the eager loader above.
|
|
1106
2417
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1107
2418
|
const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
2419
|
+
const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
|
|
2420
|
+
const relatedPk = getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPkProp)?.columnName ?? camelToSnake(relatedPkProp);
|
|
2421
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2422
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(relatedPk)} IN ` +
|
|
2423
|
+
`(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
|
|
2424
|
+
`WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`);
|
|
2425
|
+
break;
|
|
2426
|
+
}
|
|
2427
|
+
case "hasOneThrough":
|
|
2428
|
+
case "hasManyThrough": {
|
|
2429
|
+
// Two-hop correlated EXISTS: parent → through → related. Mirrors the
|
|
2430
|
+
// eager loader's key resolution (`#resolveThrough`) exactly so
|
|
2431
|
+
// whereHas/withCount agree with what preload() would return.
|
|
2432
|
+
if (!relation.through) {
|
|
2433
|
+
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
2434
|
+
}
|
|
2435
|
+
const throughClass = relation.through();
|
|
2436
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
2437
|
+
const throughTable = throughMeta.tableName;
|
|
2438
|
+
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
2439
|
+
const parentLocal = resolveParent(relation.localKey ?? parentPk);
|
|
2440
|
+
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2441
|
+
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
2442
|
+
const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
|
|
2443
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(secondKey)} IN ` +
|
|
2444
|
+
`(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
|
|
2445
|
+
`WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`);
|
|
1112
2446
|
break;
|
|
1113
2447
|
}
|
|
1114
|
-
default:
|
|
1115
|
-
// hasOneThrough / hasManyThrough build a 2-hop correlated subquery,
|
|
1116
|
-
// which isn't implemented here. Fail loud — falling through would
|
|
1117
|
-
// leave `sub` WITHOUT a join predicate, so whereHas/withCount would
|
|
1118
|
-
// silently match/count EVERY related row.
|
|
1119
|
-
throw new Error(`whereHas/withCount on a '${relation.type}' relation ` +
|
|
1120
|
-
`(${this.#entityClass.name}.${relationName}) is not supported yet. ` +
|
|
1121
|
-
`Use a direct hasMany/belongsTo/manyToMany relation, or filter via a sub-query.`);
|
|
1122
2448
|
}
|
|
1123
2449
|
return sub;
|
|
1124
2450
|
}
|
|
@@ -1132,8 +2458,8 @@ export class ModelQuery {
|
|
|
1132
2458
|
return this.#pushJoin("RIGHT", table, leftOrBuild, right);
|
|
1133
2459
|
}
|
|
1134
2460
|
crossJoin(table) {
|
|
1135
|
-
const tq = this.#
|
|
1136
|
-
this.#joins.push(`CROSS JOIN ${tq}
|
|
2461
|
+
const tq = this.#quoteCol(table);
|
|
2462
|
+
this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
|
|
1137
2463
|
return this;
|
|
1138
2464
|
}
|
|
1139
2465
|
/**
|
|
@@ -1152,12 +2478,12 @@ export class ModelQuery {
|
|
|
1152
2478
|
*
|
|
1153
2479
|
* @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
|
|
1154
2480
|
*/
|
|
1155
|
-
joinRaw(fragment) {
|
|
2481
|
+
joinRaw(fragment, bindings = []) {
|
|
1156
2482
|
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1157
2483
|
throw new Error("joinRaw() is disabled in Atlas strict mode. " +
|
|
1158
2484
|
"Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
|
|
1159
2485
|
}
|
|
1160
|
-
this.#joins.push(fragment);
|
|
2486
|
+
this.#joins.push({ sql: fragment, params: [...bindings] });
|
|
1161
2487
|
return this;
|
|
1162
2488
|
}
|
|
1163
2489
|
/**
|
|
@@ -1179,9 +2505,37 @@ export class ModelQuery {
|
|
|
1179
2505
|
this.#distinct = true;
|
|
1180
2506
|
return this;
|
|
1181
2507
|
}
|
|
2508
|
+
/**
|
|
2509
|
+
* `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
|
|
2510
|
+
* `columns` (Lucid/Knex `distinctOn`). Takes precedence over
|
|
2511
|
+
* {@link distinct}.
|
|
2512
|
+
*
|
|
2513
|
+
* Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
|
|
2514
|
+
* would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
|
|
2515
|
+
* return a *different* result set rather than fail — a silent wrong answer
|
|
2516
|
+
* is worse than an error.
|
|
2517
|
+
*
|
|
2518
|
+
* Postgres also requires the leading `ORDER BY` terms to match `columns`;
|
|
2519
|
+
* that is left to the database to enforce.
|
|
2520
|
+
*/
|
|
2521
|
+
distinctOn(...columns) {
|
|
2522
|
+
for (const c of columns)
|
|
2523
|
+
this.#distinctOn.push(this.#resolveColumn(c));
|
|
2524
|
+
return this;
|
|
2525
|
+
}
|
|
1182
2526
|
/** `SELECT COUNT(DISTINCT col)`. */
|
|
1183
2527
|
async countDistinct(column) {
|
|
1184
|
-
return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0);
|
|
2528
|
+
return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`)) ?? 0);
|
|
2529
|
+
}
|
|
2530
|
+
/** `SUM(DISTINCT col)` (Lucid parity). */
|
|
2531
|
+
async sumDistinct(column) {
|
|
2532
|
+
const v = await this.#runScalar(`SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
2533
|
+
return v === null || v === undefined ? null : Number(v);
|
|
2534
|
+
}
|
|
2535
|
+
/** `AVG(DISTINCT col)` (Lucid parity). */
|
|
2536
|
+
async avgDistinct(column) {
|
|
2537
|
+
const v = await this.#runScalar(`AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
2538
|
+
return v === null || v === undefined ? null : Number(v);
|
|
1185
2539
|
}
|
|
1186
2540
|
/** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
|
|
1187
2541
|
async exists() {
|
|
@@ -1189,7 +2543,7 @@ export class ModelQuery {
|
|
|
1189
2543
|
clone.#select = ["1"];
|
|
1190
2544
|
clone.#limit = 1;
|
|
1191
2545
|
const { sql, params } = clone.toSQL();
|
|
1192
|
-
const rows = await this.#db.query(sql, params);
|
|
2546
|
+
const rows = await this.#db.query(sql, params, this.#meta("exists"));
|
|
1193
2547
|
return rows.length > 0;
|
|
1194
2548
|
}
|
|
1195
2549
|
async doesntExist() {
|
|
@@ -1258,14 +2612,29 @@ export class ModelQuery {
|
|
|
1258
2612
|
// beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
|
|
1259
2613
|
// tenant scope) propagates into both the COUNT and the data fetch.
|
|
1260
2614
|
await fireHooks(this.#entityClass, "beforePaginate", this);
|
|
1261
|
-
//
|
|
2615
|
+
// COUNT(*) + data fetch
|
|
1262
2616
|
const countQ = this.clone();
|
|
1263
|
-
countQ.#select = ["COUNT(*) AS count"];
|
|
1264
2617
|
countQ.#limit = undefined;
|
|
1265
2618
|
countQ.#offset = undefined;
|
|
1266
2619
|
countQ.#orderBys = [];
|
|
1267
|
-
|
|
1268
|
-
|
|
2620
|
+
let cSql;
|
|
2621
|
+
let cParams;
|
|
2622
|
+
if (countQ.#groupBy.length > 0) {
|
|
2623
|
+
// A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
|
|
2624
|
+
// group's own size), so `rows[0].count` would be the first group's size, not
|
|
2625
|
+
// the number of pages. Lucid counts via a subquery: wrap the grouped query
|
|
2626
|
+
// (select + groupBy + having preserved) and count its rows = group count.
|
|
2627
|
+
const inner = countQ.toSQL();
|
|
2628
|
+
cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
|
|
2629
|
+
cParams = inner.params;
|
|
2630
|
+
}
|
|
2631
|
+
else {
|
|
2632
|
+
countQ.#select = ["COUNT(*) AS count"];
|
|
2633
|
+
const flat = countQ.toSQL();
|
|
2634
|
+
cSql = flat.sql;
|
|
2635
|
+
cParams = flat.params;
|
|
2636
|
+
}
|
|
2637
|
+
const cRows = await this.#db.query(cSql, cParams, this.#meta("paginate"));
|
|
1269
2638
|
const total = Number(cRows[0]?.count ?? 0);
|
|
1270
2639
|
const dataQ = this.clone();
|
|
1271
2640
|
dataQ.#limit = pp;
|
|
@@ -1274,7 +2643,10 @@ export class ModelQuery {
|
|
|
1274
2643
|
// top of the paginate hooks — paginate is its own terminal.
|
|
1275
2644
|
const items = await dataQ.#doExec();
|
|
1276
2645
|
await fireHooks(this.#entityClass, "afterPaginate", items);
|
|
1277
|
-
|
|
2646
|
+
const metaKeys = this.#entityClass
|
|
2647
|
+
? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
|
|
2648
|
+
: undefined;
|
|
2649
|
+
return new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
|
|
1278
2650
|
}
|
|
1279
2651
|
/**
|
|
1280
2652
|
* Cursor-based pagination — base64 opaque keyset, multi-column aware.
|
|
@@ -1290,7 +2662,13 @@ export class ModelQuery {
|
|
|
1290
2662
|
* accepts row-value comparisons.
|
|
1291
2663
|
*/
|
|
1292
2664
|
async cursorPaginate(opts) {
|
|
1293
|
-
|
|
2665
|
+
// Keep BOTH forms: `props` (model property names) to read the cursor value
|
|
2666
|
+
// off the hydrated entity, and `cols` (resolved DB columns) for the SQL
|
|
2667
|
+
// ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
|
|
2668
|
+
// encode `undefined` into the cursor (entity exposes the property, not the
|
|
2669
|
+
// DB column) — an unstable / stuck cursor.
|
|
2670
|
+
const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
|
|
2671
|
+
const cols = props.map((c) => this.#resolveColumn(c));
|
|
1294
2672
|
if (cols.length === 0)
|
|
1295
2673
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
1296
2674
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
@@ -1334,7 +2712,7 @@ export class ModelQuery {
|
|
|
1334
2712
|
const items = hasMore ? rows.slice(0, lim) : rows;
|
|
1335
2713
|
const last = items[items.length - 1];
|
|
1336
2714
|
const nextCursor = hasMore && last
|
|
1337
|
-
? Buffer.from(JSON.stringify({ v:
|
|
2715
|
+
? Buffer.from(JSON.stringify({ v: props.map((p) => last[p]) })).toString("base64")
|
|
1338
2716
|
: null;
|
|
1339
2717
|
return { items, nextCursor, hasMore };
|
|
1340
2718
|
}
|
|
@@ -1351,6 +2729,21 @@ export class ModelQuery {
|
|
|
1351
2729
|
this.#debugFlag = flag;
|
|
1352
2730
|
return this;
|
|
1353
2731
|
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Context attached to each statement this query runs, so a `db:query`
|
|
2734
|
+
* listener can say which model and which call produced it — and so
|
|
2735
|
+
* {@link debug} can force emission for this query alone.
|
|
2736
|
+
*
|
|
2737
|
+
* Note the connection's own `debug: true` emits every statement regardless;
|
|
2738
|
+
* `meta` only enriches the event and opens the per-query override.
|
|
2739
|
+
*/
|
|
2740
|
+
#meta(method) {
|
|
2741
|
+
return {
|
|
2742
|
+
model: this.#entityClass.name,
|
|
2743
|
+
method,
|
|
2744
|
+
debug: this.#debugFlag,
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
1354
2747
|
/** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
|
|
1355
2748
|
toQuery() {
|
|
1356
2749
|
const { sql, params } = this.toSQL();
|
|
@@ -1362,7 +2755,7 @@ export class ModelQuery {
|
|
|
1362
2755
|
}
|
|
1363
2756
|
/** Deep clone of this query — mutations on the clone never affect the original. */
|
|
1364
2757
|
clone() {
|
|
1365
|
-
const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect);
|
|
2758
|
+
const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
|
|
1366
2759
|
c.#softScope = this.#softScope;
|
|
1367
2760
|
c.#wheres = structuredCloneSafe(this.#wheres);
|
|
1368
2761
|
c.#orderBys = [...this.#orderBys];
|
|
@@ -1371,9 +2764,29 @@ export class ModelQuery {
|
|
|
1371
2764
|
c.#offset = this.#offset;
|
|
1372
2765
|
c.#preloads = new Map(this.#preloads);
|
|
1373
2766
|
c.#selectSubqueries = structuredClone(this.#selectSubqueries);
|
|
1374
|
-
c.#joins =
|
|
2767
|
+
c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
|
|
1375
2768
|
c.#lockMode = this.#lockMode;
|
|
2769
|
+
c.#lockModifier = this.#lockModifier;
|
|
2770
|
+
c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
|
|
1376
2771
|
c.#distinct = this.#distinct;
|
|
2772
|
+
c.#distinctOn = [...this.#distinctOn];
|
|
2773
|
+
c.#groupBy = [...this.#groupBy];
|
|
2774
|
+
c.#having = structuredCloneSafe(this.#having);
|
|
2775
|
+
c.#ctes = this.#ctes.map((e) => ({
|
|
2776
|
+
name: e.name,
|
|
2777
|
+
query: e.query.clone(),
|
|
2778
|
+
recursive: e.recursive,
|
|
2779
|
+
materialized: e.materialized,
|
|
2780
|
+
}));
|
|
2781
|
+
c.#unions = this.#unions.map((u) => ({
|
|
2782
|
+
query: u.query.clone(),
|
|
2783
|
+
all: u.all,
|
|
2784
|
+
op: u.op,
|
|
2785
|
+
}));
|
|
2786
|
+
c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
|
|
2787
|
+
// Pure closure over pivot metadata — safe to share by reference; it reads the
|
|
2788
|
+
// clone's own #pivotWheres at build time (passed in), holding no query state.
|
|
2789
|
+
c.#pivotExists = this.#pivotExists;
|
|
1377
2790
|
c.#debugFlag = this.#debugFlag;
|
|
1378
2791
|
return c;
|
|
1379
2792
|
}
|
|
@@ -1383,13 +2796,15 @@ export class ModelQuery {
|
|
|
1383
2796
|
if (!patch || Object.keys(patch).length === 0) {
|
|
1384
2797
|
throw new Error("update() requires a non-empty payload");
|
|
1385
2798
|
}
|
|
1386
|
-
|
|
2799
|
+
// Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
|
|
2800
|
+
// like BaseRepository's write paths — the fluent update() must not bypass it.
|
|
2801
|
+
const setPairs = Object.entries(patch).map(([k, v]) => [this.#resolveColumn(k), this.#prepareValue(k, v)]);
|
|
1387
2802
|
const spec = {
|
|
1388
2803
|
kind: "update",
|
|
1389
2804
|
table: this.#tableName,
|
|
1390
2805
|
set: setPairs,
|
|
1391
2806
|
wheres: this.#wheresForDml(),
|
|
1392
|
-
returning: returning ?? [],
|
|
2807
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
1393
2808
|
};
|
|
1394
2809
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
1395
2810
|
if (returning && returning.length > 0) {
|
|
@@ -1398,20 +2813,60 @@ export class ModelQuery {
|
|
|
1398
2813
|
const r = await this.#db.execute(compiled.statements[0], compiled.params);
|
|
1399
2814
|
return r.rowsAffected ?? 0;
|
|
1400
2815
|
}
|
|
1401
|
-
/**
|
|
2816
|
+
/**
|
|
2817
|
+
* Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
|
|
2818
|
+
* scoped rows (stamps `deleted_at`) — consistent with the entity-level
|
|
2819
|
+
* `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
|
|
2820
|
+
* model it issues a hard `DELETE`. Returns affected rows (or rows when
|
|
2821
|
+
* `returning` is set).
|
|
2822
|
+
*/
|
|
1402
2823
|
async delete(returning) {
|
|
2824
|
+
if (this.#softDeletes) {
|
|
2825
|
+
const spec = {
|
|
2826
|
+
kind: "update",
|
|
2827
|
+
table: this.#tableName,
|
|
2828
|
+
set: [[this.#deletedAtColumn(), new Date().toISOString()]],
|
|
2829
|
+
wheres: this.#wheresForDml(),
|
|
2830
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
2831
|
+
};
|
|
2832
|
+
return this.#runDml(spec, returning);
|
|
2833
|
+
}
|
|
2834
|
+
return this.forceDelete(returning);
|
|
2835
|
+
}
|
|
2836
|
+
/** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
|
|
2837
|
+
async forceDelete(returning) {
|
|
1403
2838
|
const spec = {
|
|
1404
2839
|
kind: "delete",
|
|
1405
2840
|
table: this.#tableName,
|
|
1406
2841
|
wheres: this.#wheresForDml(),
|
|
1407
|
-
returning: returning ?? [],
|
|
2842
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
1408
2843
|
};
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2844
|
+
return this.#runDml(spec, returning);
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* Bulk restore: clear `deleted_at` on the trashed rows matching the user's
|
|
2848
|
+
* predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
|
|
2849
|
+
* on a non-soft-delete model. Independent of the current soft-scope — it always
|
|
2850
|
+
* targets trashed rows (`deleted_at IS NOT NULL`).
|
|
2851
|
+
*/
|
|
2852
|
+
async restore(returning) {
|
|
2853
|
+
if (!this.#softDeletes)
|
|
2854
|
+
return 0;
|
|
2855
|
+
const wheres = this.#userWheresForDml();
|
|
2856
|
+
wheres.push({
|
|
2857
|
+
column: this.#deletedAtColumn(),
|
|
2858
|
+
operator: "IS NOT NULL",
|
|
2859
|
+
value: null,
|
|
2860
|
+
type: "and",
|
|
2861
|
+
});
|
|
2862
|
+
const spec = {
|
|
2863
|
+
kind: "update",
|
|
2864
|
+
table: this.#tableName,
|
|
2865
|
+
set: [[this.#deletedAtColumn(), null]],
|
|
2866
|
+
wheres,
|
|
2867
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
2868
|
+
};
|
|
2869
|
+
return this.#runDml(spec, returning);
|
|
1415
2870
|
}
|
|
1416
2871
|
increment(colOrPatch, amount = 1) {
|
|
1417
2872
|
return this.#runIncDec("increment", colOrPatch, amount);
|
|
@@ -1438,20 +2893,73 @@ export class ModelQuery {
|
|
|
1438
2893
|
}
|
|
1439
2894
|
return this;
|
|
1440
2895
|
}
|
|
2896
|
+
/** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
|
|
2897
|
+
forNoKeyUpdate() {
|
|
2898
|
+
if (this.#dialect === "postgres") {
|
|
2899
|
+
this.#lockMode = "FOR NO KEY UPDATE";
|
|
2900
|
+
}
|
|
2901
|
+
else {
|
|
2902
|
+
console.warn(`[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`);
|
|
2903
|
+
}
|
|
2904
|
+
return this;
|
|
2905
|
+
}
|
|
2906
|
+
/** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
|
|
2907
|
+
forKeyShare() {
|
|
2908
|
+
if (this.#dialect === "postgres") {
|
|
2909
|
+
this.#lockMode = "FOR KEY SHARE";
|
|
2910
|
+
}
|
|
2911
|
+
else {
|
|
2912
|
+
console.warn(`[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`);
|
|
2913
|
+
}
|
|
2914
|
+
return this;
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
|
|
2918
|
+
* waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
2919
|
+
*/
|
|
2920
|
+
skipLocked() {
|
|
2921
|
+
if (this.#dialect === "sqlite") {
|
|
2922
|
+
console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
|
|
2923
|
+
}
|
|
2924
|
+
else {
|
|
2925
|
+
this.#lockModifier = "SKIP LOCKED";
|
|
2926
|
+
}
|
|
2927
|
+
return this;
|
|
2928
|
+
}
|
|
2929
|
+
/**
|
|
2930
|
+
* Append `NOWAIT` to the lock clause — error immediately instead of waiting on
|
|
2931
|
+
* a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
2932
|
+
*/
|
|
2933
|
+
noWait() {
|
|
2934
|
+
if (this.#dialect === "sqlite") {
|
|
2935
|
+
console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
|
|
2936
|
+
}
|
|
2937
|
+
else {
|
|
2938
|
+
this.#lockModifier = "NOWAIT";
|
|
2939
|
+
}
|
|
2940
|
+
return this;
|
|
2941
|
+
}
|
|
1441
2942
|
// === Private helpers ==============================================================================
|
|
1442
2943
|
#quote(name) {
|
|
1443
2944
|
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
1444
2945
|
}
|
|
1445
2946
|
/** Quote a `table.column` reference on both sides of the dot. */
|
|
1446
2947
|
#quoteCol(ref) {
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
2948
|
+
// Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
|
|
2949
|
+
// identifier smuggling a `"`/backtick would break out of the quoting on the
|
|
2950
|
+
// join path (which the Rust screen doesn't re-validate). Strict
|
|
2951
|
+
// `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
|
|
2952
|
+
// helpers injection-safe. Use joinRaw() for anything more complex.
|
|
2953
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
|
|
2954
|
+
throw new Error(`Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`);
|
|
1450
2955
|
}
|
|
1451
|
-
return
|
|
2956
|
+
return ref
|
|
2957
|
+
.split(".")
|
|
2958
|
+
.map((seg) => this.#quote(seg))
|
|
2959
|
+
.join(".");
|
|
1452
2960
|
}
|
|
1453
2961
|
#pushJoin(kind, table, leftOrBuild, right) {
|
|
1454
|
-
const tq = this.#
|
|
2962
|
+
const tq = this.#quoteCol(table);
|
|
1455
2963
|
if (typeof leftOrBuild === "function") {
|
|
1456
2964
|
const jb = {
|
|
1457
2965
|
parts: [],
|
|
@@ -1463,24 +2971,56 @@ export class ModelQuery {
|
|
|
1463
2971
|
this.parts.push({ kind: "and", left: l, right: r });
|
|
1464
2972
|
return this;
|
|
1465
2973
|
},
|
|
1466
|
-
|
|
1467
|
-
this.parts.push({ kind: "
|
|
2974
|
+
orOn(l, r) {
|
|
2975
|
+
this.parts.push({ kind: "or", left: l, right: r });
|
|
2976
|
+
return this;
|
|
2977
|
+
},
|
|
2978
|
+
onVal(l, v) {
|
|
2979
|
+
this.parts.push({ kind: "and", left: l, value: { v } });
|
|
2980
|
+
return this;
|
|
2981
|
+
},
|
|
2982
|
+
andOnVal(l, v) {
|
|
2983
|
+
this.parts.push({ kind: "and", left: l, value: { v } });
|
|
2984
|
+
return this;
|
|
2985
|
+
},
|
|
2986
|
+
orOnVal(l, v) {
|
|
2987
|
+
this.parts.push({ kind: "or", left: l, value: { v } });
|
|
1468
2988
|
return this;
|
|
1469
2989
|
},
|
|
1470
2990
|
};
|
|
1471
2991
|
leftOrBuild(jb);
|
|
2992
|
+
// Collect the bound values in placeholder order as the fragment is built.
|
|
2993
|
+
const params = [];
|
|
1472
2994
|
const on = jb.parts
|
|
1473
2995
|
.map((p, i) => {
|
|
1474
2996
|
const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
|
|
1475
|
-
|
|
2997
|
+
if (p.value) {
|
|
2998
|
+
// A BASE-table column runs the full model prepare (DateTime→ISO +
|
|
2999
|
+
// @Column adapters/casts), keyed by its property. A FOREIGN join
|
|
3000
|
+
// column must NOT borrow the root model's adapter for a same-named
|
|
3001
|
+
// column on another table — apply only universal type-lowering
|
|
3002
|
+
// (Date/DateTime→ISO), matching Knex's model-agnostic join binding.
|
|
3003
|
+
const dot = p.left.lastIndexOf(".");
|
|
3004
|
+
const tablePrefix = dot >= 0 ? p.left.slice(0, dot) : "";
|
|
3005
|
+
const leaf = dot >= 0 ? p.left.slice(dot + 1) : p.left;
|
|
3006
|
+
const isBaseColumn = tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
|
|
3007
|
+
params.push(isBaseColumn
|
|
3008
|
+
? this.#prepareValue(leaf, p.value.v)
|
|
3009
|
+
: lowerJoinValue(p.value.v));
|
|
3010
|
+
return `${prefix} ${this.#quoteCol(p.left)} = ?`;
|
|
3011
|
+
}
|
|
3012
|
+
return `${prefix} ${this.#quoteCol(p.left)} = ${this.#quoteCol(p.right ?? "")}`;
|
|
1476
3013
|
})
|
|
1477
3014
|
.join(" ");
|
|
1478
|
-
this.#joins.push(`${kind} JOIN ${tq} ${on}
|
|
3015
|
+
this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
|
|
1479
3016
|
return this;
|
|
1480
3017
|
}
|
|
1481
3018
|
if (right === undefined)
|
|
1482
3019
|
throw new Error("join() with string form requires both left and right operands");
|
|
1483
|
-
this.#joins.push(
|
|
3020
|
+
this.#joins.push({
|
|
3021
|
+
sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
|
|
3022
|
+
params: [],
|
|
3023
|
+
});
|
|
1484
3024
|
return this;
|
|
1485
3025
|
}
|
|
1486
3026
|
async #runScalar(expr) {
|
|
@@ -1512,7 +3052,8 @@ export class ModelQuery {
|
|
|
1512
3052
|
* still rejected because the DML compiler's WHERE lowering does not yet
|
|
1513
3053
|
* handle nested sub-queries or correlated EXISTS.
|
|
1514
3054
|
*/
|
|
1515
|
-
|
|
3055
|
+
/** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
|
|
3056
|
+
#userWheresForDml() {
|
|
1516
3057
|
const out = [];
|
|
1517
3058
|
for (const w of this.#wheres) {
|
|
1518
3059
|
if ("kind" in w) {
|
|
@@ -1537,6 +3078,43 @@ export class ModelQuery {
|
|
|
1537
3078
|
}
|
|
1538
3079
|
return out;
|
|
1539
3080
|
}
|
|
3081
|
+
#wheresForDml() {
|
|
3082
|
+
const out = this.#userWheresForDml();
|
|
3083
|
+
// Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
|
|
3084
|
+
// update/delete/increment/decrement must NOT touch trashed rows under the
|
|
3085
|
+
// default scope — otherwise `query().where(x)` would denote a different row
|
|
3086
|
+
// set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
|
|
3087
|
+
// `.onlyTrashed()` restricts to trashed (mirrors reads).
|
|
3088
|
+
if (this.#softDeletes) {
|
|
3089
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
3090
|
+
if (this.#softScope === "default") {
|
|
3091
|
+
out.push({
|
|
3092
|
+
column: deletedAtCol,
|
|
3093
|
+
operator: "IS NULL",
|
|
3094
|
+
value: null,
|
|
3095
|
+
type: "and",
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
else if (this.#softScope === "only-trashed") {
|
|
3099
|
+
out.push({
|
|
3100
|
+
column: deletedAtCol,
|
|
3101
|
+
operator: "IS NOT NULL",
|
|
3102
|
+
value: null,
|
|
3103
|
+
type: "and",
|
|
3104
|
+
});
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
return out;
|
|
3108
|
+
}
|
|
3109
|
+
/** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
|
|
3110
|
+
async #runDml(spec, returning) {
|
|
3111
|
+
const compiled = compileStatementNative(spec, this.#dialect);
|
|
3112
|
+
if (returning && returning.length > 0) {
|
|
3113
|
+
return this.#db.query(compiled.statements[0], compiled.params);
|
|
3114
|
+
}
|
|
3115
|
+
const r = await this.#db.execute(compiled.statements[0], compiled.params);
|
|
3116
|
+
return r.rowsAffected ?? 0;
|
|
3117
|
+
}
|
|
1540
3118
|
/**
|
|
1541
3119
|
* !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
|
|
1542
3120
|
*
|
|
@@ -1569,7 +3147,7 @@ export class ModelQuery {
|
|
|
1569
3147
|
* groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
|
|
1570
3148
|
*/
|
|
1571
3149
|
#buildGroup(type, callback) {
|
|
1572
|
-
const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect);
|
|
3150
|
+
const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect, this.#prepareValue);
|
|
1573
3151
|
callback(scratch);
|
|
1574
3152
|
return { type, kind: "group", conditions: scratch.#wheres };
|
|
1575
3153
|
}
|
|
@@ -1609,7 +3187,7 @@ export class ModelQuery {
|
|
|
1609
3187
|
type,
|
|
1610
3188
|
column: resolved,
|
|
1611
3189
|
operator: "=",
|
|
1612
|
-
value: operatorOrValue,
|
|
3190
|
+
value: this.#prep(column, operatorOrValue),
|
|
1613
3191
|
});
|
|
1614
3192
|
}
|
|
1615
3193
|
else {
|
|
@@ -1617,11 +3195,21 @@ export class ModelQuery {
|
|
|
1617
3195
|
type,
|
|
1618
3196
|
column: resolved,
|
|
1619
3197
|
operator: operatorOrValue,
|
|
1620
|
-
value,
|
|
3198
|
+
value: this.#prep(column, value),
|
|
1621
3199
|
});
|
|
1622
3200
|
}
|
|
1623
3201
|
return this;
|
|
1624
3202
|
}
|
|
3203
|
+
/**
|
|
3204
|
+
* Lower a WHERE/search value (or each element of an array) to its DB form via
|
|
3205
|
+
* the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
|
|
3206
|
+
* adapter column used as a predicate binds the same shape the write path stores.
|
|
3207
|
+
*/
|
|
3208
|
+
#prep(column, value) {
|
|
3209
|
+
return Array.isArray(value)
|
|
3210
|
+
? value.map((v) => this.#prepareValue(column, v))
|
|
3211
|
+
: this.#prepareValue(column, value);
|
|
3212
|
+
}
|
|
1625
3213
|
/**
|
|
1626
3214
|
* Resolve this ModelQuery's preloads against a pre-loaded set of entities.
|
|
1627
3215
|
* Used by the nested-preload machinery to recurse without re-running the root select.
|