@c9up/atlas 0.1.19 → 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 +164 -5
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -33
- 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 +71 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1355 -384
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +341 -18
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +1406 -177
- 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/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/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 +3 -1
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +8 -1
- package/src/BaseEntity.ts +431 -41
- package/src/BaseModel.ts +324 -0
- package/src/BaseRepository.ts +1589 -419
- package/src/ModelQuery.ts +1817 -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/TableBuilder.ts +735 -41
- package/src/schema/catalog.ts +166 -0
- 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,38 @@ 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
|
+
}
|
|
131
297
|
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
132
298
|
get hasPages() {
|
|
133
299
|
return this.meta.lastPage > 1;
|
|
@@ -145,7 +311,9 @@ export class Paginator {
|
|
|
145
311
|
picked[f] = item[f];
|
|
146
312
|
return picked;
|
|
147
313
|
});
|
|
148
|
-
|
|
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() };
|
|
149
317
|
}
|
|
150
318
|
baseUrl(url) {
|
|
151
319
|
this.#baseUrl = url;
|
|
@@ -155,26 +323,70 @@ export class Paginator {
|
|
|
155
323
|
this.#queryString = qs;
|
|
156
324
|
return this;
|
|
157
325
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 };
|
|
162
370
|
if (this.#baseUrl) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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;
|
|
379
|
+
}
|
|
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() };
|
|
178
390
|
}
|
|
179
391
|
}
|
|
180
392
|
/** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
|
|
@@ -201,12 +413,17 @@ export class ModelQuery {
|
|
|
201
413
|
#subqueryAlias;
|
|
202
414
|
/** Raw JOIN fragments — Story 29.4. */
|
|
203
415
|
#joins = [];
|
|
204
|
-
/** Row lock mode — Story 30.8. */
|
|
416
|
+
/** Row lock base mode — Story 30.8. */
|
|
205
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;
|
|
206
422
|
/** Per-query debug flag — Story 29.11. */
|
|
207
423
|
#debugFlag = false;
|
|
208
424
|
/** Distinct flag — Story 29.5. */
|
|
209
425
|
#distinct = false;
|
|
426
|
+
#distinctOn = [];
|
|
210
427
|
/** GROUP BY columns (Lucid parity). */
|
|
211
428
|
#groupBy = [];
|
|
212
429
|
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
@@ -217,9 +434,17 @@ export class ModelQuery {
|
|
|
217
434
|
#unions = [];
|
|
218
435
|
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
219
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;
|
|
220
445
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
221
446
|
#dialect;
|
|
222
|
-
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) {
|
|
223
448
|
this.#tableName = tableName;
|
|
224
449
|
this.#db = db;
|
|
225
450
|
this.#hydrateFn = hydrateFn;
|
|
@@ -227,7 +452,13 @@ export class ModelQuery {
|
|
|
227
452
|
this.#resolveColumn = resolveColumn;
|
|
228
453
|
this.#softDeletes = softDeletes;
|
|
229
454
|
this.#dialect = dialect;
|
|
455
|
+
this.#prepareValue = prepareValue;
|
|
456
|
+
this.#onDomainEvents = onDomainEvents;
|
|
230
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;
|
|
231
462
|
/** Include soft-deleted rows in the result (default behavior excludes them). */
|
|
232
463
|
withTrashed() {
|
|
233
464
|
this.#softScope = "with-trashed";
|
|
@@ -252,11 +483,29 @@ export class ModelQuery {
|
|
|
252
483
|
}
|
|
253
484
|
/** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
|
|
254
485
|
select(columns) {
|
|
255
|
-
|
|
486
|
+
const list = Array.isArray(columns)
|
|
256
487
|
? columns
|
|
257
488
|
: columns.split(",").map((c) => c.trim());
|
|
489
|
+
this.#select = list.map((c) => this.#resolveSelect(c));
|
|
258
490
|
return this;
|
|
259
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
|
+
}
|
|
260
509
|
where(columnOrCb, operatorOrValue, value) {
|
|
261
510
|
if (typeof columnOrCb === "function") {
|
|
262
511
|
this.#wheres.push(this.#buildGroup("and", columnOrCb));
|
|
@@ -289,13 +538,63 @@ export class ModelQuery {
|
|
|
289
538
|
});
|
|
290
539
|
return this;
|
|
291
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
|
+
}
|
|
292
591
|
/** `WHERE col != ?` — negation of `where`. */
|
|
293
592
|
whereNot(column, value) {
|
|
294
593
|
this.#wheres.push({
|
|
295
594
|
type: "and",
|
|
296
595
|
column: this.#resolveColumn(column),
|
|
297
596
|
operator: "!=",
|
|
298
|
-
value,
|
|
597
|
+
value: this.#prep(column, value),
|
|
299
598
|
});
|
|
300
599
|
return this;
|
|
301
600
|
}
|
|
@@ -315,7 +614,7 @@ export class ModelQuery {
|
|
|
315
614
|
type: "and",
|
|
316
615
|
column: this.#resolveColumn(column),
|
|
317
616
|
operator: "IN",
|
|
318
|
-
value: [...source],
|
|
617
|
+
value: this.#prep(column, [...source]),
|
|
319
618
|
});
|
|
320
619
|
return this;
|
|
321
620
|
}
|
|
@@ -335,7 +634,7 @@ export class ModelQuery {
|
|
|
335
634
|
type: "and",
|
|
336
635
|
column: this.#resolveColumn(column),
|
|
337
636
|
operator: "NOT IN",
|
|
338
|
-
value: [...source],
|
|
637
|
+
value: this.#prep(column, [...source]),
|
|
339
638
|
});
|
|
340
639
|
return this;
|
|
341
640
|
}
|
|
@@ -345,7 +644,7 @@ export class ModelQuery {
|
|
|
345
644
|
type: "and",
|
|
346
645
|
column: this.#resolveColumn(column),
|
|
347
646
|
operator: "BETWEEN",
|
|
348
|
-
value: [...range],
|
|
647
|
+
value: this.#prep(column, [...range]),
|
|
349
648
|
});
|
|
350
649
|
return this;
|
|
351
650
|
}
|
|
@@ -355,7 +654,7 @@ export class ModelQuery {
|
|
|
355
654
|
type: "and",
|
|
356
655
|
column: this.#resolveColumn(column),
|
|
357
656
|
operator: "NOT BETWEEN",
|
|
358
|
-
value: [...range],
|
|
657
|
+
value: this.#prep(column, [...range]),
|
|
359
658
|
});
|
|
360
659
|
return this;
|
|
361
660
|
}
|
|
@@ -412,7 +711,7 @@ export class ModelQuery {
|
|
|
412
711
|
type: "or",
|
|
413
712
|
column: this.#resolveColumn(column),
|
|
414
713
|
operator: "!=",
|
|
415
|
-
value,
|
|
714
|
+
value: this.#prep(column, value),
|
|
416
715
|
});
|
|
417
716
|
return this;
|
|
418
717
|
}
|
|
@@ -432,7 +731,7 @@ export class ModelQuery {
|
|
|
432
731
|
type: "or",
|
|
433
732
|
column: this.#resolveColumn(column),
|
|
434
733
|
operator: "IN",
|
|
435
|
-
value: [...source],
|
|
734
|
+
value: this.#prep(column, [...source]),
|
|
436
735
|
});
|
|
437
736
|
return this;
|
|
438
737
|
}
|
|
@@ -452,7 +751,7 @@ export class ModelQuery {
|
|
|
452
751
|
type: "or",
|
|
453
752
|
column: this.#resolveColumn(column),
|
|
454
753
|
operator: "NOT IN",
|
|
455
|
-
value: [...source],
|
|
754
|
+
value: this.#prep(column, [...source]),
|
|
456
755
|
});
|
|
457
756
|
return this;
|
|
458
757
|
}
|
|
@@ -462,7 +761,7 @@ export class ModelQuery {
|
|
|
462
761
|
type: "or",
|
|
463
762
|
column: this.#resolveColumn(column),
|
|
464
763
|
operator: "BETWEEN",
|
|
465
|
-
value: [...range],
|
|
764
|
+
value: this.#prep(column, [...range]),
|
|
466
765
|
});
|
|
467
766
|
return this;
|
|
468
767
|
}
|
|
@@ -472,7 +771,7 @@ export class ModelQuery {
|
|
|
472
771
|
type: "or",
|
|
473
772
|
column: this.#resolveColumn(column),
|
|
474
773
|
operator: "NOT BETWEEN",
|
|
475
|
-
value: [...range],
|
|
774
|
+
value: this.#prep(column, [...range]),
|
|
476
775
|
});
|
|
477
776
|
return this;
|
|
478
777
|
}
|
|
@@ -535,15 +834,47 @@ export class ModelQuery {
|
|
|
535
834
|
* Not exported from the package barrel — only accessible inside the Atlas
|
|
536
835
|
* codebase via direct ModelQuery instance access.
|
|
537
836
|
*/
|
|
538
|
-
#pushWhereRaw(sql, bindings = []) {
|
|
837
|
+
#pushWhereRaw(sql, bindings = [], type = "and") {
|
|
539
838
|
this.#wheres.push({
|
|
540
|
-
type
|
|
839
|
+
type,
|
|
541
840
|
kind: "raw",
|
|
542
841
|
sql,
|
|
543
842
|
bindings: [...bindings],
|
|
544
843
|
});
|
|
545
844
|
return this;
|
|
546
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
|
+
}
|
|
547
878
|
whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
|
|
548
879
|
// 3-arg form: whereExpr(col, op, value)
|
|
549
880
|
// 4-arg form: whereExpr(col, extraExpr, op, value)
|
|
@@ -562,6 +893,18 @@ export class ModelQuery {
|
|
|
562
893
|
if (!hasBalancedParens(extra)) {
|
|
563
894
|
throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
|
|
564
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
|
+
}
|
|
565
908
|
// `op` is interpolated raw into the fragment below, so it MUST be
|
|
566
909
|
// allow-listed — the 3-arg path gets this from the Rust operator
|
|
567
910
|
// validation, but the raw 4-arg path bypasses Rust and would
|
|
@@ -578,9 +921,16 @@ export class ModelQuery {
|
|
|
578
921
|
// the operator against the allow-list above.
|
|
579
922
|
if (hasExtra) {
|
|
580
923
|
const q = this.#quote(resolved);
|
|
581
|
-
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
924
|
+
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
925
|
+
this.#prep(column, value),
|
|
926
|
+
]);
|
|
582
927
|
}
|
|
583
|
-
this.#wheres.push({
|
|
928
|
+
this.#wheres.push({
|
|
929
|
+
type: "and",
|
|
930
|
+
column: resolved,
|
|
931
|
+
operator: op,
|
|
932
|
+
value: this.#prep(column, value),
|
|
933
|
+
});
|
|
584
934
|
return this;
|
|
585
935
|
}
|
|
586
936
|
/**
|
|
@@ -589,6 +939,153 @@ export class ModelQuery {
|
|
|
589
939
|
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
590
940
|
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
591
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
|
+
}
|
|
592
1089
|
whereColumn(left, operator, right) {
|
|
593
1090
|
return this.#whereColumn("and", left, operator, right);
|
|
594
1091
|
}
|
|
@@ -596,7 +1093,19 @@ export class ModelQuery {
|
|
|
596
1093
|
orWhereColumn(left, operator, right) {
|
|
597
1094
|
return this.#whereColumn("or", left, operator, right);
|
|
598
1095
|
}
|
|
599
|
-
|
|
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) {
|
|
600
1109
|
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
601
1110
|
throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
|
|
602
1111
|
}
|
|
@@ -606,7 +1115,7 @@ export class ModelQuery {
|
|
|
606
1115
|
// `[table.]column` charset. This closes the injection surface regardless of
|
|
607
1116
|
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
608
1117
|
const safe = (name) => {
|
|
609
|
-
const resolved = this.#
|
|
1118
|
+
const resolved = this.#resolveColumnReference(name);
|
|
610
1119
|
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
|
|
611
1120
|
throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
|
|
612
1121
|
}
|
|
@@ -617,10 +1126,45 @@ export class ModelQuery {
|
|
|
617
1126
|
.map((part) => this.#quote(part))
|
|
618
1127
|
.join(".");
|
|
619
1128
|
};
|
|
620
|
-
const
|
|
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;
|
|
621
1133
|
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
622
1134
|
return this;
|
|
623
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
|
+
}
|
|
624
1168
|
/**
|
|
625
1169
|
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
626
1170
|
* by the existence of related rows, optionally constrained by a callback.
|
|
@@ -645,6 +1189,14 @@ export class ModelQuery {
|
|
|
645
1189
|
this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
|
|
646
1190
|
return this;
|
|
647
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
|
+
}
|
|
648
1200
|
/**
|
|
649
1201
|
* Short form of `whereHas`. With an operator + count, emits a count threshold:
|
|
650
1202
|
* has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
|
|
@@ -663,6 +1215,19 @@ export class ModelQuery {
|
|
|
663
1215
|
this.#wheres.push(this.#buildExistsClause("and", true, relationName));
|
|
664
1216
|
return this;
|
|
665
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
|
+
}
|
|
666
1231
|
/**
|
|
667
1232
|
* Set this query's projection alias — only meaningful when this ModelQuery
|
|
668
1233
|
* is used as the sub-builder callback argument of `withCount` / `withAggregate`.
|
|
@@ -698,23 +1263,25 @@ export class ModelQuery {
|
|
|
698
1263
|
// --- Top-level scalar executors (Story 29.5) ---
|
|
699
1264
|
/** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
|
|
700
1265
|
async count(column = "*") {
|
|
701
|
-
const expr = column === "*"
|
|
1266
|
+
const expr = column === "*"
|
|
1267
|
+
? "COUNT(*)"
|
|
1268
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
|
|
702
1269
|
return Number((await this.#runScalar(expr)) ?? 0);
|
|
703
1270
|
}
|
|
704
1271
|
async sum(column) {
|
|
705
|
-
const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
|
|
1272
|
+
const v = await this.#runScalar(`SUM(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
706
1273
|
return v === null || v === undefined ? null : Number(v);
|
|
707
1274
|
}
|
|
708
1275
|
async avg(column) {
|
|
709
|
-
const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
|
|
1276
|
+
const v = await this.#runScalar(`AVG(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
710
1277
|
return v === null || v === undefined ? null : Number(v);
|
|
711
1278
|
}
|
|
712
1279
|
async min(column) {
|
|
713
|
-
const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
|
|
1280
|
+
const v = await this.#runScalar(`MIN(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
714
1281
|
return v === null || v === undefined ? null : Number(v);
|
|
715
1282
|
}
|
|
716
1283
|
async max(column) {
|
|
717
|
-
const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
|
|
1284
|
+
const v = await this.#runScalar(`MAX(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
718
1285
|
return v === null || v === undefined ? null : Number(v);
|
|
719
1286
|
}
|
|
720
1287
|
/**
|
|
@@ -745,31 +1312,91 @@ export class ModelQuery {
|
|
|
745
1312
|
this.#orderBys.push({ column: this.#resolveColumn(column), direction });
|
|
746
1313
|
return this;
|
|
747
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
|
+
}
|
|
748
1334
|
/**
|
|
749
1335
|
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
750
1336
|
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
751
|
-
* For a
|
|
752
|
-
* fluent {@link QueryBuilder}.
|
|
1337
|
+
* For a grouping expression with no typed form, see {@link groupByRaw}.
|
|
753
1338
|
*/
|
|
754
1339
|
groupBy(...columns) {
|
|
755
1340
|
for (const c of columns)
|
|
756
1341
|
this.#groupBy.push(this.#resolveColumn(c));
|
|
757
1342
|
return this;
|
|
758
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
|
+
}
|
|
759
1360
|
/**
|
|
760
1361
|
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
* (`COUNT(*)`, `SUM(col)`, …)
|
|
764
|
-
*
|
|
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.
|
|
765
1366
|
*/
|
|
766
1367
|
having(column, operator, value) {
|
|
767
|
-
this.#having.push({
|
|
1368
|
+
this.#having.push({
|
|
1369
|
+
column: this.#resolveHavingCol(column),
|
|
1370
|
+
operator,
|
|
1371
|
+
value: this.#prep(column, value),
|
|
1372
|
+
type: "and",
|
|
1373
|
+
});
|
|
768
1374
|
return this;
|
|
769
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
|
+
}
|
|
770
1392
|
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
771
1393
|
orHaving(column, operator, value) {
|
|
772
|
-
this.#having.push({
|
|
1394
|
+
this.#having.push({
|
|
1395
|
+
column: this.#resolveHavingCol(column),
|
|
1396
|
+
operator,
|
|
1397
|
+
value: this.#prep(column, value),
|
|
1398
|
+
type: "or",
|
|
1399
|
+
});
|
|
773
1400
|
return this;
|
|
774
1401
|
}
|
|
775
1402
|
/**
|
|
@@ -781,6 +1408,12 @@ export class ModelQuery {
|
|
|
781
1408
|
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
782
1409
|
*/
|
|
783
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
|
+
}
|
|
784
1417
|
this.#having.push({
|
|
785
1418
|
kind: "raw",
|
|
786
1419
|
sql,
|
|
@@ -803,34 +1436,160 @@ export class ModelQuery {
|
|
|
803
1436
|
this.#unions.push({ query, all: true });
|
|
804
1437
|
return this;
|
|
805
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
|
+
}
|
|
806
1465
|
/**
|
|
807
1466
|
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
808
1467
|
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
809
1468
|
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
810
1469
|
*/
|
|
811
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) {
|
|
812
1506
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
813
|
-
throw new Error(
|
|
1507
|
+
throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
|
|
814
1508
|
}
|
|
815
|
-
this.#ctes.push({ name, query });
|
|
1509
|
+
this.#ctes.push({ name, query, ...options });
|
|
816
1510
|
return this;
|
|
817
1511
|
}
|
|
818
1512
|
wherePivot(column, operatorOrValue, value) {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
value,
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
return this;
|
|
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);
|
|
830
1520
|
}
|
|
831
|
-
/** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (Lucid `
|
|
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. */
|
|
832
1534
|
wherePivotIn(column, values) {
|
|
833
|
-
this
|
|
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 });
|
|
834
1593
|
return this;
|
|
835
1594
|
}
|
|
836
1595
|
/** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
|
|
@@ -903,14 +1662,79 @@ export class ModelQuery {
|
|
|
903
1662
|
return this.exec().then(onfulfilled, onrejected);
|
|
904
1663
|
}
|
|
905
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
|
+
}
|
|
906
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
|
+
}
|
|
907
1720
|
const wheres = [...this.#wheres];
|
|
908
|
-
//
|
|
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).
|
|
909
1732
|
if (this.#softDeletes) {
|
|
1733
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
910
1734
|
if (this.#softScope === "default") {
|
|
911
1735
|
wheres.push({
|
|
912
1736
|
type: "and",
|
|
913
|
-
column:
|
|
1737
|
+
column: deletedAtCol,
|
|
914
1738
|
operator: "IS NULL",
|
|
915
1739
|
value: null,
|
|
916
1740
|
});
|
|
@@ -918,7 +1742,7 @@ export class ModelQuery {
|
|
|
918
1742
|
else if (this.#softScope === "only-trashed") {
|
|
919
1743
|
wheres.push({
|
|
920
1744
|
type: "and",
|
|
921
|
-
column:
|
|
1745
|
+
column: deletedAtCol,
|
|
922
1746
|
operator: "IS NOT NULL",
|
|
923
1747
|
value: null,
|
|
924
1748
|
});
|
|
@@ -928,7 +1752,7 @@ export class ModelQuery {
|
|
|
928
1752
|
return {
|
|
929
1753
|
kind: "select",
|
|
930
1754
|
table: this.#tableName,
|
|
931
|
-
select:
|
|
1755
|
+
select: selectCols,
|
|
932
1756
|
selectSubqueries: this.#selectSubqueries,
|
|
933
1757
|
wheres,
|
|
934
1758
|
orderBy: this.#orderBys,
|
|
@@ -937,16 +1761,27 @@ export class ModelQuery {
|
|
|
937
1761
|
limit: this.#limit ?? null,
|
|
938
1762
|
offset: this.#offset ?? null,
|
|
939
1763
|
distinct: this.#distinct,
|
|
1764
|
+
distinctOn: this.#distinctOn,
|
|
940
1765
|
ctes: this.#ctes.map((c) => {
|
|
941
1766
|
const { sql, params } = c.query.toSQL();
|
|
942
|
-
return {
|
|
1767
|
+
return {
|
|
1768
|
+
name: c.name,
|
|
1769
|
+
sql,
|
|
1770
|
+
params,
|
|
1771
|
+
recursive: c.recursive ?? false,
|
|
1772
|
+
materialized: c.materialized ?? null,
|
|
1773
|
+
};
|
|
943
1774
|
}),
|
|
944
1775
|
unions: this.#unions.map((u) => {
|
|
945
1776
|
const { sql, params } = u.query.toSQL();
|
|
946
|
-
return { sql, params, all: u.all };
|
|
1777
|
+
return { sql, params, all: u.all, op: u.op ?? null };
|
|
947
1778
|
}),
|
|
948
1779
|
joins: this.#joins,
|
|
949
|
-
lockMode: this.#lockMode
|
|
1780
|
+
lockMode: this.#lockMode
|
|
1781
|
+
? this.#lockModifier
|
|
1782
|
+
? `${this.#lockMode} ${this.#lockModifier}`
|
|
1783
|
+
: this.#lockMode
|
|
1784
|
+
: null,
|
|
950
1785
|
};
|
|
951
1786
|
}
|
|
952
1787
|
/** Build SQL + params via the Rust query compiler. */
|
|
@@ -977,7 +1812,7 @@ export class ModelQuery {
|
|
|
977
1812
|
}
|
|
978
1813
|
async #doExec() {
|
|
979
1814
|
const { sql, params } = this.toSQL();
|
|
980
|
-
const rawRows = await this.#db.query(sql, params);
|
|
1815
|
+
const rawRows = await this.#db.query(sql, params, this.#meta("exec"));
|
|
981
1816
|
// Peel withCount / withAggregate alias columns off the raw row into $extras
|
|
982
1817
|
// BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
|
|
983
1818
|
const extraKeys = this.#selectSubqueries.map((s) => s.alias);
|
|
@@ -992,6 +1827,9 @@ export class ModelQuery {
|
|
|
992
1827
|
const entity = this.#hydrateFn(row);
|
|
993
1828
|
for (const [k, v] of Object.entries(picked))
|
|
994
1829
|
entity.setExtra(k, v);
|
|
1830
|
+
// Thread query-level sideloaded context onto each hydrated instance.
|
|
1831
|
+
if (this.#sideloaded)
|
|
1832
|
+
entity.$sideloaded = { ...this.#sideloaded };
|
|
995
1833
|
return entity;
|
|
996
1834
|
});
|
|
997
1835
|
// Resolve preloads (eager loading)
|
|
@@ -1000,6 +1838,25 @@ export class ModelQuery {
|
|
|
1000
1838
|
}
|
|
1001
1839
|
return entities;
|
|
1002
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
|
+
}
|
|
1003
1860
|
/** Resolve preloaded relations via batched subqueries (no N+1). */
|
|
1004
1861
|
async #resolvePreloads(entities) {
|
|
1005
1862
|
if (!this.#entityClass)
|
|
@@ -1019,33 +1876,84 @@ export class ModelQuery {
|
|
|
1019
1876
|
/** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
|
|
1020
1877
|
#buildPreloadContext(relation, relationName) {
|
|
1021
1878
|
const relatedClass = relation.target();
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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);
|
|
1025
1884
|
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1026
1885
|
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
1027
1886
|
// on a freshly constructed instance, so `key in entity` is always false and
|
|
1028
1887
|
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1029
1888
|
const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
|
|
1030
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);
|
|
1031
1900
|
for (const col of getColumnMetadata(relatedClass)) {
|
|
1901
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
1032
1902
|
validColumns.add(col.propertyKey);
|
|
1033
|
-
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;
|
|
1034
1911
|
}
|
|
1035
1912
|
validColumns.add(relatedPkName);
|
|
1036
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;
|
|
1037
1935
|
const hydrate = (row) => {
|
|
1038
1936
|
const entity = new relatedClass();
|
|
1039
1937
|
for (const [key, value] of Object.entries(row)) {
|
|
1040
1938
|
const camelKey = snakeToCamel(key);
|
|
1041
|
-
const targetKey =
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1939
|
+
const targetKey = byDbName.get(key) ??
|
|
1940
|
+
(validColumns.has(camelKey)
|
|
1941
|
+
? camelKey
|
|
1942
|
+
: validColumns.has(key)
|
|
1943
|
+
? key
|
|
1944
|
+
: null);
|
|
1046
1945
|
if (targetKey !== null)
|
|
1047
|
-
entity.setProp(targetKey, value);
|
|
1946
|
+
entity.setProp(targetKey, consumeValue(targetKey, value, entity));
|
|
1048
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
|
+
});
|
|
1049
1957
|
return entity;
|
|
1050
1958
|
};
|
|
1051
1959
|
return {
|
|
@@ -1053,7 +1961,9 @@ export class ModelQuery {
|
|
|
1053
1961
|
relationName,
|
|
1054
1962
|
relatedClass,
|
|
1055
1963
|
relatedTable: relatedMeta.tableName,
|
|
1056
|
-
|
|
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,
|
|
1057
1967
|
hydrate,
|
|
1058
1968
|
runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
|
|
1059
1969
|
runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
|
|
@@ -1087,15 +1997,17 @@ export class ModelQuery {
|
|
|
1087
1997
|
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
1088
1998
|
}
|
|
1089
1999
|
const throughClass = relation.through();
|
|
1090
|
-
const throughMeta =
|
|
1091
|
-
if (!throughMeta)
|
|
1092
|
-
throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
|
|
2000
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1093
2001
|
const throughTable = throughMeta.tableName;
|
|
1094
2002
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1095
2003
|
const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
1096
2004
|
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1097
2005
|
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1098
|
-
|
|
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);
|
|
1099
2011
|
const parentIds = entities
|
|
1100
2012
|
.map((e) => e[parentLocal])
|
|
1101
2013
|
.filter((v) => v != null);
|
|
@@ -1224,7 +2136,10 @@ export class ModelQuery {
|
|
|
1224
2136
|
// on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
|
|
1225
2137
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1226
2138
|
const otherKey = pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1227
|
-
|
|
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";
|
|
1228
2143
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
1229
2144
|
if (ids.length === 0)
|
|
1230
2145
|
return [];
|
|
@@ -1234,10 +2149,37 @@ export class ModelQuery {
|
|
|
1234
2149
|
// runs are pure builder mutations, and pivot constraints are inert there.
|
|
1235
2150
|
const pivotWheres = [];
|
|
1236
2151
|
if (ctx.nestedCallback) {
|
|
1237
|
-
const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, (
|
|
2152
|
+
const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
|
|
1238
2153
|
ctx.nestedCallback(scratch);
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
+
}
|
|
1241
2183
|
}
|
|
1242
2184
|
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
1243
2185
|
const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
|
|
@@ -1282,7 +2224,9 @@ export class ModelQuery {
|
|
|
1282
2224
|
for (const col of pivotCols) {
|
|
1283
2225
|
const rawVal = pivotRow[col];
|
|
1284
2226
|
const adapter = pivotAdapters[col];
|
|
1285
|
-
related.setExtra(`pivot_${col}`,
|
|
2227
|
+
related.setExtra(`pivot_${col}`,
|
|
2228
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
2229
|
+
adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal);
|
|
1286
2230
|
}
|
|
1287
2231
|
allRelated.push(related);
|
|
1288
2232
|
}
|
|
@@ -1305,7 +2249,7 @@ export class ModelQuery {
|
|
|
1305
2249
|
async #applyNestedPreloads(relatedEntities, ctx) {
|
|
1306
2250
|
if (!ctx.nestedCallback || relatedEntities.length === 0)
|
|
1307
2251
|
return;
|
|
1308
|
-
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));
|
|
1309
2253
|
ctx.nestedCallback(sub);
|
|
1310
2254
|
if (sub.#preloads.size > 0) {
|
|
1311
2255
|
await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
|
|
@@ -1316,12 +2260,22 @@ export class ModelQuery {
|
|
|
1316
2260
|
const wheres = [
|
|
1317
2261
|
{ column, operator: "IN", value: values, type: "and" },
|
|
1318
2262
|
];
|
|
1319
|
-
|
|
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) {
|
|
1320
2270
|
wheres.push({
|
|
1321
|
-
|
|
1322
|
-
operator: w.operator,
|
|
1323
|
-
value: w.value,
|
|
2271
|
+
kind: "group",
|
|
1324
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
|
+
})),
|
|
1325
2279
|
});
|
|
1326
2280
|
}
|
|
1327
2281
|
const spec = {
|
|
@@ -1356,7 +2310,11 @@ export class ModelQuery {
|
|
|
1356
2310
|
* inside the callback are re-collected later by `#applyNestedPreloads`.
|
|
1357
2311
|
*/
|
|
1358
2312
|
async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
|
|
1359
|
-
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),
|
|
1360
2318
|
// Propagate the RELATED entity's soft-delete flag — hardcoding
|
|
1361
2319
|
// false here meant `preload('posts')` returned soft-deleted
|
|
1362
2320
|
// posts even when Post is @SoftDeletes (a data leak). The
|
|
@@ -1364,7 +2322,7 @@ export class ModelQuery {
|
|
|
1364
2322
|
// matching a direct query on that entity. (with-trashed on the
|
|
1365
2323
|
// related set, if ever needed, would be opted-in via the
|
|
1366
2324
|
// preload callback.)
|
|
1367
|
-
hasSoftDeletes(relatedClass), this.#dialect);
|
|
2325
|
+
hasSoftDeletes(relatedClass), this.#dialect, buildValuePreparer(relatedClass));
|
|
1368
2326
|
sub.whereIn(column, values);
|
|
1369
2327
|
if (relation.onQuery)
|
|
1370
2328
|
relation.onQuery(sub);
|
|
@@ -1403,31 +2361,50 @@ export class ModelQuery {
|
|
|
1403
2361
|
throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
|
|
1404
2362
|
}
|
|
1405
2363
|
const relatedClass = relation.target();
|
|
1406
|
-
const relatedMeta =
|
|
1407
|
-
if (!relatedMeta) {
|
|
1408
|
-
throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
|
|
1409
|
-
}
|
|
2364
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1410
2365
|
const relatedTable = relatedMeta.tableName;
|
|
1411
2366
|
const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
1412
2367
|
const parentTable = this.#tableName;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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);
|
|
1417
2394
|
switch (relation.type) {
|
|
1418
2395
|
case "hasOne":
|
|
1419
2396
|
case "hasMany": {
|
|
1420
2397
|
// Honour custom foreignKey/localKey exactly like the eager loader —
|
|
1421
2398
|
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
1422
2399
|
const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1423
|
-
const localKey = relation.localKey ?? parentPk;
|
|
1424
|
-
sub.#pushWhereRaw(`${
|
|
2400
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2401
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`);
|
|
1425
2402
|
break;
|
|
1426
2403
|
}
|
|
1427
2404
|
case "belongsTo": {
|
|
1428
2405
|
const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1429
|
-
const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
1430
|
-
sub.#pushWhereRaw(`${
|
|
2406
|
+
const ownerKey = buildColumnResolver(relatedClass)(relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id");
|
|
2407
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`);
|
|
1431
2408
|
break;
|
|
1432
2409
|
}
|
|
1433
2410
|
case "manyToMany": {
|
|
@@ -1439,11 +2416,12 @@ export class ModelQuery {
|
|
|
1439
2416
|
// name stripped of a trailing `s` — see the eager loader above.
|
|
1440
2417
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1441
2418
|
const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1442
|
-
const
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
`
|
|
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)})`);
|
|
1447
2425
|
break;
|
|
1448
2426
|
}
|
|
1449
2427
|
case "hasOneThrough":
|
|
@@ -1455,19 +2433,16 @@ export class ModelQuery {
|
|
|
1455
2433
|
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
1456
2434
|
}
|
|
1457
2435
|
const throughClass = relation.through();
|
|
1458
|
-
const throughMeta =
|
|
1459
|
-
if (!throughMeta) {
|
|
1460
|
-
throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
|
|
1461
|
-
}
|
|
2436
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1462
2437
|
const throughTable = throughMeta.tableName;
|
|
1463
2438
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1464
|
-
const parentLocal = relation.localKey ?? parentPk;
|
|
2439
|
+
const parentLocal = resolveParent(relation.localKey ?? parentPk);
|
|
1465
2440
|
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1466
2441
|
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1467
|
-
const secondLocal = relation.secondLocalKey ?? throughPk;
|
|
1468
|
-
sub.#pushWhereRaw(`${
|
|
1469
|
-
`(SELECT ${q(secondLocal)} FROM ${
|
|
1470
|
-
`WHERE ${
|
|
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)})`);
|
|
1471
2446
|
break;
|
|
1472
2447
|
}
|
|
1473
2448
|
}
|
|
@@ -1483,8 +2458,8 @@ export class ModelQuery {
|
|
|
1483
2458
|
return this.#pushJoin("RIGHT", table, leftOrBuild, right);
|
|
1484
2459
|
}
|
|
1485
2460
|
crossJoin(table) {
|
|
1486
|
-
const tq = this.#
|
|
1487
|
-
this.#joins.push(`CROSS JOIN ${tq}
|
|
2461
|
+
const tq = this.#quoteCol(table);
|
|
2462
|
+
this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
|
|
1488
2463
|
return this;
|
|
1489
2464
|
}
|
|
1490
2465
|
/**
|
|
@@ -1503,12 +2478,12 @@ export class ModelQuery {
|
|
|
1503
2478
|
*
|
|
1504
2479
|
* @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
|
|
1505
2480
|
*/
|
|
1506
|
-
joinRaw(fragment) {
|
|
2481
|
+
joinRaw(fragment, bindings = []) {
|
|
1507
2482
|
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1508
2483
|
throw new Error("joinRaw() is disabled in Atlas strict mode. " +
|
|
1509
2484
|
"Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
|
|
1510
2485
|
}
|
|
1511
|
-
this.#joins.push(fragment);
|
|
2486
|
+
this.#joins.push({ sql: fragment, params: [...bindings] });
|
|
1512
2487
|
return this;
|
|
1513
2488
|
}
|
|
1514
2489
|
/**
|
|
@@ -1530,9 +2505,37 @@ export class ModelQuery {
|
|
|
1530
2505
|
this.#distinct = true;
|
|
1531
2506
|
return this;
|
|
1532
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
|
+
}
|
|
1533
2526
|
/** `SELECT COUNT(DISTINCT col)`. */
|
|
1534
2527
|
async countDistinct(column) {
|
|
1535
|
-
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);
|
|
1536
2539
|
}
|
|
1537
2540
|
/** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
|
|
1538
2541
|
async exists() {
|
|
@@ -1540,7 +2543,7 @@ export class ModelQuery {
|
|
|
1540
2543
|
clone.#select = ["1"];
|
|
1541
2544
|
clone.#limit = 1;
|
|
1542
2545
|
const { sql, params } = clone.toSQL();
|
|
1543
|
-
const rows = await this.#db.query(sql, params);
|
|
2546
|
+
const rows = await this.#db.query(sql, params, this.#meta("exists"));
|
|
1544
2547
|
return rows.length > 0;
|
|
1545
2548
|
}
|
|
1546
2549
|
async doesntExist() {
|
|
@@ -1609,14 +2612,29 @@ export class ModelQuery {
|
|
|
1609
2612
|
// beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
|
|
1610
2613
|
// tenant scope) propagates into both the COUNT and the data fetch.
|
|
1611
2614
|
await fireHooks(this.#entityClass, "beforePaginate", this);
|
|
1612
|
-
//
|
|
2615
|
+
// COUNT(*) + data fetch
|
|
1613
2616
|
const countQ = this.clone();
|
|
1614
|
-
countQ.#select = ["COUNT(*) AS count"];
|
|
1615
2617
|
countQ.#limit = undefined;
|
|
1616
2618
|
countQ.#offset = undefined;
|
|
1617
2619
|
countQ.#orderBys = [];
|
|
1618
|
-
|
|
1619
|
-
|
|
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"));
|
|
1620
2638
|
const total = Number(cRows[0]?.count ?? 0);
|
|
1621
2639
|
const dataQ = this.clone();
|
|
1622
2640
|
dataQ.#limit = pp;
|
|
@@ -1625,7 +2643,10 @@ export class ModelQuery {
|
|
|
1625
2643
|
// top of the paginate hooks — paginate is its own terminal.
|
|
1626
2644
|
const items = await dataQ.#doExec();
|
|
1627
2645
|
await fireHooks(this.#entityClass, "afterPaginate", items);
|
|
1628
|
-
|
|
2646
|
+
const metaKeys = this.#entityClass
|
|
2647
|
+
? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
|
|
2648
|
+
: undefined;
|
|
2649
|
+
return new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
|
|
1629
2650
|
}
|
|
1630
2651
|
/**
|
|
1631
2652
|
* Cursor-based pagination — base64 opaque keyset, multi-column aware.
|
|
@@ -1641,7 +2662,13 @@ export class ModelQuery {
|
|
|
1641
2662
|
* accepts row-value comparisons.
|
|
1642
2663
|
*/
|
|
1643
2664
|
async cursorPaginate(opts) {
|
|
1644
|
-
|
|
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));
|
|
1645
2672
|
if (cols.length === 0)
|
|
1646
2673
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
1647
2674
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
@@ -1685,7 +2712,7 @@ export class ModelQuery {
|
|
|
1685
2712
|
const items = hasMore ? rows.slice(0, lim) : rows;
|
|
1686
2713
|
const last = items[items.length - 1];
|
|
1687
2714
|
const nextCursor = hasMore && last
|
|
1688
|
-
? Buffer.from(JSON.stringify({ v:
|
|
2715
|
+
? Buffer.from(JSON.stringify({ v: props.map((p) => last[p]) })).toString("base64")
|
|
1689
2716
|
: null;
|
|
1690
2717
|
return { items, nextCursor, hasMore };
|
|
1691
2718
|
}
|
|
@@ -1702,6 +2729,21 @@ export class ModelQuery {
|
|
|
1702
2729
|
this.#debugFlag = flag;
|
|
1703
2730
|
return this;
|
|
1704
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
|
+
}
|
|
1705
2747
|
/** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
|
|
1706
2748
|
toQuery() {
|
|
1707
2749
|
const { sql, params } = this.toSQL();
|
|
@@ -1713,7 +2755,7 @@ export class ModelQuery {
|
|
|
1713
2755
|
}
|
|
1714
2756
|
/** Deep clone of this query — mutations on the clone never affect the original. */
|
|
1715
2757
|
clone() {
|
|
1716
|
-
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);
|
|
1717
2759
|
c.#softScope = this.#softScope;
|
|
1718
2760
|
c.#wheres = structuredCloneSafe(this.#wheres);
|
|
1719
2761
|
c.#orderBys = [...this.#orderBys];
|
|
@@ -1722,17 +2764,29 @@ export class ModelQuery {
|
|
|
1722
2764
|
c.#offset = this.#offset;
|
|
1723
2765
|
c.#preloads = new Map(this.#preloads);
|
|
1724
2766
|
c.#selectSubqueries = structuredClone(this.#selectSubqueries);
|
|
1725
|
-
c.#joins =
|
|
2767
|
+
c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
|
|
1726
2768
|
c.#lockMode = this.#lockMode;
|
|
2769
|
+
c.#lockModifier = this.#lockModifier;
|
|
2770
|
+
c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
|
|
1727
2771
|
c.#distinct = this.#distinct;
|
|
2772
|
+
c.#distinctOn = [...this.#distinctOn];
|
|
1728
2773
|
c.#groupBy = [...this.#groupBy];
|
|
1729
2774
|
c.#having = structuredCloneSafe(this.#having);
|
|
1730
|
-
c.#ctes = this.#ctes.map((e) => ({
|
|
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
|
+
}));
|
|
1731
2781
|
c.#unions = this.#unions.map((u) => ({
|
|
1732
2782
|
query: u.query.clone(),
|
|
1733
2783
|
all: u.all,
|
|
2784
|
+
op: u.op,
|
|
1734
2785
|
}));
|
|
1735
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;
|
|
1736
2790
|
c.#debugFlag = this.#debugFlag;
|
|
1737
2791
|
return c;
|
|
1738
2792
|
}
|
|
@@ -1742,13 +2796,15 @@ export class ModelQuery {
|
|
|
1742
2796
|
if (!patch || Object.keys(patch).length === 0) {
|
|
1743
2797
|
throw new Error("update() requires a non-empty payload");
|
|
1744
2798
|
}
|
|
1745
|
-
|
|
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)]);
|
|
1746
2802
|
const spec = {
|
|
1747
2803
|
kind: "update",
|
|
1748
2804
|
table: this.#tableName,
|
|
1749
2805
|
set: setPairs,
|
|
1750
2806
|
wheres: this.#wheresForDml(),
|
|
1751
|
-
returning: returning ?? [],
|
|
2807
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
1752
2808
|
};
|
|
1753
2809
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
1754
2810
|
if (returning && returning.length > 0) {
|
|
@@ -1757,20 +2813,60 @@ export class ModelQuery {
|
|
|
1757
2813
|
const r = await this.#db.execute(compiled.statements[0], compiled.params);
|
|
1758
2814
|
return r.rowsAffected ?? 0;
|
|
1759
2815
|
}
|
|
1760
|
-
/**
|
|
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
|
+
*/
|
|
1761
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) {
|
|
1762
2838
|
const spec = {
|
|
1763
2839
|
kind: "delete",
|
|
1764
2840
|
table: this.#tableName,
|
|
1765
2841
|
wheres: this.#wheresForDml(),
|
|
1766
|
-
returning: returning ?? [],
|
|
2842
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
1767
2843
|
};
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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);
|
|
1774
2870
|
}
|
|
1775
2871
|
increment(colOrPatch, amount = 1) {
|
|
1776
2872
|
return this.#runIncDec("increment", colOrPatch, amount);
|
|
@@ -1797,20 +2893,73 @@ export class ModelQuery {
|
|
|
1797
2893
|
}
|
|
1798
2894
|
return this;
|
|
1799
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
|
+
}
|
|
1800
2942
|
// === Private helpers ==============================================================================
|
|
1801
2943
|
#quote(name) {
|
|
1802
2944
|
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
1803
2945
|
}
|
|
1804
2946
|
/** Quote a `table.column` reference on both sides of the dot. */
|
|
1805
2947
|
#quoteCol(ref) {
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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.`);
|
|
2955
|
+
}
|
|
2956
|
+
return ref
|
|
2957
|
+
.split(".")
|
|
2958
|
+
.map((seg) => this.#quote(seg))
|
|
2959
|
+
.join(".");
|
|
1811
2960
|
}
|
|
1812
2961
|
#pushJoin(kind, table, leftOrBuild, right) {
|
|
1813
|
-
const tq = this.#
|
|
2962
|
+
const tq = this.#quoteCol(table);
|
|
1814
2963
|
if (typeof leftOrBuild === "function") {
|
|
1815
2964
|
const jb = {
|
|
1816
2965
|
parts: [],
|
|
@@ -1822,24 +2971,56 @@ export class ModelQuery {
|
|
|
1822
2971
|
this.parts.push({ kind: "and", left: l, right: r });
|
|
1823
2972
|
return this;
|
|
1824
2973
|
},
|
|
1825
|
-
|
|
1826
|
-
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 } });
|
|
1827
2988
|
return this;
|
|
1828
2989
|
},
|
|
1829
2990
|
};
|
|
1830
2991
|
leftOrBuild(jb);
|
|
2992
|
+
// Collect the bound values in placeholder order as the fragment is built.
|
|
2993
|
+
const params = [];
|
|
1831
2994
|
const on = jb.parts
|
|
1832
2995
|
.map((p, i) => {
|
|
1833
2996
|
const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
|
|
1834
|
-
|
|
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 ?? "")}`;
|
|
1835
3013
|
})
|
|
1836
3014
|
.join(" ");
|
|
1837
|
-
this.#joins.push(`${kind} JOIN ${tq} ${on}
|
|
3015
|
+
this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
|
|
1838
3016
|
return this;
|
|
1839
3017
|
}
|
|
1840
3018
|
if (right === undefined)
|
|
1841
3019
|
throw new Error("join() with string form requires both left and right operands");
|
|
1842
|
-
this.#joins.push(
|
|
3020
|
+
this.#joins.push({
|
|
3021
|
+
sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
|
|
3022
|
+
params: [],
|
|
3023
|
+
});
|
|
1843
3024
|
return this;
|
|
1844
3025
|
}
|
|
1845
3026
|
async #runScalar(expr) {
|
|
@@ -1871,7 +3052,8 @@ export class ModelQuery {
|
|
|
1871
3052
|
* still rejected because the DML compiler's WHERE lowering does not yet
|
|
1872
3053
|
* handle nested sub-queries or correlated EXISTS.
|
|
1873
3054
|
*/
|
|
1874
|
-
|
|
3055
|
+
/** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
|
|
3056
|
+
#userWheresForDml() {
|
|
1875
3057
|
const out = [];
|
|
1876
3058
|
for (const w of this.#wheres) {
|
|
1877
3059
|
if ("kind" in w) {
|
|
@@ -1896,6 +3078,43 @@ export class ModelQuery {
|
|
|
1896
3078
|
}
|
|
1897
3079
|
return out;
|
|
1898
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
|
+
}
|
|
1899
3118
|
/**
|
|
1900
3119
|
* !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
|
|
1901
3120
|
*
|
|
@@ -1928,7 +3147,7 @@ export class ModelQuery {
|
|
|
1928
3147
|
* groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
|
|
1929
3148
|
*/
|
|
1930
3149
|
#buildGroup(type, callback) {
|
|
1931
|
-
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);
|
|
1932
3151
|
callback(scratch);
|
|
1933
3152
|
return { type, kind: "group", conditions: scratch.#wheres };
|
|
1934
3153
|
}
|
|
@@ -1968,7 +3187,7 @@ export class ModelQuery {
|
|
|
1968
3187
|
type,
|
|
1969
3188
|
column: resolved,
|
|
1970
3189
|
operator: "=",
|
|
1971
|
-
value: operatorOrValue,
|
|
3190
|
+
value: this.#prep(column, operatorOrValue),
|
|
1972
3191
|
});
|
|
1973
3192
|
}
|
|
1974
3193
|
else {
|
|
@@ -1976,11 +3195,21 @@ export class ModelQuery {
|
|
|
1976
3195
|
type,
|
|
1977
3196
|
column: resolved,
|
|
1978
3197
|
operator: operatorOrValue,
|
|
1979
|
-
value,
|
|
3198
|
+
value: this.#prep(column, value),
|
|
1980
3199
|
});
|
|
1981
3200
|
}
|
|
1982
3201
|
return this;
|
|
1983
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
|
+
}
|
|
1984
3213
|
/**
|
|
1985
3214
|
* Resolve this ModelQuery's preloads against a pre-loaded set of entities.
|
|
1986
3215
|
* Used by the nested-preload machinery to recurse without re-running the root select.
|