@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/src/ModelQuery.ts
CHANGED
|
@@ -7,17 +7,28 @@
|
|
|
7
7
|
* Builds SQL fluently and executes against the database connection.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import type {
|
|
10
|
+
import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
|
|
11
|
+
import type { QueryMeta } from "./adapters/NapiDbAdapter.js";
|
|
12
|
+
import { type BaseEntity, type DomainEvent, REPO_REF } from "./BaseEntity.js";
|
|
13
|
+
// Value import used only inside method bodies (preload hydration) — the
|
|
14
|
+
// BaseRepository ↔ ModelQuery cycle resolves at runtime, after both are defined.
|
|
12
15
|
import {
|
|
16
|
+
assertNotPromise,
|
|
17
|
+
BaseRepository,
|
|
18
|
+
type DatabaseConnection,
|
|
19
|
+
wrapAdapterError,
|
|
20
|
+
} from "./BaseRepository.js";
|
|
21
|
+
import {
|
|
22
|
+
ensureEntityMetadata,
|
|
13
23
|
getColumnMetadata,
|
|
14
|
-
|
|
24
|
+
getDateColumnConfig,
|
|
15
25
|
getPrimaryKey,
|
|
16
26
|
getRelationMetadata,
|
|
17
27
|
hasSoftDeletes,
|
|
18
28
|
type RelationMetadata,
|
|
19
29
|
} from "./decorators/entity.js";
|
|
20
30
|
import { fireHooks } from "./decorators/hooks.js";
|
|
31
|
+
import { getNamingStrategy } from "./naming/NamingStrategy.js";
|
|
21
32
|
import {
|
|
22
33
|
type AtlasDialect,
|
|
23
34
|
compileStatementNative,
|
|
@@ -43,6 +54,50 @@ const WHEREEXPR_OPERATORS = new Set<string>([
|
|
|
43
54
|
"NOT LIKE",
|
|
44
55
|
]);
|
|
45
56
|
|
|
57
|
+
/**
|
|
58
|
+
* SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
|
|
59
|
+
* They are just letters (pass the charset guard) but would let the fragment alter
|
|
60
|
+
* the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
|
|
61
|
+
* alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
|
|
62
|
+
*/
|
|
63
|
+
const WHEREEXPR_FORBIDDEN_WORDS = new Set<string>([
|
|
64
|
+
"OR",
|
|
65
|
+
"AND",
|
|
66
|
+
"NOT",
|
|
67
|
+
"IS",
|
|
68
|
+
"NULL",
|
|
69
|
+
"IN",
|
|
70
|
+
"LIKE",
|
|
71
|
+
"ILIKE",
|
|
72
|
+
"BETWEEN",
|
|
73
|
+
"EXISTS",
|
|
74
|
+
"ANY",
|
|
75
|
+
"ALL",
|
|
76
|
+
"SOME",
|
|
77
|
+
"CASE",
|
|
78
|
+
"WHEN",
|
|
79
|
+
"THEN",
|
|
80
|
+
"ELSE",
|
|
81
|
+
"END",
|
|
82
|
+
"SELECT",
|
|
83
|
+
"FROM",
|
|
84
|
+
"WHERE",
|
|
85
|
+
"JOIN",
|
|
86
|
+
"UNION",
|
|
87
|
+
"INTERSECT",
|
|
88
|
+
"EXCEPT",
|
|
89
|
+
"HAVING",
|
|
90
|
+
"GROUP",
|
|
91
|
+
"ORDER",
|
|
92
|
+
"BY",
|
|
93
|
+
"LIMIT",
|
|
94
|
+
"OFFSET",
|
|
95
|
+
"AS",
|
|
96
|
+
"DISTINCT",
|
|
97
|
+
"TRUE",
|
|
98
|
+
"FALSE",
|
|
99
|
+
]);
|
|
100
|
+
|
|
46
101
|
/** True when every `(` in `s` has a matching `)` and none closes early. */
|
|
47
102
|
function hasBalancedParens(s: string): boolean {
|
|
48
103
|
let depth = 0;
|
|
@@ -59,6 +114,112 @@ function hasBalancedParens(s: string): boolean {
|
|
|
59
114
|
type PreloadCallback = (query: ModelQuery<BaseEntity>) => void;
|
|
60
115
|
|
|
61
116
|
type ColumnResolver = (column: string) => string;
|
|
117
|
+
/**
|
|
118
|
+
* Lower a value bound for a given property to its DB form — mirrors
|
|
119
|
+
* `BaseRepository.#applyPrepare` (a `@column.dateTime` DateTime → ISO string, a
|
|
120
|
+
* `@Column({ prepare })` adapter runs). Threaded into ModelQuery so the fluent
|
|
121
|
+
* `update()` / WHERE paths don't bypass prepare the way direct repo writes don't.
|
|
122
|
+
*/
|
|
123
|
+
type ValuePreparer = (column: string, value: unknown) => unknown;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
|
|
127
|
+
* and the snake_case convention. Used to build correlated/preload subqueries on a
|
|
128
|
+
* RELATED model so their WHERE/join columns resolve like a direct query would.
|
|
129
|
+
*/
|
|
130
|
+
function buildColumnResolver(
|
|
131
|
+
entityClass: new () => BaseEntity,
|
|
132
|
+
): ColumnResolver {
|
|
133
|
+
const map = new Map<string, string>();
|
|
134
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
135
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
136
|
+
map.set(col.propertyKey, db);
|
|
137
|
+
map.set(db, db);
|
|
138
|
+
}
|
|
139
|
+
return (col) => map.get(col) ?? camelToSnake(col);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
|
|
144
|
+
* (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
|
|
145
|
+
* preload/whereHas constraint on a RELATED model prepares its values like a direct query.
|
|
146
|
+
*/
|
|
147
|
+
function buildValuePreparer(entityClass: new () => BaseEntity): ValuePreparer {
|
|
148
|
+
const prepares = new Map<
|
|
149
|
+
string,
|
|
150
|
+
(v: unknown, attribute?: string, model?: unknown) => unknown
|
|
151
|
+
>();
|
|
152
|
+
// Reverse map (db column → property) so a caller passing a DB name or an
|
|
153
|
+
// explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
|
|
154
|
+
// still routes through the property-keyed prepare/date maps — mirrors
|
|
155
|
+
// BaseRepository.#applyPrepare.
|
|
156
|
+
const byDbName = new Map<string, string>();
|
|
157
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
158
|
+
if (col.prepare) prepares.set(col.propertyKey, col.prepare);
|
|
159
|
+
byDbName.set(
|
|
160
|
+
col.columnName ?? camelToSnake(col.propertyKey),
|
|
161
|
+
col.propertyKey,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const dateCols = getDateColumnConfig(entityClass);
|
|
165
|
+
return (key, value) => {
|
|
166
|
+
const prop = byDbName.get(key) ?? key;
|
|
167
|
+
const p = prepares.get(prop);
|
|
168
|
+
// Query-builder value transform — no model instance, but the attribute is
|
|
169
|
+
// known (Adonis Lucid signature: value, attribute, model).
|
|
170
|
+
if (p) return p(value, prop, undefined);
|
|
171
|
+
if (dateCols[prop] && value != null) {
|
|
172
|
+
if (value instanceof Date) return value.toISOString();
|
|
173
|
+
return dateTimeAtlasAdapter.prepare(value);
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
|
|
180
|
+
function joinValueHasToISO(v: unknown): v is { toISO(): string } {
|
|
181
|
+
return (
|
|
182
|
+
typeof v === "object" &&
|
|
183
|
+
v !== null &&
|
|
184
|
+
"toISO" in v &&
|
|
185
|
+
typeof v.toISO === "function"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
|
|
191
|
+
* `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
|
|
192
|
+
* column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
|
|
193
|
+
* borrow the root model's adapter for a same-named column on a different table
|
|
194
|
+
* (Knex binds join values model-agnostically; we add only safe universal
|
|
195
|
+
* serialization so a DateTime still lowers to ISO like `where()`).
|
|
196
|
+
*/
|
|
197
|
+
function lowerJoinValue(value: unknown): unknown {
|
|
198
|
+
if (value instanceof Date) return value.toISOString();
|
|
199
|
+
if (joinValueHasToISO(value)) return value.toISO();
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Does a join column's table reference (`ref`) denote the root model's own table
|
|
205
|
+
* (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
|
|
206
|
+
* model declares (default schema) — `orders` matches a `public.orders` model — but
|
|
207
|
+
* it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
|
|
208
|
+
* accepts `orders.col`, while an unqualified `orders` model rejects
|
|
209
|
+
* `archive.orders.col` (a different schema the model never named) — keeping it
|
|
210
|
+
* foreign so the root model's `@Column` adapters aren't misapplied to it.
|
|
211
|
+
*/
|
|
212
|
+
function sameTableRef(ref: string, modelTable: string): boolean {
|
|
213
|
+
const rs = ref.split(".");
|
|
214
|
+
const ms = modelTable.split(".");
|
|
215
|
+
// The reference cannot be MORE qualified than the model (it can only drop the
|
|
216
|
+
// schema, never assert a new one) — otherwise treat it as a foreign table.
|
|
217
|
+
if (rs.length > ms.length) return false;
|
|
218
|
+
for (let i = 1; i <= rs.length; i++) {
|
|
219
|
+
if (rs[rs.length - i] !== ms[ms.length - i]) return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
62
223
|
|
|
63
224
|
/** Per-preload-relation locals shared by the resolver helpers. Built once per relation, then passed by ref. */
|
|
64
225
|
interface PreloadContext {
|
|
@@ -148,6 +309,22 @@ interface RawWhere {
|
|
|
148
309
|
bindings: unknown[];
|
|
149
310
|
}
|
|
150
311
|
|
|
312
|
+
/**
|
|
313
|
+
* A JSON predicate — path comparison or containment. The column is a quoted
|
|
314
|
+
* identifier; the path and value cross the boundary as bound params. Mirrors
|
|
315
|
+
* the Rust `json` WHERE kind.
|
|
316
|
+
*/
|
|
317
|
+
interface JsonWhere {
|
|
318
|
+
type: "and" | "or";
|
|
319
|
+
kind: "json";
|
|
320
|
+
jsonOp: "path" | "superset" | "subset";
|
|
321
|
+
column: string;
|
|
322
|
+
negated: boolean;
|
|
323
|
+
path?: string;
|
|
324
|
+
operator?: string;
|
|
325
|
+
value: unknown;
|
|
326
|
+
}
|
|
327
|
+
|
|
151
328
|
/** An EXISTS / NOT EXISTS correlated subquery — used by whereHas / doesntHave. */
|
|
152
329
|
interface ExistsWhere {
|
|
153
330
|
type: "and" | "or";
|
|
@@ -195,13 +372,27 @@ interface CteSpec {
|
|
|
195
372
|
name: string;
|
|
196
373
|
sql: string;
|
|
197
374
|
params: unknown[];
|
|
375
|
+
/** One recursive CTE makes the whole WITH clause recursive — see `withRecursive`. */
|
|
376
|
+
recursive?: boolean;
|
|
377
|
+
/**
|
|
378
|
+
* `true` → AS MATERIALIZED, `false` → AS NOT MATERIALIZED, `null` →
|
|
379
|
+
* planner's choice. Null rather than absent because this crosses the NAPI
|
|
380
|
+
* boundary, where serde reads a missing key and an explicit null alike.
|
|
381
|
+
*/
|
|
382
|
+
materialized?: boolean | null;
|
|
198
383
|
}
|
|
199
384
|
|
|
200
|
-
/**
|
|
385
|
+
/**
|
|
386
|
+
* A compiled set-operation branch — pre-compiled to SQL + params. Still named
|
|
387
|
+
* `UnionSpec` (and sent under `unions`) because that wire field predates
|
|
388
|
+
* INTERSECT/EXCEPT; renaming it would break the contract for no gain.
|
|
389
|
+
*/
|
|
201
390
|
interface UnionSpec {
|
|
202
391
|
sql: string;
|
|
203
392
|
params: unknown[];
|
|
204
393
|
all: boolean;
|
|
394
|
+
/** Defaults to `union` when absent/null, keeping the pre-existing wire format. */
|
|
395
|
+
op?: "union" | "intersect" | "except" | null;
|
|
205
396
|
}
|
|
206
397
|
|
|
207
398
|
interface SubqueryProjection {
|
|
@@ -209,22 +400,41 @@ interface SubqueryProjection {
|
|
|
209
400
|
subquery: SelectSpec;
|
|
210
401
|
}
|
|
211
402
|
|
|
403
|
+
/**
|
|
404
|
+
* One ORDER BY term: a resolved column + direction, or a verbatim fragment
|
|
405
|
+
* (`orderByRaw`). Both share one list so a raw term keeps its position among
|
|
406
|
+
* the plain ones. Mirrors the Rust `OrderByClause`.
|
|
407
|
+
*/
|
|
408
|
+
type OrderByEntry =
|
|
409
|
+
| { column: string; direction: "asc" | "desc" }
|
|
410
|
+
| { raw: string };
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* One GROUP BY term: a resolved column, or a verbatim fragment (`groupByRaw`).
|
|
414
|
+
* Mirrors the Rust `GroupByItem` — untagged, so a bare string stays a column
|
|
415
|
+
* and the pre-existing wire format is unchanged.
|
|
416
|
+
*/
|
|
417
|
+
type GroupByEntry = string | { raw: string };
|
|
418
|
+
|
|
212
419
|
interface SelectSpec {
|
|
213
420
|
kind: "select";
|
|
214
421
|
table: string;
|
|
215
422
|
select: string[];
|
|
216
423
|
selectSubqueries: SubqueryProjection[];
|
|
217
424
|
wheres: WhereClause[];
|
|
218
|
-
orderBy:
|
|
219
|
-
groupBy:
|
|
425
|
+
orderBy: OrderByEntry[];
|
|
426
|
+
groupBy: GroupByEntry[];
|
|
220
427
|
having: HavingEntry[];
|
|
221
428
|
limit: number | null;
|
|
222
429
|
offset: number | null;
|
|
223
430
|
distinct: boolean;
|
|
431
|
+
distinctOn: string[];
|
|
224
432
|
ctes: CteSpec[];
|
|
225
433
|
unions: UnionSpec[];
|
|
226
|
-
|
|
227
|
-
|
|
434
|
+
/** JOIN fragments; each carries its own `?`-style bound params (e.g. `onVal`). */
|
|
435
|
+
joins: Array<{ sql: string; params: unknown[] }>;
|
|
436
|
+
/** Composite lock clause, e.g. `FOR UPDATE`, `FOR NO KEY UPDATE SKIP LOCKED`. */
|
|
437
|
+
lockMode: string | null;
|
|
228
438
|
}
|
|
229
439
|
|
|
230
440
|
type WhereClause =
|
|
@@ -232,15 +442,18 @@ type WhereClause =
|
|
|
232
442
|
| RawWhere
|
|
233
443
|
| ExistsWhere
|
|
234
444
|
| GroupWhere
|
|
235
|
-
| InSubWhere
|
|
445
|
+
| InSubWhere
|
|
446
|
+
| JsonWhere;
|
|
236
447
|
|
|
237
448
|
type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
|
|
238
449
|
|
|
239
450
|
/**
|
|
240
|
-
* Process-wide strict mode flag. When enabled, `whereRaw()
|
|
241
|
-
* throw unconditionally — forcing every
|
|
242
|
-
* `whereExpr()` / `joinOn()` /
|
|
243
|
-
*
|
|
451
|
+
* Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
|
|
452
|
+
* `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
|
|
453
|
+
* call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
|
|
454
|
+
* builder paths. The connection-level `db.query()` / `db.execute()` stay available
|
|
455
|
+
* as the explicit, parameterised break-glass. Intended for prod hardening on apps
|
|
456
|
+
* that can't audit every call site manually.
|
|
244
457
|
*
|
|
245
458
|
* Enable via:
|
|
246
459
|
* - `setAtlasStrictMode(true)` at app bootstrap
|
|
@@ -252,7 +465,7 @@ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
|
|
|
252
465
|
*/
|
|
253
466
|
let atlasStrictMode: boolean | undefined;
|
|
254
467
|
|
|
255
|
-
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
|
|
468
|
+
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
|
|
256
469
|
export function setAtlasStrictMode(enabled: boolean): void {
|
|
257
470
|
atlasStrictMode = enabled;
|
|
258
471
|
}
|
|
@@ -286,12 +499,26 @@ function isInternalBypass(): boolean {
|
|
|
286
499
|
return atlasInternalBypass;
|
|
287
500
|
}
|
|
288
501
|
|
|
289
|
-
/**
|
|
502
|
+
/**
|
|
503
|
+
* Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks.
|
|
504
|
+
* `on`/`andOn`/`orOn` join two COLUMNS; `onVal`/`andOnVal`/`orOnVal` join a column
|
|
505
|
+
* to a bound VALUE (AdonisJS/Knex parity) — the value flows through the join-params
|
|
506
|
+
* channel into the compiled parameter list.
|
|
507
|
+
*/
|
|
290
508
|
interface JoinBuilder {
|
|
291
|
-
|
|
509
|
+
/** A column-to-column part (`value` absent) or a column-to-value part (`value` set). */
|
|
510
|
+
parts: Array<{
|
|
511
|
+
kind: "and" | "or";
|
|
512
|
+
left: string;
|
|
513
|
+
right?: string;
|
|
514
|
+
value?: { v: unknown };
|
|
515
|
+
}>;
|
|
292
516
|
on(left: string, right: string): JoinBuilder;
|
|
293
517
|
andOn(left: string, right: string): JoinBuilder;
|
|
518
|
+
orOn(left: string, right: string): JoinBuilder;
|
|
519
|
+
onVal(left: string, value: unknown): JoinBuilder;
|
|
294
520
|
andOnVal(left: string, value: unknown): JoinBuilder;
|
|
521
|
+
orOnVal(left: string, value: unknown): JoinBuilder;
|
|
295
522
|
}
|
|
296
523
|
|
|
297
524
|
/** Offset-based paginator (Story 29.10). */
|
|
@@ -306,20 +533,46 @@ export class Paginator<T> {
|
|
|
306
533
|
};
|
|
307
534
|
#baseUrl?: string;
|
|
308
535
|
#queryString: Record<string, unknown> = {};
|
|
536
|
+
#metaKeys?: Record<string, string>;
|
|
309
537
|
|
|
310
538
|
constructor(
|
|
311
539
|
items: T[],
|
|
312
540
|
base: { total: number; perPage: number; currentPage: number },
|
|
541
|
+
metaKeys?: Record<string, string>,
|
|
313
542
|
) {
|
|
314
543
|
this.items = items;
|
|
315
544
|
const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
|
|
316
545
|
this.meta = { ...base, lastPage, firstPage: 1 };
|
|
546
|
+
this.#metaKeys = metaKeys;
|
|
317
547
|
}
|
|
318
548
|
|
|
319
549
|
all(): T[] {
|
|
320
550
|
return this.items;
|
|
321
551
|
}
|
|
322
552
|
|
|
553
|
+
// Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
|
|
554
|
+
// carried in `.meta`, exposed directly on the instance for convenience.
|
|
555
|
+
/** Total row count across all pages. */
|
|
556
|
+
get total(): number {
|
|
557
|
+
return this.meta.total;
|
|
558
|
+
}
|
|
559
|
+
/** Rows per page. */
|
|
560
|
+
get perPage(): number {
|
|
561
|
+
return this.meta.perPage;
|
|
562
|
+
}
|
|
563
|
+
/** The current page number. */
|
|
564
|
+
get currentPage(): number {
|
|
565
|
+
return this.meta.currentPage;
|
|
566
|
+
}
|
|
567
|
+
/** The last page number. */
|
|
568
|
+
get lastPage(): number {
|
|
569
|
+
return this.meta.lastPage;
|
|
570
|
+
}
|
|
571
|
+
/** The first page number (always 1). */
|
|
572
|
+
get firstPage(): number {
|
|
573
|
+
return this.meta.firstPage;
|
|
574
|
+
}
|
|
575
|
+
|
|
323
576
|
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
324
577
|
get hasPages(): boolean {
|
|
325
578
|
return this.meta.lastPage > 1;
|
|
@@ -332,7 +585,7 @@ export class Paginator<T> {
|
|
|
332
585
|
|
|
333
586
|
serialize(opts?: { fields?: string[] }): {
|
|
334
587
|
data: unknown[];
|
|
335
|
-
meta:
|
|
588
|
+
meta: Record<string, unknown>;
|
|
336
589
|
} {
|
|
337
590
|
const data = this.items.map((item) => {
|
|
338
591
|
if (!opts?.fields) return item;
|
|
@@ -341,7 +594,9 @@ export class Paginator<T> {
|
|
|
341
594
|
picked[f] = (item as Record<string, unknown>)[f];
|
|
342
595
|
return picked;
|
|
343
596
|
});
|
|
344
|
-
|
|
597
|
+
// Same meta shape as toJSON — snake_case keys via the naming strategy's
|
|
598
|
+
// paginationMetaKeys, plus page URLs when a baseUrl is set.
|
|
599
|
+
return { data, meta: this.#buildMeta() };
|
|
345
600
|
}
|
|
346
601
|
|
|
347
602
|
baseUrl(url: string): this {
|
|
@@ -353,29 +608,76 @@ export class Paginator<T> {
|
|
|
353
608
|
return this;
|
|
354
609
|
}
|
|
355
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Build the URL for a page number, honouring `baseUrl` + `queryString`.
|
|
613
|
+
* Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
|
|
614
|
+
*/
|
|
615
|
+
getUrl(page: number): string {
|
|
616
|
+
if (!this.#baseUrl) return "";
|
|
617
|
+
const params = new URLSearchParams();
|
|
618
|
+
for (const [k, v] of Object.entries(this.#queryString))
|
|
619
|
+
params.set(k, String(v));
|
|
620
|
+
params.set("page", String(page));
|
|
621
|
+
return `${this.#baseUrl}?${params.toString()}`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
|
|
625
|
+
getNextPageUrl(): string | null {
|
|
626
|
+
return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
|
|
630
|
+
getPreviousPageUrl(): string | null {
|
|
631
|
+
return this.meta.currentPage > 1
|
|
632
|
+
? this.getUrl(this.meta.currentPage - 1)
|
|
633
|
+
: null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
|
|
637
|
+
getUrlsForRange(
|
|
638
|
+
start: number,
|
|
639
|
+
end: number,
|
|
640
|
+
): Array<{ page: number; url: string; isActive: boolean }> {
|
|
641
|
+
const lo = Math.max(1, start);
|
|
642
|
+
const hi = Math.min(this.meta.lastPage, end);
|
|
643
|
+
const range: Array<{ page: number; url: string; isActive: boolean }> = [];
|
|
644
|
+
for (let page = lo; page <= hi; page++)
|
|
645
|
+
range.push({
|
|
646
|
+
page,
|
|
647
|
+
url: this.getUrl(page),
|
|
648
|
+
isActive: page === this.meta.currentPage,
|
|
649
|
+
});
|
|
650
|
+
return range;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Build the serialized `meta` object: the raw camelCase fields plus page URLs
|
|
655
|
+
* (when a baseUrl is set), remapped through the naming strategy's
|
|
656
|
+
* `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
|
|
657
|
+
* Shared by {@link toJSON} and {@link serialize} so they never diverge.
|
|
658
|
+
*/
|
|
659
|
+
#buildMeta(): Record<string, unknown> {
|
|
660
|
+
const raw: Record<string, unknown> = { ...this.meta };
|
|
661
|
+
if (this.#baseUrl) {
|
|
662
|
+
raw.firstPageUrl = this.getUrl(1);
|
|
663
|
+
raw.lastPageUrl = this.getUrl(this.meta.lastPage);
|
|
664
|
+
const next = this.getNextPageUrl();
|
|
665
|
+
const prev = this.getPreviousPageUrl();
|
|
666
|
+
if (next) raw.nextPageUrl = next;
|
|
667
|
+
if (prev) raw.previousPageUrl = prev;
|
|
668
|
+
}
|
|
669
|
+
const keys = this.#metaKeys;
|
|
670
|
+
if (!keys) return raw;
|
|
671
|
+
const meta: Record<string, unknown> = {};
|
|
672
|
+
for (const [k, v] of Object.entries(raw)) meta[keys[k] ?? k] = v;
|
|
673
|
+
return meta;
|
|
674
|
+
}
|
|
675
|
+
|
|
356
676
|
toJSON(): {
|
|
357
677
|
data: unknown[];
|
|
358
|
-
meta:
|
|
678
|
+
meta: Record<string, unknown>;
|
|
359
679
|
} {
|
|
360
|
-
|
|
361
|
-
...this.meta,
|
|
362
|
-
};
|
|
363
|
-
if (this.#baseUrl) {
|
|
364
|
-
const build = (page: number) => {
|
|
365
|
-
const params = new URLSearchParams();
|
|
366
|
-
for (const [k, v] of Object.entries(this.#queryString))
|
|
367
|
-
params.set(k, String(v));
|
|
368
|
-
params.set("page", String(page));
|
|
369
|
-
return `${this.#baseUrl}?${params.toString()}`;
|
|
370
|
-
};
|
|
371
|
-
meta.firstPageUrl = build(1);
|
|
372
|
-
meta.lastPageUrl = build(this.meta.lastPage);
|
|
373
|
-
if (this.meta.currentPage < this.meta.lastPage)
|
|
374
|
-
meta.nextPageUrl = build(this.meta.currentPage + 1);
|
|
375
|
-
if (this.meta.currentPage > 1)
|
|
376
|
-
meta.previousPageUrl = build(this.meta.currentPage - 1);
|
|
377
|
-
}
|
|
378
|
-
return { data: this.items as unknown[], meta };
|
|
680
|
+
return { data: this.items as unknown[], meta: this.#buildMeta() };
|
|
379
681
|
}
|
|
380
682
|
}
|
|
381
683
|
|
|
@@ -393,7 +695,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
393
695
|
#softDeletes: boolean;
|
|
394
696
|
#softScope: SoftDeleteScope = "default";
|
|
395
697
|
#wheres: WhereClause[] = [];
|
|
396
|
-
#orderBys:
|
|
698
|
+
#orderBys: OrderByEntry[] = [];
|
|
397
699
|
#select: string[] = ["*"];
|
|
398
700
|
#limit?: number;
|
|
399
701
|
#offset?: number;
|
|
@@ -403,24 +705,62 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
403
705
|
/** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
|
|
404
706
|
#subqueryAlias?: string;
|
|
405
707
|
/** Raw JOIN fragments — Story 29.4. */
|
|
406
|
-
#joins: string[] = [];
|
|
407
|
-
/** Row lock mode — Story 30.8. */
|
|
408
|
-
#lockMode:
|
|
708
|
+
#joins: Array<{ sql: string; params: unknown[] }> = [];
|
|
709
|
+
/** Row lock base mode — Story 30.8. */
|
|
710
|
+
#lockMode:
|
|
711
|
+
| "FOR UPDATE"
|
|
712
|
+
| "FOR SHARE"
|
|
713
|
+
| "FOR NO KEY UPDATE"
|
|
714
|
+
| "FOR KEY SHARE"
|
|
715
|
+
| null = null;
|
|
716
|
+
/** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
|
|
717
|
+
#lockModifier: "SKIP LOCKED" | "NOWAIT" | null = null;
|
|
718
|
+
/** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
|
|
719
|
+
#sideloaded: Record<string, unknown> | null = null;
|
|
409
720
|
/** Per-query debug flag — Story 29.11. */
|
|
410
721
|
#debugFlag = false;
|
|
411
722
|
/** Distinct flag — Story 29.5. */
|
|
412
723
|
#distinct = false;
|
|
724
|
+
#distinctOn: string[] = [];
|
|
413
725
|
/** GROUP BY columns (Lucid parity). */
|
|
414
|
-
#groupBy:
|
|
726
|
+
#groupBy: GroupByEntry[] = [];
|
|
415
727
|
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
416
728
|
#having: HavingEntry[] = [];
|
|
417
729
|
/** CTEs registered via `.with()` (Lucid parity). */
|
|
418
|
-
#ctes: Array<{
|
|
730
|
+
#ctes: Array<{
|
|
731
|
+
name: string;
|
|
732
|
+
query: ModelQuery<BaseEntity>;
|
|
733
|
+
recursive?: boolean;
|
|
734
|
+
materialized?: boolean;
|
|
735
|
+
}> = [];
|
|
419
736
|
/** UNION / UNION ALL branches (Lucid parity). */
|
|
420
|
-
#unions: Array<{
|
|
737
|
+
#unions: Array<{
|
|
738
|
+
query: ModelQuery<BaseEntity>;
|
|
739
|
+
all: boolean;
|
|
740
|
+
op?: "union" | "intersect" | "except";
|
|
741
|
+
}> = [];
|
|
421
742
|
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
422
|
-
#pivotWheres: Array<{
|
|
423
|
-
|
|
743
|
+
#pivotWheres: Array<{
|
|
744
|
+
column: string;
|
|
745
|
+
operator: string;
|
|
746
|
+
value: unknown;
|
|
747
|
+
/** AND/OR within the parenthesised pivot-filter group — see `#runInQuery`. */
|
|
748
|
+
type: "and" | "or";
|
|
749
|
+
}> = [];
|
|
750
|
+
/**
|
|
751
|
+
* Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
|
|
752
|
+
* the relation proxy's scoped query; invoked at `#buildSpec()` time with the
|
|
753
|
+
* CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
|
|
754
|
+
* back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
|
|
755
|
+
* time would freeze the predicate before those calls and silently drop them).
|
|
756
|
+
*/
|
|
757
|
+
#pivotExists?: (
|
|
758
|
+
pivotWheres: ReadonlyArray<{
|
|
759
|
+
column: string;
|
|
760
|
+
operator: string;
|
|
761
|
+
value: unknown;
|
|
762
|
+
}>,
|
|
763
|
+
) => { sql: string; bindings: unknown[] };
|
|
424
764
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
425
765
|
#dialect: AtlasDialect;
|
|
426
766
|
|
|
@@ -432,6 +772,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
432
772
|
resolveColumn: ColumnResolver = (c) => c,
|
|
433
773
|
softDeletes = false,
|
|
434
774
|
dialect: AtlasDialect = getAtlasDialect(),
|
|
775
|
+
prepareValue: ValuePreparer = (_c, v) => v,
|
|
776
|
+
onDomainEvents?: (events: DomainEvent[]) => Promise<void>,
|
|
435
777
|
) {
|
|
436
778
|
this.#tableName = tableName;
|
|
437
779
|
this.#db = db;
|
|
@@ -440,8 +782,15 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
440
782
|
this.#resolveColumn = resolveColumn;
|
|
441
783
|
this.#softDeletes = softDeletes;
|
|
442
784
|
this.#dialect = dialect;
|
|
785
|
+
this.#prepareValue = prepareValue;
|
|
786
|
+
this.#onDomainEvents = onDomainEvents;
|
|
443
787
|
}
|
|
444
788
|
|
|
789
|
+
/** @see ValuePreparer — identity unless the owning repository wires prepare in. */
|
|
790
|
+
#prepareValue: ValuePreparer;
|
|
791
|
+
/** Domain-event bus threaded from the owning repository — propagated to preload repos. */
|
|
792
|
+
#onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
|
|
793
|
+
|
|
445
794
|
/** Include soft-deleted rows in the result (default behavior excludes them). */
|
|
446
795
|
withTrashed(): this {
|
|
447
796
|
this.#softScope = "with-trashed";
|
|
@@ -469,12 +818,31 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
469
818
|
|
|
470
819
|
/** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
|
|
471
820
|
select(columns: string | string[]): this {
|
|
472
|
-
|
|
821
|
+
const list = Array.isArray(columns)
|
|
473
822
|
? columns
|
|
474
823
|
: columns.split(",").map((c) => c.trim());
|
|
824
|
+
this.#select = list.map((c) => this.#resolveSelect(c));
|
|
475
825
|
return this;
|
|
476
826
|
}
|
|
477
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Resolve a bare model-property select/returning target to its DB column
|
|
830
|
+
* (honouring `@Column({ columnName })`), leaving expressions / aliases /
|
|
831
|
+
* qualified names / `*` untouched. A bare identifier IS validated through the
|
|
832
|
+
* column resolver — so a typo like `select('lable')` raises the same Atlas
|
|
833
|
+
* error as `where`/`orderBy`, rather than reaching the DB.
|
|
834
|
+
*/
|
|
835
|
+
#resolveSelect(col: string): string {
|
|
836
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col)) return this.#resolveColumn(col);
|
|
837
|
+
// `col as alias` — resolve the (bare) column part to its DB name, keep the
|
|
838
|
+
// alias verbatim, so `select('label as name')` honours a columnName override.
|
|
839
|
+
const aliased = col.match(
|
|
840
|
+
/^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i,
|
|
841
|
+
);
|
|
842
|
+
if (aliased) return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
|
|
843
|
+
return col;
|
|
844
|
+
}
|
|
845
|
+
|
|
478
846
|
where(callback: WhereCallback): this;
|
|
479
847
|
where(column: string, value: unknown): this;
|
|
480
848
|
where(column: string, operator: string, value: unknown): this;
|
|
@@ -525,13 +893,88 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
525
893
|
return this;
|
|
526
894
|
}
|
|
527
895
|
|
|
896
|
+
// ─── AND aliases ──────────────────────────────────────────
|
|
897
|
+
//
|
|
898
|
+
// Lucid documents an `and*` spelling alongside every `where*`. They are
|
|
899
|
+
// exact synonyms — the base methods already default to AND — and exist so a
|
|
900
|
+
// chain can say so out loud: `.where(a).andWhere(b)`. Kept as thin
|
|
901
|
+
// delegations rather than duplicated bodies, so they cannot drift.
|
|
902
|
+
|
|
903
|
+
andWhere(callback: WhereCallback): this;
|
|
904
|
+
andWhere(column: string, value: unknown): this;
|
|
905
|
+
andWhere(column: string, operator: string, value: unknown): this;
|
|
906
|
+
andWhere(
|
|
907
|
+
columnOrCb: string | WhereCallback,
|
|
908
|
+
operatorOrValue?: unknown,
|
|
909
|
+
value?: unknown,
|
|
910
|
+
): this {
|
|
911
|
+
// The 2-arg overload must not forward a phantom third argument: `where`
|
|
912
|
+
// switches on `value === undefined` to tell `(col, value)` from
|
|
913
|
+
// `(col, operator, value)`.
|
|
914
|
+
return typeof columnOrCb === "function"
|
|
915
|
+
? this.where(columnOrCb)
|
|
916
|
+
: value === undefined
|
|
917
|
+
? this.where(columnOrCb, operatorOrValue)
|
|
918
|
+
: this.where(columnOrCb, operatorOrValue as string, value);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Alias of {@link whereNot} (Lucid parity). */
|
|
922
|
+
andWhereNot(column: string, value: unknown): this {
|
|
923
|
+
return this.whereNot(column, value);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Alias of {@link whereIn} (Lucid parity). */
|
|
927
|
+
andWhereIn(column: string, values: readonly unknown[]): this {
|
|
928
|
+
return this.whereIn(column, values);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Alias of {@link whereNotIn} (Lucid parity). */
|
|
932
|
+
andWhereNotIn(column: string, values: readonly unknown[]): this {
|
|
933
|
+
return this.whereNotIn(column, values);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Alias of {@link whereNull} (Lucid parity). */
|
|
937
|
+
andWhereNull(column: string): this {
|
|
938
|
+
return this.whereNull(column);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Alias of {@link whereNotNull} (Lucid parity). */
|
|
942
|
+
andWhereNotNull(column: string): this {
|
|
943
|
+
return this.whereNotNull(column);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Alias of {@link whereBetween} (Lucid parity). */
|
|
947
|
+
andWhereBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
948
|
+
return this.whereBetween(column, range);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Alias of {@link whereNotBetween} (Lucid parity). */
|
|
952
|
+
andWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
953
|
+
return this.whereNotBetween(column, range);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Alias of {@link whereLike} (Lucid parity). */
|
|
957
|
+
andWhereLike(column: string, pattern: string): this {
|
|
958
|
+
return this.whereLike(column, pattern);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Alias of {@link whereILike} (Lucid parity). */
|
|
962
|
+
andWhereILike(column: string, pattern: string): this {
|
|
963
|
+
return this.whereILike(column, pattern);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Alias of {@link whereColumn} (Lucid parity). */
|
|
967
|
+
andWhereColumn(left: string, operator: string, right: string): this {
|
|
968
|
+
return this.whereColumn(left, operator, right);
|
|
969
|
+
}
|
|
970
|
+
|
|
528
971
|
/** `WHERE col != ?` — negation of `where`. */
|
|
529
972
|
whereNot(column: string, value: unknown): this {
|
|
530
973
|
this.#wheres.push({
|
|
531
974
|
type: "and",
|
|
532
975
|
column: this.#resolveColumn(column),
|
|
533
976
|
operator: "!=",
|
|
534
|
-
value,
|
|
977
|
+
value: this.#prep(column, value),
|
|
535
978
|
});
|
|
536
979
|
return this;
|
|
537
980
|
}
|
|
@@ -555,7 +998,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
555
998
|
type: "and",
|
|
556
999
|
column: this.#resolveColumn(column),
|
|
557
1000
|
operator: "IN",
|
|
558
|
-
value: [...source],
|
|
1001
|
+
value: this.#prep(column, [...source]),
|
|
559
1002
|
});
|
|
560
1003
|
return this;
|
|
561
1004
|
}
|
|
@@ -579,7 +1022,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
579
1022
|
type: "and",
|
|
580
1023
|
column: this.#resolveColumn(column),
|
|
581
1024
|
operator: "NOT IN",
|
|
582
|
-
value: [...source],
|
|
1025
|
+
value: this.#prep(column, [...source]),
|
|
583
1026
|
});
|
|
584
1027
|
return this;
|
|
585
1028
|
}
|
|
@@ -590,7 +1033,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
590
1033
|
type: "and",
|
|
591
1034
|
column: this.#resolveColumn(column),
|
|
592
1035
|
operator: "BETWEEN",
|
|
593
|
-
value: [...range],
|
|
1036
|
+
value: this.#prep(column, [...range]),
|
|
594
1037
|
});
|
|
595
1038
|
return this;
|
|
596
1039
|
}
|
|
@@ -601,7 +1044,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
601
1044
|
type: "and",
|
|
602
1045
|
column: this.#resolveColumn(column),
|
|
603
1046
|
operator: "NOT BETWEEN",
|
|
604
|
-
value: [...range],
|
|
1047
|
+
value: this.#prep(column, [...range]),
|
|
605
1048
|
});
|
|
606
1049
|
return this;
|
|
607
1050
|
}
|
|
@@ -664,7 +1107,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
664
1107
|
type: "or",
|
|
665
1108
|
column: this.#resolveColumn(column),
|
|
666
1109
|
operator: "!=",
|
|
667
|
-
value,
|
|
1110
|
+
value: this.#prep(column, value),
|
|
668
1111
|
});
|
|
669
1112
|
return this;
|
|
670
1113
|
}
|
|
@@ -688,7 +1131,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
688
1131
|
type: "or",
|
|
689
1132
|
column: this.#resolveColumn(column),
|
|
690
1133
|
operator: "IN",
|
|
691
|
-
value: [...source],
|
|
1134
|
+
value: this.#prep(column, [...source]),
|
|
692
1135
|
});
|
|
693
1136
|
return this;
|
|
694
1137
|
}
|
|
@@ -712,7 +1155,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
712
1155
|
type: "or",
|
|
713
1156
|
column: this.#resolveColumn(column),
|
|
714
1157
|
operator: "NOT IN",
|
|
715
|
-
value: [...source],
|
|
1158
|
+
value: this.#prep(column, [...source]),
|
|
716
1159
|
});
|
|
717
1160
|
return this;
|
|
718
1161
|
}
|
|
@@ -723,7 +1166,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
723
1166
|
type: "or",
|
|
724
1167
|
column: this.#resolveColumn(column),
|
|
725
1168
|
operator: "BETWEEN",
|
|
726
|
-
value: [...range],
|
|
1169
|
+
value: this.#prep(column, [...range]),
|
|
727
1170
|
});
|
|
728
1171
|
return this;
|
|
729
1172
|
}
|
|
@@ -734,7 +1177,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
734
1177
|
type: "or",
|
|
735
1178
|
column: this.#resolveColumn(column),
|
|
736
1179
|
operator: "NOT BETWEEN",
|
|
737
|
-
value: [...range],
|
|
1180
|
+
value: this.#prep(column, [...range]),
|
|
738
1181
|
});
|
|
739
1182
|
return this;
|
|
740
1183
|
}
|
|
@@ -803,9 +1246,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
803
1246
|
* Not exported from the package barrel — only accessible inside the Atlas
|
|
804
1247
|
* codebase via direct ModelQuery instance access.
|
|
805
1248
|
*/
|
|
806
|
-
#pushWhereRaw(
|
|
1249
|
+
#pushWhereRaw(
|
|
1250
|
+
sql: string,
|
|
1251
|
+
bindings: readonly unknown[] = [],
|
|
1252
|
+
type: "and" | "or" = "and",
|
|
1253
|
+
): this {
|
|
807
1254
|
this.#wheres.push({
|
|
808
|
-
type
|
|
1255
|
+
type,
|
|
809
1256
|
kind: "raw",
|
|
810
1257
|
sql,
|
|
811
1258
|
bindings: [...bindings],
|
|
@@ -813,6 +1260,52 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
813
1260
|
return this;
|
|
814
1261
|
}
|
|
815
1262
|
|
|
1263
|
+
/** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
|
|
1264
|
+
andWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
|
|
1265
|
+
return this.whereRaw(sql, bindings);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* `OR <raw fragment>` (Lucid parity).
|
|
1270
|
+
*
|
|
1271
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1272
|
+
* Subject to the same strict-mode gate as {@link whereRaw}.
|
|
1273
|
+
*/
|
|
1274
|
+
orWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
|
|
1275
|
+
this.#assertRawAllowed("orWhereRaw");
|
|
1276
|
+
return this.#pushWhereRaw(sql, bindings, "or");
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Shared strict-mode gate for the raw WHERE entry points. */
|
|
1280
|
+
#assertRawAllowed(method: string): void {
|
|
1281
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1282
|
+
throw new Error(
|
|
1283
|
+
`${method}() is disabled in Atlas strict mode. ` +
|
|
1284
|
+
"Use whereExpr() or a structured builder method instead. " +
|
|
1285
|
+
"Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.",
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Framework-internal: register the deferred m2m EXISTS predicate for a lazy
|
|
1292
|
+
* `related().query()`. The builder is re-invoked on every `#buildSpec()` with
|
|
1293
|
+
* the pivot constraints known at that moment, so `.wherePivot()` added after
|
|
1294
|
+
* the proxy returned still applies. Not exported from the barrel.
|
|
1295
|
+
*/
|
|
1296
|
+
setPivotExistsBuilder(
|
|
1297
|
+
builder: (
|
|
1298
|
+
pivotWheres: ReadonlyArray<{
|
|
1299
|
+
column: string;
|
|
1300
|
+
operator: string;
|
|
1301
|
+
value: unknown;
|
|
1302
|
+
}>,
|
|
1303
|
+
) => { sql: string; bindings: unknown[] },
|
|
1304
|
+
): this {
|
|
1305
|
+
this.#pivotExists = builder;
|
|
1306
|
+
return this;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
816
1309
|
/**
|
|
817
1310
|
* **SAFE** alternative to `whereRaw` for the common case of a single
|
|
818
1311
|
* SQL expression built from a validated column + operator + bound value.
|
|
@@ -863,6 +1356,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
863
1356
|
`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
|
|
864
1357
|
);
|
|
865
1358
|
}
|
|
1359
|
+
// The charset blocks comparison/quote symbols, but bare SQL keywords
|
|
1360
|
+
// (OR / AND / IS / NOT / SELECT …) are just letters and would slip
|
|
1361
|
+
// through, letting `extra` alter the predicate's logical structure
|
|
1362
|
+
// (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
|
|
1363
|
+
// SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
|
|
1364
|
+
// token — arithmetic on columns/numbers/functions only.
|
|
1365
|
+
for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
|
1366
|
+
if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
|
|
1367
|
+
throw new Error(
|
|
1368
|
+
`whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
|
|
1369
|
+
"whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.",
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
866
1373
|
// `op` is interpolated raw into the fragment below, so it MUST be
|
|
867
1374
|
// allow-listed — the 3-arg path gets this from the Rust operator
|
|
868
1375
|
// validation, but the raw 4-arg path bypasses Rust and would
|
|
@@ -881,9 +1388,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
881
1388
|
// the operator against the allow-list above.
|
|
882
1389
|
if (hasExtra) {
|
|
883
1390
|
const q = this.#quote(resolved);
|
|
884
|
-
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1391
|
+
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1392
|
+
this.#prep(column, value),
|
|
1393
|
+
]);
|
|
885
1394
|
}
|
|
886
|
-
this.#wheres.push({
|
|
1395
|
+
this.#wheres.push({
|
|
1396
|
+
type: "and",
|
|
1397
|
+
column: resolved,
|
|
1398
|
+
operator: op,
|
|
1399
|
+
value: this.#prep(column, value),
|
|
1400
|
+
});
|
|
887
1401
|
return this;
|
|
888
1402
|
}
|
|
889
1403
|
|
|
@@ -893,6 +1407,206 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
893
1407
|
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
894
1408
|
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
895
1409
|
*/
|
|
1410
|
+
// ─── EXISTS ───────────────────────────────────────────────
|
|
1411
|
+
//
|
|
1412
|
+
// `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
|
|
1413
|
+
// builder `repo.query()` actually hands back, so it was unreachable from
|
|
1414
|
+
// normal use. The subquery is another `ModelQuery`; correlate it to the
|
|
1415
|
+
// outer table with `whereColumn`:
|
|
1416
|
+
//
|
|
1417
|
+
// userRepo.query().whereExists(
|
|
1418
|
+
// postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
|
|
1419
|
+
// )
|
|
1420
|
+
//
|
|
1421
|
+
// For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
|
|
1422
|
+
// join predicate from the relation metadata.
|
|
1423
|
+
|
|
1424
|
+
/** `WHERE EXISTS (subquery)` (Lucid parity). */
|
|
1425
|
+
whereExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1426
|
+
return this.#pushExists("and", false, subquery);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** Alias of {@link whereExists} (Lucid parity). */
|
|
1430
|
+
andWhereExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1431
|
+
return this.#pushExists("and", false, subquery);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** `OR EXISTS (subquery)` (Lucid parity). */
|
|
1435
|
+
orWhereExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1436
|
+
return this.#pushExists("or", false, subquery);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
|
|
1440
|
+
whereNotExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1441
|
+
return this.#pushExists("and", true, subquery);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Alias of {@link whereNotExists} (Lucid parity). */
|
|
1445
|
+
andWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1446
|
+
return this.#pushExists("and", true, subquery);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** `OR NOT EXISTS (subquery)` (Lucid parity). */
|
|
1450
|
+
orWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1451
|
+
return this.#pushExists("or", true, subquery);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ─── JSON ─────────────────────────────────────────────────
|
|
1455
|
+
//
|
|
1456
|
+
// Every value crosses the boundary as a bound param — the path and the
|
|
1457
|
+
// compared value both. Only the column is a quoted identifier. Path access
|
|
1458
|
+
// and containment are each spelled per dialect, and SQLite has no
|
|
1459
|
+
// containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* `WHERE <col at path> <op> ?` — compare a value inside a JSON column
|
|
1463
|
+
* (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
|
|
1464
|
+
*
|
|
1465
|
+
* query.whereJsonPath('data', '$.address.city', '=', 'Paris')
|
|
1466
|
+
*/
|
|
1467
|
+
whereJsonPath(
|
|
1468
|
+
column: string,
|
|
1469
|
+
path: string,
|
|
1470
|
+
operator: string,
|
|
1471
|
+
value: unknown,
|
|
1472
|
+
): this {
|
|
1473
|
+
return this.#pushJson("and", false, "path", column, value, path, operator);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** Alias of {@link whereJsonPath} (Lucid parity). */
|
|
1477
|
+
andWhereJsonPath(
|
|
1478
|
+
column: string,
|
|
1479
|
+
path: string,
|
|
1480
|
+
operator: string,
|
|
1481
|
+
value: unknown,
|
|
1482
|
+
): this {
|
|
1483
|
+
return this.#pushJson("and", false, "path", column, value, path, operator);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** `OR <col at path> <op> ?` (Lucid parity). */
|
|
1487
|
+
orWhereJsonPath(
|
|
1488
|
+
column: string,
|
|
1489
|
+
path: string,
|
|
1490
|
+
operator: string,
|
|
1491
|
+
value: unknown,
|
|
1492
|
+
): this {
|
|
1493
|
+
return this.#pushJson("or", false, "path", column, value, path, operator);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
|
|
1498
|
+
* `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
|
|
1499
|
+
*
|
|
1500
|
+
* Postgres and MySQL only — SQLite has no JSON containment operator and the
|
|
1501
|
+
* compiler raises `E_UNSUPPORTED` there.
|
|
1502
|
+
*/
|
|
1503
|
+
whereJsonSupersetOf(column: string, value: unknown): this {
|
|
1504
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
|
|
1508
|
+
andWhereJsonSupersetOf(column: string, value: unknown): this {
|
|
1509
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
|
|
1513
|
+
orWhereJsonSupersetOf(column: string, value: unknown): this {
|
|
1514
|
+
return this.#pushJson("or", false, "superset", column, value);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** `WHERE NOT (<col> @> ?)` (Lucid parity). */
|
|
1518
|
+
whereNotJsonSupersetOf(column: string, value: unknown): this {
|
|
1519
|
+
return this.#pushJson("and", true, "superset", column, value);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** `OR NOT (<col> @> ?)` (Lucid parity). */
|
|
1523
|
+
orWhereNotJsonSupersetOf(column: string, value: unknown): this {
|
|
1524
|
+
return this.#pushJson("or", true, "superset", column, value);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
|
|
1529
|
+
* `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
|
|
1530
|
+
*/
|
|
1531
|
+
whereJsonSubsetOf(column: string, value: unknown): this {
|
|
1532
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
|
|
1536
|
+
andWhereJsonSubsetOf(column: string, value: unknown): this {
|
|
1537
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
|
|
1541
|
+
orWhereJsonSubsetOf(column: string, value: unknown): this {
|
|
1542
|
+
return this.#pushJson("or", false, "subset", column, value);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
|
|
1546
|
+
whereNotJsonSubsetOf(column: string, value: unknown): this {
|
|
1547
|
+
return this.#pushJson("and", true, "subset", column, value);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** `OR NOT (<col> <@ ?)` (Lucid parity). */
|
|
1551
|
+
orWhereNotJsonSubsetOf(column: string, value: unknown): this {
|
|
1552
|
+
return this.#pushJson("or", true, "subset", column, value);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
#pushJson(
|
|
1556
|
+
type: "and" | "or",
|
|
1557
|
+
negated: boolean,
|
|
1558
|
+
jsonOp: "path" | "superset" | "subset",
|
|
1559
|
+
column: string,
|
|
1560
|
+
value: unknown,
|
|
1561
|
+
path?: string,
|
|
1562
|
+
operator?: string,
|
|
1563
|
+
): this {
|
|
1564
|
+
// A JSONPath is bound, not interpolated, so injection is not the concern
|
|
1565
|
+
// here — a clear early error for a malformed path is. Lucid/Knex paths
|
|
1566
|
+
// start at the document root.
|
|
1567
|
+
if (path !== undefined && !path.startsWith("$")) {
|
|
1568
|
+
throw new Error(
|
|
1569
|
+
`whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`,
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
// Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
|
|
1573
|
+
// and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
|
|
1574
|
+
// as-is would not cast. A path comparison keeps its scalar value.
|
|
1575
|
+
const bound =
|
|
1576
|
+
jsonOp === "path"
|
|
1577
|
+
? value
|
|
1578
|
+
: typeof value === "string"
|
|
1579
|
+
? value
|
|
1580
|
+
: JSON.stringify(value);
|
|
1581
|
+
this.#wheres.push({
|
|
1582
|
+
type,
|
|
1583
|
+
kind: "json",
|
|
1584
|
+
jsonOp,
|
|
1585
|
+
column: this.#resolveColumn(column),
|
|
1586
|
+
negated,
|
|
1587
|
+
path,
|
|
1588
|
+
operator,
|
|
1589
|
+
value: bound,
|
|
1590
|
+
});
|
|
1591
|
+
return this;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
#pushExists(
|
|
1595
|
+
type: "and" | "or",
|
|
1596
|
+
negated: boolean,
|
|
1597
|
+
subquery: ModelQuery<BaseEntity>,
|
|
1598
|
+
): this {
|
|
1599
|
+
// `#buildSpec` is private, but private access is per-class, not per
|
|
1600
|
+
// instance: another ModelQuery's spec is reachable from here.
|
|
1601
|
+
this.#wheres.push({
|
|
1602
|
+
type,
|
|
1603
|
+
kind: "exists",
|
|
1604
|
+
negated,
|
|
1605
|
+
subquery: subquery.#buildSpec(),
|
|
1606
|
+
});
|
|
1607
|
+
return this;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
896
1610
|
whereColumn(left: string, operator: string, right: string): this {
|
|
897
1611
|
return this.#whereColumn("and", left, operator, right);
|
|
898
1612
|
}
|
|
@@ -902,11 +1616,27 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
902
1616
|
return this.#whereColumn("or", left, operator, right);
|
|
903
1617
|
}
|
|
904
1618
|
|
|
1619
|
+
/** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
|
|
1620
|
+
whereNotColumn(left: string, operator: string, right: string): this {
|
|
1621
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Alias of {@link whereNotColumn} (Lucid parity). */
|
|
1625
|
+
andWhereNotColumn(left: string, operator: string, right: string): this {
|
|
1626
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** `OR NOT (left <op> right)` (Lucid parity). */
|
|
1630
|
+
orWhereNotColumn(left: string, operator: string, right: string): this {
|
|
1631
|
+
return this.#whereColumn("or", left, operator, right, true);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
905
1634
|
#whereColumn(
|
|
906
1635
|
type: "and" | "or",
|
|
907
1636
|
left: string,
|
|
908
1637
|
operator: string,
|
|
909
1638
|
right: string,
|
|
1639
|
+
negated = false,
|
|
910
1640
|
): this {
|
|
911
1641
|
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
912
1642
|
throw new Error(
|
|
@@ -919,7 +1649,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
919
1649
|
// `[table.]column` charset. This closes the injection surface regardless of
|
|
920
1650
|
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
921
1651
|
const safe = (name: string): string => {
|
|
922
|
-
const resolved = this.#
|
|
1652
|
+
const resolved = this.#resolveColumnReference(name);
|
|
923
1653
|
if (
|
|
924
1654
|
!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
|
|
925
1655
|
) {
|
|
@@ -934,11 +1664,48 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
934
1664
|
.map((part) => this.#quote(part))
|
|
935
1665
|
.join(".");
|
|
936
1666
|
};
|
|
937
|
-
const
|
|
1667
|
+
const predicate = `${safe(left)} ${operator} ${safe(right)}`;
|
|
1668
|
+
// Both operands are already validated identifiers and the operator is
|
|
1669
|
+
// allow-listed, so wrapping in NOT(...) adds no new surface.
|
|
1670
|
+
const sql = negated ? `NOT (${predicate})` : predicate;
|
|
938
1671
|
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
939
1672
|
return this;
|
|
940
1673
|
}
|
|
941
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* Resolve a column reference that may legitimately point at a table other
|
|
1677
|
+
* than this query's own.
|
|
1678
|
+
*
|
|
1679
|
+
* `#resolveColumn` only knows the entity's own columns, so it rejects
|
|
1680
|
+
* anything qualified. That is right for a value predicate, but wrong for a
|
|
1681
|
+
* column-to-column one: a correlated subquery
|
|
1682
|
+
* (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
|
|
1683
|
+
* and a joined query both have to name another table, and atlas cannot know
|
|
1684
|
+
* that table's columns. So: an unqualified name resolves as usual (typos
|
|
1685
|
+
* still get the helpful error), and a `table.column` naming a different
|
|
1686
|
+
* table passes through — validated against the identifier charset here and
|
|
1687
|
+
* quoted segment by segment by the caller, never interpolated loose. A typo
|
|
1688
|
+
* in that case surfaces as a database error rather than an atlas one, which
|
|
1689
|
+
* is the unavoidable cost of referencing a table we have no metadata for.
|
|
1690
|
+
*/
|
|
1691
|
+
#resolveColumnReference(name: string): string {
|
|
1692
|
+
const qualified =
|
|
1693
|
+
/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
|
|
1694
|
+
if (!qualified) return this.#resolveColumn(name);
|
|
1695
|
+
|
|
1696
|
+
const [, table, column] = qualified;
|
|
1697
|
+
// Our own table: resolve the column half so `@Column({ columnName })` and
|
|
1698
|
+
// the camel→snake convention still apply.
|
|
1699
|
+
if (table === this.#tableName) {
|
|
1700
|
+
return `${table}.${this.#resolveColumn(column as string)}`;
|
|
1701
|
+
}
|
|
1702
|
+
// Another table in scope (outer query or JOIN). Charset-checked by the
|
|
1703
|
+
// regex above and quoted segment by segment by the caller — strict mode
|
|
1704
|
+
// does not apply, since its concern is unvalidated SQL reaching the
|
|
1705
|
+
// compiler and this identifier is validated.
|
|
1706
|
+
return `${table}.${column}`;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
942
1709
|
/**
|
|
943
1710
|
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
944
1711
|
* by the existence of related rows, optionally constrained by a callback.
|
|
@@ -987,6 +1754,22 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
987
1754
|
return this;
|
|
988
1755
|
}
|
|
989
1756
|
|
|
1757
|
+
/** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
|
|
1758
|
+
andWhereHas(
|
|
1759
|
+
relationName: string,
|
|
1760
|
+
callback?: (query: ModelQuery<BaseEntity>) => void,
|
|
1761
|
+
): this {
|
|
1762
|
+
return this.whereHas(relationName, callback);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/** Alias of {@link whereDoesntHave} (Lucid parity). */
|
|
1766
|
+
andWhereDoesntHave(
|
|
1767
|
+
relationName: string,
|
|
1768
|
+
callback?: (query: ModelQuery<BaseEntity>) => void,
|
|
1769
|
+
): this {
|
|
1770
|
+
return this.whereDoesntHave(relationName, callback);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
990
1773
|
/**
|
|
991
1774
|
* Short form of `whereHas`. With an operator + count, emits a count threshold:
|
|
992
1775
|
* has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
|
|
@@ -1026,6 +1809,26 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1026
1809
|
return this;
|
|
1027
1810
|
}
|
|
1028
1811
|
|
|
1812
|
+
/** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
|
|
1813
|
+
orDoesntHave(relationName: string): this {
|
|
1814
|
+
this.#wheres.push(this.#buildExistsClause("or", true, relationName));
|
|
1815
|
+
return this;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** Alias of {@link has} (Lucid parity) — `has` is already AND. */
|
|
1819
|
+
andHas(
|
|
1820
|
+
relationName: string,
|
|
1821
|
+
countOp?: string,
|
|
1822
|
+
countThreshold?: number,
|
|
1823
|
+
): this {
|
|
1824
|
+
return this.has(relationName, countOp, countThreshold);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Alias of {@link doesntHave} (Lucid parity). */
|
|
1828
|
+
andDoesntHave(relationName: string): this {
|
|
1829
|
+
return this.doesntHave(relationName);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1029
1832
|
/**
|
|
1030
1833
|
* Set this query's projection alias — only meaningful when this ModelQuery
|
|
1031
1834
|
* is used as the sub-builder callback argument of `withCount` / `withAggregate`.
|
|
@@ -1070,27 +1873,37 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1070
1873
|
/** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
|
|
1071
1874
|
async count(column: string = "*"): Promise<number> {
|
|
1072
1875
|
const expr =
|
|
1073
|
-
column === "*"
|
|
1876
|
+
column === "*"
|
|
1877
|
+
? "COUNT(*)"
|
|
1878
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
|
|
1074
1879
|
return Number((await this.#runScalar(expr)) ?? 0);
|
|
1075
1880
|
}
|
|
1076
1881
|
|
|
1077
1882
|
async sum(column: string): Promise<number | null> {
|
|
1078
|
-
const v = await this.#runScalar(
|
|
1883
|
+
const v = await this.#runScalar(
|
|
1884
|
+
`SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1885
|
+
);
|
|
1079
1886
|
return v === null || v === undefined ? null : Number(v);
|
|
1080
1887
|
}
|
|
1081
1888
|
|
|
1082
1889
|
async avg(column: string): Promise<number | null> {
|
|
1083
|
-
const v = await this.#runScalar(
|
|
1890
|
+
const v = await this.#runScalar(
|
|
1891
|
+
`AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1892
|
+
);
|
|
1084
1893
|
return v === null || v === undefined ? null : Number(v);
|
|
1085
1894
|
}
|
|
1086
1895
|
|
|
1087
1896
|
async min(column: string): Promise<number | null> {
|
|
1088
|
-
const v = await this.#runScalar(
|
|
1897
|
+
const v = await this.#runScalar(
|
|
1898
|
+
`MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1899
|
+
);
|
|
1089
1900
|
return v === null || v === undefined ? null : Number(v);
|
|
1090
1901
|
}
|
|
1091
1902
|
|
|
1092
1903
|
async max(column: string): Promise<number | null> {
|
|
1093
|
-
const v = await this.#runScalar(
|
|
1904
|
+
const v = await this.#runScalar(
|
|
1905
|
+
`MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1906
|
+
);
|
|
1094
1907
|
return v === null || v === undefined ? null : Number(v);
|
|
1095
1908
|
}
|
|
1096
1909
|
|
|
@@ -1145,32 +1958,93 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1145
1958
|
return this;
|
|
1146
1959
|
}
|
|
1147
1960
|
|
|
1961
|
+
/**
|
|
1962
|
+
* `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
|
|
1963
|
+
* no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
|
|
1964
|
+
* alias.
|
|
1965
|
+
*
|
|
1966
|
+
* query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
|
|
1967
|
+
*
|
|
1968
|
+
* The fragment keeps its position among the plain `orderBy` terms.
|
|
1969
|
+
*
|
|
1970
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
1971
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
1972
|
+
*
|
|
1973
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1974
|
+
*/
|
|
1975
|
+
orderByRaw(sql: string): this {
|
|
1976
|
+
this.#assertRawAllowed("orderByRaw");
|
|
1977
|
+
this.#orderBys.push({ raw: sql });
|
|
1978
|
+
return this;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1148
1981
|
/**
|
|
1149
1982
|
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
1150
1983
|
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
1151
|
-
* For a
|
|
1152
|
-
* fluent {@link QueryBuilder}.
|
|
1984
|
+
* For a grouping expression with no typed form, see {@link groupByRaw}.
|
|
1153
1985
|
*/
|
|
1154
1986
|
groupBy(...columns: string[]): this {
|
|
1155
1987
|
for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
|
|
1156
1988
|
return this;
|
|
1157
1989
|
}
|
|
1158
1990
|
|
|
1991
|
+
/**
|
|
1992
|
+
* `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
|
|
1993
|
+
* no typed form, e.g. `DATE_TRUNC('day', created_at)`.
|
|
1994
|
+
*
|
|
1995
|
+
* The fragment keeps its position among the plain `groupBy` terms.
|
|
1996
|
+
*
|
|
1997
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
1998
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
1999
|
+
*
|
|
2000
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
2001
|
+
*/
|
|
2002
|
+
groupByRaw(sql: string): this {
|
|
2003
|
+
this.#assertRawAllowed("groupByRaw");
|
|
2004
|
+
this.#groupBy.push({ raw: sql });
|
|
2005
|
+
return this;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
1159
2008
|
/**
|
|
1160
2009
|
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
* (`COUNT(*)`, `SUM(col)`, …)
|
|
1164
|
-
*
|
|
2010
|
+
* A bare model property is resolved through the entity column map (honouring
|
|
2011
|
+
* `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
|
|
2012
|
+
* expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
|
|
2013
|
+
* `having` can still reference `withCount`/`withAggregate` aliases.
|
|
1165
2014
|
*/
|
|
1166
2015
|
having(column: string, operator: string, value: unknown): this {
|
|
1167
|
-
this.#having.push({
|
|
2016
|
+
this.#having.push({
|
|
2017
|
+
column: this.#resolveHavingCol(column),
|
|
2018
|
+
operator,
|
|
2019
|
+
value: this.#prep(column, value),
|
|
2020
|
+
type: "and",
|
|
2021
|
+
});
|
|
1168
2022
|
return this;
|
|
1169
2023
|
}
|
|
1170
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* Resolve a HAVING column: a bare model property maps to its DB column
|
|
2027
|
+
* (honouring `@Column({ columnName })`), but an aggregate expression
|
|
2028
|
+
* (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
|
|
2029
|
+
* so `having` can still reference `withCount`/`withAggregate` aliases.
|
|
2030
|
+
*/
|
|
2031
|
+
#resolveHavingCol(column: string): string {
|
|
2032
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) return column;
|
|
2033
|
+
try {
|
|
2034
|
+
return this.#resolveColumn(column);
|
|
2035
|
+
} catch {
|
|
2036
|
+
return column;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
1171
2040
|
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
1172
2041
|
orHaving(column: string, operator: string, value: unknown): this {
|
|
1173
|
-
this.#having.push({
|
|
2042
|
+
this.#having.push({
|
|
2043
|
+
column: this.#resolveHavingCol(column),
|
|
2044
|
+
operator,
|
|
2045
|
+
value: this.#prep(column, value),
|
|
2046
|
+
type: "or",
|
|
2047
|
+
});
|
|
1174
2048
|
return this;
|
|
1175
2049
|
}
|
|
1176
2050
|
|
|
@@ -1183,6 +2057,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1183
2057
|
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1184
2058
|
*/
|
|
1185
2059
|
havingRaw(sql: string, bindings: readonly unknown[] = []): this {
|
|
2060
|
+
// Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
|
|
2061
|
+
// surface, so prod hardening must be able to neutralise it too.
|
|
2062
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
2063
|
+
throw new Error(
|
|
2064
|
+
"havingRaw() is disabled in Atlas strict mode. " +
|
|
2065
|
+
"Use having(column, operator, value) instead.",
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
1186
2068
|
this.#having.push({
|
|
1187
2069
|
kind: "raw",
|
|
1188
2070
|
sql,
|
|
@@ -1208,16 +2090,92 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1208
2090
|
return this;
|
|
1209
2091
|
}
|
|
1210
2092
|
|
|
2093
|
+
/** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
|
|
2094
|
+
intersect(query: ModelQuery<BaseEntity>): this {
|
|
2095
|
+
this.#unions.push({ query, all: false, op: "intersect" });
|
|
2096
|
+
return this;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
|
|
2101
|
+
*
|
|
2102
|
+
* Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
|
|
2103
|
+
* INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
|
|
2104
|
+
* `E_UNSUPPORTED` there rather than emitting a syntax error.
|
|
2105
|
+
*/
|
|
2106
|
+
intersectAll(query: ModelQuery<BaseEntity>): this {
|
|
2107
|
+
this.#unions.push({ query, all: true, op: "intersect" });
|
|
2108
|
+
return this;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
|
|
2112
|
+
except(query: ModelQuery<BaseEntity>): this {
|
|
2113
|
+
this.#unions.push({ query, all: false, op: "except" });
|
|
2114
|
+
return this;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
|
|
2118
|
+
exceptAll(query: ModelQuery<BaseEntity>): this {
|
|
2119
|
+
this.#unions.push({ query, all: true, op: "except" });
|
|
2120
|
+
return this;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1211
2123
|
/**
|
|
1212
2124
|
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
1213
2125
|
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
1214
2126
|
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
1215
2127
|
*/
|
|
1216
2128
|
with(name: string, query: ModelQuery<BaseEntity>): this {
|
|
2129
|
+
return this.#pushCte("with", name, query, {});
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
|
|
2134
|
+
* (Lucid/Knex `withRecursive`), for trees and graph walks.
|
|
2135
|
+
*
|
|
2136
|
+
* RECURSIVE is a property of the WITH clause rather than of one CTE, so a
|
|
2137
|
+
* single recursive entry makes the whole clause recursive — which is what
|
|
2138
|
+
* all three dialects require. Mixing `with()` and `withRecursive()` is fine.
|
|
2139
|
+
*
|
|
2140
|
+
* The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
|
|
2141
|
+
* an anchor `SELECT` unioned with a select that references `<name>`.
|
|
2142
|
+
*/
|
|
2143
|
+
withRecursive(name: string, query: ModelQuery<BaseEntity>): this {
|
|
2144
|
+
return this.#pushCte("withRecursive", name, query, { recursive: true });
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
|
|
2149
|
+
* once and stashed (Lucid/Knex `withMaterialized`).
|
|
2150
|
+
*
|
|
2151
|
+
* Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
|
|
2152
|
+
* compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
|
|
2153
|
+
*/
|
|
2154
|
+
withMaterialized(name: string, query: ModelQuery<BaseEntity>): this {
|
|
2155
|
+
return this.#pushCte("withMaterialized", name, query, {
|
|
2156
|
+
materialized: true,
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
|
|
2161
|
+
withNotMaterialized(name: string, query: ModelQuery<BaseEntity>): this {
|
|
2162
|
+
return this.#pushCte("withNotMaterialized", name, query, {
|
|
2163
|
+
materialized: false,
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
#pushCte(
|
|
2168
|
+
method: string,
|
|
2169
|
+
name: string,
|
|
2170
|
+
query: ModelQuery<BaseEntity>,
|
|
2171
|
+
options: { recursive?: boolean; materialized?: boolean },
|
|
2172
|
+
): this {
|
|
1217
2173
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1218
|
-
throw new Error(
|
|
2174
|
+
throw new Error(
|
|
2175
|
+
`${method}(): CTE name '${name}' is not a valid identifier`,
|
|
2176
|
+
);
|
|
1219
2177
|
}
|
|
1220
|
-
this.#ctes.push({ name, query });
|
|
2178
|
+
this.#ctes.push({ name, query, ...options });
|
|
1221
2179
|
return this;
|
|
1222
2180
|
}
|
|
1223
2181
|
|
|
@@ -1232,21 +2190,136 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1232
2190
|
wherePivot(column: string, value: unknown): this;
|
|
1233
2191
|
wherePivot(column: string, operator: string, value: unknown): this;
|
|
1234
2192
|
wherePivot(column: string, operatorOrValue: unknown, value?: unknown): this {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
2193
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/** Alias of {@link wherePivot} (Lucid parity) — pivot filters already AND together. */
|
|
2197
|
+
andWherePivot(column: string, value: unknown): this;
|
|
2198
|
+
andWherePivot(column: string, operator: string, value: unknown): this;
|
|
2199
|
+
andWherePivot(
|
|
2200
|
+
column: string,
|
|
2201
|
+
operatorOrValue: unknown,
|
|
2202
|
+
value?: unknown,
|
|
2203
|
+
): this {
|
|
2204
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* `@ManyToMany` only — OR form of {@link wherePivot} (Lucid parity).
|
|
2209
|
+
*
|
|
2210
|
+
* The pivot filters are compiled as a parenthesised group, so an OR joins
|
|
2211
|
+
* the other pivot filters and cannot escape the `pivot_fk IN (parents)`
|
|
2212
|
+
* scoping that makes the preload correct.
|
|
2213
|
+
*/
|
|
2214
|
+
orWherePivot(column: string, value: unknown): this;
|
|
2215
|
+
orWherePivot(column: string, operator: string, value: unknown): this;
|
|
2216
|
+
orWherePivot(
|
|
2217
|
+
column: string,
|
|
2218
|
+
operatorOrValue: unknown,
|
|
2219
|
+
value?: unknown,
|
|
2220
|
+
): this {
|
|
2221
|
+
return this.#pushPivot("or", column, operatorOrValue, value);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
|
|
2225
|
+
whereInPivot(column: string, values: readonly unknown[]): this {
|
|
2226
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/** Alias of {@link whereInPivot} (Lucid parity). */
|
|
2230
|
+
andWhereInPivot(column: string, values: readonly unknown[]): this {
|
|
2231
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
|
|
2235
|
+
orWhereInPivot(column: string, values: readonly unknown[]): this {
|
|
2236
|
+
return this.#pushPivotOp("or", column, "IN", [...values]);
|
|
1245
2237
|
}
|
|
1246
2238
|
|
|
1247
|
-
/**
|
|
2239
|
+
/** Alias of {@link whereInPivot} kept for the earlier atlas name. */
|
|
1248
2240
|
wherePivotIn(column: string, values: readonly unknown[]): this {
|
|
1249
|
-
this
|
|
2241
|
+
return this.whereInPivot(column, values);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> != <value>` on the pivot table (AdonisJS Lucid `whereNotPivot`). */
|
|
2245
|
+
whereNotPivot(column: string, value: unknown): this {
|
|
2246
|
+
return this.#pushPivotOp("and", column, "!=", value);
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/** Alias of {@link whereNotPivot} (Lucid parity). */
|
|
2250
|
+
andWhereNotPivot(column: string, value: unknown): this {
|
|
2251
|
+
return this.#pushPivotOp("and", column, "!=", value);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
|
|
2255
|
+
orWhereNotPivot(column: string, value: unknown): this {
|
|
2256
|
+
return this.#pushPivotOp("or", column, "!=", value);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
|
|
2260
|
+
whereNotInPivot(column: string, values: readonly unknown[]): this {
|
|
2261
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/** Alias of {@link whereNotInPivot} (Lucid parity). */
|
|
2265
|
+
andWhereNotInPivot(column: string, values: readonly unknown[]): this {
|
|
2266
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
|
|
2270
|
+
orWhereNotInPivot(column: string, values: readonly unknown[]): this {
|
|
2271
|
+
return this.#pushPivotOp("or", column, "NOT IN", [...values]);
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
|
|
2275
|
+
whereNullPivot(column: string): this {
|
|
2276
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/** Alias of {@link whereNullPivot} (Lucid parity). */
|
|
2280
|
+
andWhereNullPivot(column: string): this {
|
|
2281
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
|
|
2285
|
+
orWhereNullPivot(column: string): this {
|
|
2286
|
+
return this.#pushPivotOp("or", column, "IS NULL", null);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
|
|
2290
|
+
whereNotNullPivot(column: string): this {
|
|
2291
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/** Alias of {@link whereNotNullPivot} (Lucid parity). */
|
|
2295
|
+
andWhereNotNullPivot(column: string): this {
|
|
2296
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
|
|
2300
|
+
orWhereNotNullPivot(column: string): this {
|
|
2301
|
+
return this.#pushPivotOp("or", column, "IS NOT NULL", null);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
|
|
2305
|
+
#pushPivot(
|
|
2306
|
+
type: "and" | "or",
|
|
2307
|
+
column: string,
|
|
2308
|
+
operatorOrValue: unknown,
|
|
2309
|
+
value?: unknown,
|
|
2310
|
+
): this {
|
|
2311
|
+
return value === undefined
|
|
2312
|
+
? this.#pushPivotOp(type, column, "=", operatorOrValue)
|
|
2313
|
+
: this.#pushPivotOp(type, column, operatorOrValue as string, value);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
#pushPivotOp(
|
|
2317
|
+
type: "and" | "or",
|
|
2318
|
+
column: string,
|
|
2319
|
+
operator: string,
|
|
2320
|
+
value: unknown,
|
|
2321
|
+
): this {
|
|
2322
|
+
this.#pivotWheres.push({ column, operator, value, type });
|
|
1250
2323
|
return this;
|
|
1251
2324
|
}
|
|
1252
2325
|
|
|
@@ -1255,6 +2328,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1255
2328
|
column: string;
|
|
1256
2329
|
operator: string;
|
|
1257
2330
|
value: unknown;
|
|
2331
|
+
type: "and" | "or";
|
|
1258
2332
|
}> {
|
|
1259
2333
|
return this.#pivotWheres;
|
|
1260
2334
|
}
|
|
@@ -1341,21 +2415,99 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1341
2415
|
}
|
|
1342
2416
|
|
|
1343
2417
|
/** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
|
|
2418
|
+
/**
|
|
2419
|
+
* DB column backing the soft-delete `deletedAt` property — honours a
|
|
2420
|
+
* `@Column({ columnName })` override, read straight from the entity metadata
|
|
2421
|
+
* (not the resolver callback, which is identity for subqueries/preloads).
|
|
2422
|
+
*/
|
|
2423
|
+
#deletedAtColumn(): string {
|
|
2424
|
+
const col = this.#entityClass
|
|
2425
|
+
? getColumnMetadata(this.#entityClass).find(
|
|
2426
|
+
(c) => c.propertyKey === "deletedAt",
|
|
2427
|
+
)
|
|
2428
|
+
: undefined;
|
|
2429
|
+
return col?.columnName ?? "deleted_at";
|
|
2430
|
+
}
|
|
2431
|
+
|
|
1344
2432
|
#buildSpec(): SelectSpec {
|
|
2433
|
+
// `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
|
|
2434
|
+
// compiler emits the lock clause only when a base mode is set, so a lone
|
|
2435
|
+
// modifier would be a SILENT no-op (dangerous for job-queue polling that
|
|
2436
|
+
// believes it skips locked rows). Fail loud instead. Order-independent: this
|
|
2437
|
+
// fires whether the modifier was chained before or after the base lock.
|
|
2438
|
+
if (this.#lockModifier && !this.#lockMode) {
|
|
2439
|
+
throw new Error(
|
|
2440
|
+
`${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`,
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
// With a JOIN and the default `SELECT *`, scope the projection to the base
|
|
2444
|
+
// table's declared columns so joined columns can't clobber the model's fields
|
|
2445
|
+
// (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
|
|
2446
|
+
// AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
|
|
2447
|
+
let selectCols = this.#select;
|
|
2448
|
+
if (
|
|
2449
|
+
this.#joins.length > 0 &&
|
|
2450
|
+
this.#select.length === 1 &&
|
|
2451
|
+
this.#select[0] === "*"
|
|
2452
|
+
) {
|
|
2453
|
+
const cols = getColumnMetadata(this.#entityClass).map(
|
|
2454
|
+
(c) =>
|
|
2455
|
+
`${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`,
|
|
2456
|
+
);
|
|
2457
|
+
if (cols.length > 0) selectCols = cols;
|
|
2458
|
+
} else if (
|
|
2459
|
+
!(selectCols.length === 1 && selectCols[0] === "*") &&
|
|
2460
|
+
selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))
|
|
2461
|
+
) {
|
|
2462
|
+
// A partial `select()` of PLAIN columns that omits the primary key would
|
|
2463
|
+
// hydrate a persisted entity with no PK — a later save() would then INSERT
|
|
2464
|
+
// instead of UPDATE (double-write / unique violation / spurious
|
|
2465
|
+
// beforeCreate). Auto-include the (base-table-qualified) PK so model
|
|
2466
|
+
// entities stay saveable. Aggregate/alias/expression selects are left
|
|
2467
|
+
// untouched — use `.pojo()` for those.
|
|
2468
|
+
const pkProp = getPrimaryKey(this.#entityClass);
|
|
2469
|
+
if (pkProp) {
|
|
2470
|
+
const pkCol =
|
|
2471
|
+
getColumnMetadata(this.#entityClass).find(
|
|
2472
|
+
(c) => c.propertyKey === pkProp,
|
|
2473
|
+
)?.columnName ?? camelToSnake(pkProp);
|
|
2474
|
+
// The PK counts as present ONLY as the bare column or the BASE-table-
|
|
2475
|
+
// qualified column. A joined `other.id` must NOT satisfy it (its leaf
|
|
2476
|
+
// collides with the PK name but it's a different table's row) — otherwise
|
|
2477
|
+
// we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
|
|
2478
|
+
// save(). Appended last, `base.id` also wins the duplicate result key
|
|
2479
|
+
// (rows collect in column order, last-wins) so the base row's PK hydrates.
|
|
2480
|
+
const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
|
|
2481
|
+
if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
|
|
2482
|
+
selectCols = [...selectCols, baseQualifiedPk];
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
1345
2486
|
const wheres: WhereClause[] = [...this.#wheres];
|
|
1346
|
-
//
|
|
2487
|
+
// Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
|
|
2488
|
+
// `.wherePivot()` recorded since the proxy handed back this query (pushed to
|
|
2489
|
+
// the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
|
|
2490
|
+
if (this.#pivotExists) {
|
|
2491
|
+
const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
|
|
2492
|
+
wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
|
|
2493
|
+
}
|
|
2494
|
+
// Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
|
|
2495
|
+
// Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
|
|
2496
|
+
// override on the soft-delete column is honoured on the read side too — matching
|
|
2497
|
+
// the write side (delete/restore go through #dbColumn).
|
|
1347
2498
|
if (this.#softDeletes) {
|
|
2499
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
1348
2500
|
if (this.#softScope === "default") {
|
|
1349
2501
|
wheres.push({
|
|
1350
2502
|
type: "and",
|
|
1351
|
-
column:
|
|
2503
|
+
column: deletedAtCol,
|
|
1352
2504
|
operator: "IS NULL",
|
|
1353
2505
|
value: null,
|
|
1354
2506
|
});
|
|
1355
2507
|
} else if (this.#softScope === "only-trashed") {
|
|
1356
2508
|
wheres.push({
|
|
1357
2509
|
type: "and",
|
|
1358
|
-
column:
|
|
2510
|
+
column: deletedAtCol,
|
|
1359
2511
|
operator: "IS NOT NULL",
|
|
1360
2512
|
value: null,
|
|
1361
2513
|
});
|
|
@@ -1366,7 +2518,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1366
2518
|
return {
|
|
1367
2519
|
kind: "select",
|
|
1368
2520
|
table: this.#tableName,
|
|
1369
|
-
select:
|
|
2521
|
+
select: selectCols,
|
|
1370
2522
|
selectSubqueries: this.#selectSubqueries,
|
|
1371
2523
|
wheres,
|
|
1372
2524
|
orderBy: this.#orderBys,
|
|
@@ -1375,16 +2527,27 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1375
2527
|
limit: this.#limit ?? null,
|
|
1376
2528
|
offset: this.#offset ?? null,
|
|
1377
2529
|
distinct: this.#distinct,
|
|
2530
|
+
distinctOn: this.#distinctOn,
|
|
1378
2531
|
ctes: this.#ctes.map((c) => {
|
|
1379
2532
|
const { sql, params } = c.query.toSQL();
|
|
1380
|
-
return {
|
|
2533
|
+
return {
|
|
2534
|
+
name: c.name,
|
|
2535
|
+
sql,
|
|
2536
|
+
params,
|
|
2537
|
+
recursive: c.recursive ?? false,
|
|
2538
|
+
materialized: c.materialized ?? null,
|
|
2539
|
+
};
|
|
1381
2540
|
}),
|
|
1382
2541
|
unions: this.#unions.map((u) => {
|
|
1383
2542
|
const { sql, params } = u.query.toSQL();
|
|
1384
|
-
return { sql, params, all: u.all };
|
|
2543
|
+
return { sql, params, all: u.all, op: u.op ?? null };
|
|
1385
2544
|
}),
|
|
1386
2545
|
joins: this.#joins,
|
|
1387
|
-
lockMode: this.#lockMode
|
|
2546
|
+
lockMode: this.#lockMode
|
|
2547
|
+
? this.#lockModifier
|
|
2548
|
+
? `${this.#lockMode} ${this.#lockModifier}`
|
|
2549
|
+
: this.#lockMode
|
|
2550
|
+
: null,
|
|
1388
2551
|
};
|
|
1389
2552
|
}
|
|
1390
2553
|
|
|
@@ -1420,7 +2583,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1420
2583
|
|
|
1421
2584
|
async #doExec(): Promise<T[]> {
|
|
1422
2585
|
const { sql, params } = this.toSQL();
|
|
1423
|
-
const rawRows = await this.#db.query<Record<string, unknown>>(
|
|
2586
|
+
const rawRows = await this.#db.query<Record<string, unknown>>(
|
|
2587
|
+
sql,
|
|
2588
|
+
params,
|
|
2589
|
+
this.#meta("exec"),
|
|
2590
|
+
);
|
|
1424
2591
|
// Peel withCount / withAggregate alias columns off the raw row into $extras
|
|
1425
2592
|
// BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
|
|
1426
2593
|
const extraKeys = this.#selectSubqueries.map((s) => s.alias);
|
|
@@ -1434,6 +2601,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1434
2601
|
}
|
|
1435
2602
|
const entity = this.#hydrateFn(row);
|
|
1436
2603
|
for (const [k, v] of Object.entries(picked)) entity.setExtra(k, v);
|
|
2604
|
+
// Thread query-level sideloaded context onto each hydrated instance.
|
|
2605
|
+
if (this.#sideloaded) entity.$sideloaded = { ...this.#sideloaded };
|
|
1437
2606
|
return entity;
|
|
1438
2607
|
});
|
|
1439
2608
|
|
|
@@ -1445,6 +2614,27 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1445
2614
|
return entities;
|
|
1446
2615
|
}
|
|
1447
2616
|
|
|
2617
|
+
/**
|
|
2618
|
+
* Execute and return PLAIN row objects (raw snake_case DB columns), skipping
|
|
2619
|
+
* model hydration, `@column({ consume })`, dirty-tracking and preloads —
|
|
2620
|
+
* AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
|
|
2621
|
+
* instances aren't needed.
|
|
2622
|
+
*/
|
|
2623
|
+
async pojo<R = Record<string, unknown>>(): Promise<R[]> {
|
|
2624
|
+
const { sql, params } = this.toSQL();
|
|
2625
|
+
return this.#db.query<R>(sql, params);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/**
|
|
2629
|
+
* Thread arbitrary context onto every instance this query hydrates, exposed as
|
|
2630
|
+
* `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
|
|
2631
|
+
* user, so hooks/computed can read it. Merges across calls. Chainable.
|
|
2632
|
+
*/
|
|
2633
|
+
sideload(values: Record<string, unknown>): this {
|
|
2634
|
+
this.#sideloaded = { ...this.#sideloaded, ...values };
|
|
2635
|
+
return this;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
1448
2638
|
/** Resolve preloaded relations via batched subqueries (no N+1). */
|
|
1449
2639
|
async #resolvePreloads(entities: T[]): Promise<void> {
|
|
1450
2640
|
if (!this.#entityClass) return;
|
|
@@ -1473,8 +2663,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1473
2663
|
relationName: string,
|
|
1474
2664
|
): PreloadContext | null {
|
|
1475
2665
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1476
|
-
|
|
1477
|
-
|
|
2666
|
+
// Boot the related model's metadata on demand (Lucid parity): a preload
|
|
2667
|
+
// must not silently no-op just because the related class hasn't been
|
|
2668
|
+
// touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
|
|
2669
|
+
// static table / naming strategy when the decorator hasn't run.
|
|
2670
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1478
2671
|
|
|
1479
2672
|
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1480
2673
|
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
@@ -1482,24 +2675,79 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1482
2675
|
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1483
2676
|
const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
|
|
1484
2677
|
const validColumns = new Set<string>();
|
|
2678
|
+
// Reverse map (db column → property) so an explicit `@Column({ columnName })`
|
|
2679
|
+
// on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
|
|
2680
|
+
const byDbName = new Map<string, string>();
|
|
2681
|
+
// Capture the related model's `@Column({ consume })` adapters + its date
|
|
2682
|
+
// columns so preloaded rows hydrate identically to a direct query — dates
|
|
2683
|
+
// become Chronos DateTime, decimal/etc adapters run. Without this, a
|
|
2684
|
+
// preloaded relation left column values raw (Lucid parity bug + a runtime
|
|
2685
|
+
// footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
|
|
2686
|
+
const consumes = new Map<
|
|
2687
|
+
string,
|
|
2688
|
+
(v: unknown, attribute?: string, model?: unknown) => unknown
|
|
2689
|
+
>();
|
|
2690
|
+
let relatedPkDb = camelToSnake(relatedPkName);
|
|
1485
2691
|
for (const col of getColumnMetadata(relatedClass)) {
|
|
2692
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
1486
2693
|
validColumns.add(col.propertyKey);
|
|
1487
|
-
validColumns.add(
|
|
2694
|
+
validColumns.add(db);
|
|
2695
|
+
byDbName.set(db, col.propertyKey);
|
|
2696
|
+
if (col.consume) consumes.set(col.propertyKey, col.consume);
|
|
2697
|
+
// The related PK may be multi-word (postId→post_id) or columnName-mapped;
|
|
2698
|
+
// its DB column name is what the WHERE + row indexing must use.
|
|
2699
|
+
if (col.propertyKey === relatedPkName) relatedPkDb = db;
|
|
1488
2700
|
}
|
|
1489
2701
|
validColumns.add(relatedPkName);
|
|
1490
2702
|
validColumns.add(camelToSnake(relatedPkName));
|
|
2703
|
+
const dateCols = getDateColumnConfig(relatedClass);
|
|
2704
|
+
const consumeValue = (
|
|
2705
|
+
prop: string,
|
|
2706
|
+
value: unknown,
|
|
2707
|
+
model?: unknown,
|
|
2708
|
+
): unknown => {
|
|
2709
|
+
const c = consumes.get(prop);
|
|
2710
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
2711
|
+
if (c) return c(value, prop, model);
|
|
2712
|
+
if (dateCols[prop] && value != null)
|
|
2713
|
+
return dateTimeAtlasAdapter.consume(value);
|
|
2714
|
+
return value;
|
|
2715
|
+
};
|
|
1491
2716
|
|
|
2717
|
+
// A repository for the related model so preloaded instances are hydrated with
|
|
2718
|
+
// the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
|
|
2719
|
+
// not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
|
|
2720
|
+
// refresh()/fresh()/load()/related(). Without this a preloaded relation
|
|
2721
|
+
// looked $isNew/$isLocal/$dirty and a later save() over-updated it.
|
|
2722
|
+
const relatedRepo = new BaseRepository(relatedClass, this.#db, {
|
|
2723
|
+
dialect: this.#dialect,
|
|
2724
|
+
});
|
|
2725
|
+
// Propagate the domain-event bus so save()/create() from a preloaded relation
|
|
2726
|
+
// still dispatch events (a fresh repo has none by default).
|
|
2727
|
+
relatedRepo.onDomainEvents = this.#onDomainEvents;
|
|
1492
2728
|
const hydrate = (row: Record<string, unknown>): BaseEntity => {
|
|
1493
2729
|
const entity = new relatedClass();
|
|
1494
2730
|
for (const [key, value] of Object.entries(row)) {
|
|
1495
2731
|
const camelKey = snakeToCamel(key);
|
|
1496
|
-
const targetKey =
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
?
|
|
1500
|
-
:
|
|
1501
|
-
|
|
2732
|
+
const targetKey =
|
|
2733
|
+
byDbName.get(key) ??
|
|
2734
|
+
(validColumns.has(camelKey)
|
|
2735
|
+
? camelKey
|
|
2736
|
+
: validColumns.has(key)
|
|
2737
|
+
? key
|
|
2738
|
+
: null);
|
|
2739
|
+
if (targetKey !== null)
|
|
2740
|
+
entity.setProp(targetKey, consumeValue(targetKey, value, entity));
|
|
1502
2741
|
}
|
|
2742
|
+
// Freeze the clean snapshot + mark persisted/from-DB, and back-reference
|
|
2743
|
+
// the related repo (mirrors BaseRepository.#hydrate).
|
|
2744
|
+
entity.markAsPersisted();
|
|
2745
|
+
entity.markAsFromDatabase();
|
|
2746
|
+
Object.defineProperty(entity, REPO_REF, {
|
|
2747
|
+
value: relatedRepo,
|
|
2748
|
+
enumerable: false,
|
|
2749
|
+
configurable: true,
|
|
2750
|
+
});
|
|
1503
2751
|
return entity;
|
|
1504
2752
|
};
|
|
1505
2753
|
|
|
@@ -1508,7 +2756,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1508
2756
|
relationName,
|
|
1509
2757
|
relatedClass,
|
|
1510
2758
|
relatedTable: relatedMeta.tableName,
|
|
1511
|
-
|
|
2759
|
+
// DB column name (not property) — used as the WHERE column in the related
|
|
2760
|
+
// query AND to index the returned DB rows by their PK value.
|
|
2761
|
+
relatedPk: relatedPkDb,
|
|
1512
2762
|
hydrate,
|
|
1513
2763
|
runInQuery: (table, column, values) =>
|
|
1514
2764
|
this.#runInQuery(table, column, values),
|
|
@@ -1570,11 +2820,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1570
2820
|
);
|
|
1571
2821
|
}
|
|
1572
2822
|
const throughClass = relation.through() as new () => BaseEntity;
|
|
1573
|
-
const throughMeta =
|
|
1574
|
-
if (!throughMeta)
|
|
1575
|
-
throw new Error(
|
|
1576
|
-
`Entity metadata missing on through class ${throughClass.name}`,
|
|
1577
|
-
);
|
|
2823
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1578
2824
|
const throughTable = throughMeta.tableName;
|
|
1579
2825
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1580
2826
|
const parentLocal =
|
|
@@ -1583,7 +2829,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1583
2829
|
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1584
2830
|
const secondKey =
|
|
1585
2831
|
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1586
|
-
|
|
2832
|
+
// secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
|
|
2833
|
+
// DB column — resolve the through model's key (default: its PK), honouring a
|
|
2834
|
+
// multi-word / columnName PK. (parentLocal stays a property: it's read off
|
|
2835
|
+
// the parent ENTITY, not a row.)
|
|
2836
|
+
const secondLocal = buildColumnResolver(throughClass)(
|
|
2837
|
+
relation.secondLocalKey ?? throughPk,
|
|
2838
|
+
);
|
|
1587
2839
|
|
|
1588
2840
|
const parentIds = entities
|
|
1589
2841
|
.map((e) => e[parentLocal])
|
|
@@ -1752,7 +3004,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1752
3004
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1753
3005
|
const otherKey =
|
|
1754
3006
|
pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1755
|
-
|
|
3007
|
+
// The pivot FK stores `parent[localKey]` (default PK) — attach() writes it,
|
|
3008
|
+
// so preload MUST read back with the SAME key, else a custom-localKey m2m
|
|
3009
|
+
// writes `user_code = code` but reads `user_code IN (id)` and never matches.
|
|
3010
|
+
const pk =
|
|
3011
|
+
ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
1756
3012
|
|
|
1757
3013
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
1758
3014
|
if (ids.length === 0) return [];
|
|
@@ -1772,12 +3028,39 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1772
3028
|
this.#db,
|
|
1773
3029
|
(r) => r as BaseEntity,
|
|
1774
3030
|
ctx.relatedClass,
|
|
1775
|
-
(
|
|
3031
|
+
buildColumnResolver(ctx.relatedClass),
|
|
1776
3032
|
false,
|
|
1777
3033
|
this.#dialect,
|
|
3034
|
+
buildValuePreparer(ctx.relatedClass),
|
|
1778
3035
|
);
|
|
1779
3036
|
ctx.nestedCallback(scratch);
|
|
1780
|
-
|
|
3037
|
+
// Apply the pivot column adapters' `prepare` to wherePivot values, so a
|
|
3038
|
+
// filter like wherePivot('amount', new Money(1)) matches what attach()/
|
|
3039
|
+
// sync() stored (they prepare the same extras on write).
|
|
3040
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
3041
|
+
for (const c of scratch.pivotConstraints) {
|
|
3042
|
+
const prep = pivotAdapters[c.column]?.prepare;
|
|
3043
|
+
// Same guards as the attach()/sync() write path: wrap a throwing
|
|
3044
|
+
// adapter with a column-annotated error and reject async adapters,
|
|
3045
|
+
// so filter and write agree on the adapter contract.
|
|
3046
|
+
const apply = (v: unknown): unknown => {
|
|
3047
|
+
if (!prep) return v;
|
|
3048
|
+
let out: unknown;
|
|
3049
|
+
try {
|
|
3050
|
+
// Adonis Lucid signature: (value, attribute, model). wherePivot is
|
|
3051
|
+
// a query filter — attribute known, no model instance.
|
|
3052
|
+
out = prep(v, c.column, undefined);
|
|
3053
|
+
} catch (err) {
|
|
3054
|
+
throw wrapAdapterError("prepare", c.column, err);
|
|
3055
|
+
}
|
|
3056
|
+
assertNotPromise("prepare", c.column, out);
|
|
3057
|
+
return out;
|
|
3058
|
+
};
|
|
3059
|
+
const value = Array.isArray(c.value)
|
|
3060
|
+
? c.value.map(apply)
|
|
3061
|
+
: apply(c.value);
|
|
3062
|
+
pivotWheres.push({ ...c, value });
|
|
3063
|
+
}
|
|
1781
3064
|
}
|
|
1782
3065
|
|
|
1783
3066
|
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
@@ -1830,7 +3113,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1830
3113
|
const adapter = pivotAdapters[col];
|
|
1831
3114
|
related.setExtra(
|
|
1832
3115
|
`pivot_${col}`,
|
|
1833
|
-
|
|
3116
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
3117
|
+
adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal,
|
|
1834
3118
|
);
|
|
1835
3119
|
}
|
|
1836
3120
|
allRelated.push(related);
|
|
@@ -1860,6 +3144,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1860
3144
|
this.#db,
|
|
1861
3145
|
(r) => ctx.hydrate(r),
|
|
1862
3146
|
ctx.relatedClass,
|
|
3147
|
+
buildColumnResolver(ctx.relatedClass),
|
|
3148
|
+
hasSoftDeletes(ctx.relatedClass),
|
|
3149
|
+
this.#dialect,
|
|
3150
|
+
buildValuePreparer(ctx.relatedClass),
|
|
1863
3151
|
);
|
|
1864
3152
|
ctx.nestedCallback(sub);
|
|
1865
3153
|
if (sub.#preloads.size > 0) {
|
|
@@ -1876,17 +3164,28 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1876
3164
|
column: string;
|
|
1877
3165
|
operator: string;
|
|
1878
3166
|
value: unknown;
|
|
3167
|
+
type?: "and" | "or";
|
|
1879
3168
|
}> = [],
|
|
1880
3169
|
): Promise<Record<string, unknown>[]> {
|
|
1881
3170
|
const wheres: Array<Record<string, unknown>> = [
|
|
1882
3171
|
{ column, operator: "IN", value: values, type: "and" },
|
|
1883
3172
|
];
|
|
1884
|
-
|
|
3173
|
+
// The caller's filters go in a parenthesised group, never flat beside the
|
|
3174
|
+
// `IN`. Flat, an `orWherePivot` would read as
|
|
3175
|
+
// `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
|
|
3176
|
+
// other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
|
|
3177
|
+
// With every filter ANDed the two forms are equivalent, so this changes
|
|
3178
|
+
// no existing query.
|
|
3179
|
+
if (extraWheres.length > 0) {
|
|
1885
3180
|
wheres.push({
|
|
1886
|
-
|
|
1887
|
-
operator: w.operator,
|
|
1888
|
-
value: w.value,
|
|
3181
|
+
kind: "group",
|
|
1889
3182
|
type: "and",
|
|
3183
|
+
conditions: extraWheres.map((w) => ({
|
|
3184
|
+
column: w.column,
|
|
3185
|
+
operator: w.operator,
|
|
3186
|
+
value: w.value,
|
|
3187
|
+
type: w.type ?? "and",
|
|
3188
|
+
})),
|
|
1890
3189
|
});
|
|
1891
3190
|
}
|
|
1892
3191
|
const spec = {
|
|
@@ -1937,7 +3236,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1937
3236
|
this.#db,
|
|
1938
3237
|
(row) => row as BaseEntity,
|
|
1939
3238
|
relatedClass,
|
|
1940
|
-
|
|
3239
|
+
// Resolve columns + prepare values against the RELATED model so a preload
|
|
3240
|
+
// constraint (onQuery / callback) targeting a columnName-mapped or date
|
|
3241
|
+
// column compiles/binds like a direct query on that model.
|
|
3242
|
+
buildColumnResolver(relatedClass),
|
|
1941
3243
|
// Propagate the RELATED entity's soft-delete flag — hardcoding
|
|
1942
3244
|
// false here meant `preload('posts')` returned soft-deleted
|
|
1943
3245
|
// posts even when Post is @SoftDeletes (a data leak). The
|
|
@@ -1947,6 +3249,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1947
3249
|
// preload callback.)
|
|
1948
3250
|
hasSoftDeletes(relatedClass),
|
|
1949
3251
|
this.#dialect,
|
|
3252
|
+
buildValuePreparer(relatedClass),
|
|
1950
3253
|
);
|
|
1951
3254
|
sub.whereIn(column, values);
|
|
1952
3255
|
if (relation.onQuery) relation.onQuery(sub as unknown);
|
|
@@ -1995,30 +3298,46 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1995
3298
|
);
|
|
1996
3299
|
}
|
|
1997
3300
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1998
|
-
const relatedMeta =
|
|
1999
|
-
if (!relatedMeta) {
|
|
2000
|
-
throw new Error(
|
|
2001
|
-
`Entity metadata missing on related class ${relatedClass.name}`,
|
|
2002
|
-
);
|
|
2003
|
-
}
|
|
3301
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
2004
3302
|
const relatedTable = relatedMeta.tableName;
|
|
2005
3303
|
const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
2006
3304
|
const parentTable = this.#tableName;
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
3305
|
+
// Strict single-segment identifier quote. This builds a RAW correlated
|
|
3306
|
+
// subquery fragment (no bind params for identifiers), so every segment must
|
|
3307
|
+
// be validated — a table/key from relation metadata carrying a quote/backtick
|
|
3308
|
+
// would otherwise emit invalid or injectable SQL. Same policy as
|
|
3309
|
+
// BaseRepository's lazy m2m path.
|
|
3310
|
+
const q = (name: string): string => {
|
|
3311
|
+
if (!/^[A-Za-z0-9_]+$/.test(name)) {
|
|
3312
|
+
throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
|
|
3313
|
+
}
|
|
3314
|
+
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
3315
|
+
};
|
|
3316
|
+
// Table identifiers may be schema-qualified (`schema.table`) — quote each
|
|
3317
|
+
// dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
|
|
3318
|
+
// `public.users_roles` gets wrapped as ONE identifier and silently targets a
|
|
3319
|
+
// table literally named with a dot. Each segment still passes the strict
|
|
3320
|
+
// guard above. Columns stay single-segment via `q`.
|
|
3321
|
+
const qTable = (name: string): string => name.split(".").map(q).join(".");
|
|
2011
3322
|
|
|
2012
3323
|
const sub = new ModelQuery<BaseEntity>(
|
|
2013
3324
|
relatedTable,
|
|
2014
3325
|
this.#db,
|
|
2015
3326
|
(row) => row as BaseEntity,
|
|
2016
3327
|
relatedClass,
|
|
2017
|
-
|
|
3328
|
+
// whereHas/withCount constraints run against the RELATED model — resolve
|
|
3329
|
+
// its columns (columnName/multi-word) and prepare its values like a direct query.
|
|
3330
|
+
buildColumnResolver(relatedClass),
|
|
2018
3331
|
false,
|
|
2019
3332
|
this.#dialect,
|
|
3333
|
+
buildValuePreparer(relatedClass),
|
|
2020
3334
|
);
|
|
2021
3335
|
|
|
3336
|
+
// `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
|
|
3337
|
+
// PK); resolve each to its DB column via the owning model so a multi-word or
|
|
3338
|
+
// `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
|
|
3339
|
+
// `firstKey`/`secondKey` are DB column names already — left as-is.
|
|
3340
|
+
const resolveParent = buildColumnResolver(this.#entityClass);
|
|
2022
3341
|
switch (relation.type) {
|
|
2023
3342
|
case "hasOne":
|
|
2024
3343
|
case "hasMany": {
|
|
@@ -2026,19 +3345,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2026
3345
|
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
2027
3346
|
const fk =
|
|
2028
3347
|
relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2029
|
-
const localKey = relation.localKey ?? parentPk;
|
|
3348
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2030
3349
|
sub.#pushWhereRaw(
|
|
2031
|
-
`${
|
|
3350
|
+
`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
|
|
2032
3351
|
);
|
|
2033
3352
|
break;
|
|
2034
3353
|
}
|
|
2035
3354
|
case "belongsTo": {
|
|
2036
3355
|
const fk =
|
|
2037
3356
|
relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
2038
|
-
const ownerKey =
|
|
2039
|
-
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id"
|
|
3357
|
+
const ownerKey = buildColumnResolver(relatedClass)(
|
|
3358
|
+
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
|
|
3359
|
+
);
|
|
2040
3360
|
sub.#pushWhereRaw(
|
|
2041
|
-
`${
|
|
3361
|
+
`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`,
|
|
2042
3362
|
);
|
|
2043
3363
|
break;
|
|
2044
3364
|
}
|
|
@@ -2055,12 +3375,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2055
3375
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2056
3376
|
const otherKey =
|
|
2057
3377
|
pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
2058
|
-
const
|
|
2059
|
-
const
|
|
3378
|
+
const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
|
|
3379
|
+
const relatedPk =
|
|
3380
|
+
getColumnMetadata(relatedClass).find(
|
|
3381
|
+
(c) => c.propertyKey === relatedPkProp,
|
|
3382
|
+
)?.columnName ?? camelToSnake(relatedPkProp);
|
|
3383
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2060
3384
|
sub.#pushWhereRaw(
|
|
2061
|
-
`${
|
|
2062
|
-
`(SELECT ${q(otherKey)} FROM ${
|
|
2063
|
-
`WHERE ${
|
|
3385
|
+
`${qTable(relatedTable)}.${q(relatedPk)} IN ` +
|
|
3386
|
+
`(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
|
|
3387
|
+
`WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`,
|
|
2064
3388
|
);
|
|
2065
3389
|
break;
|
|
2066
3390
|
}
|
|
@@ -2075,24 +3399,21 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2075
3399
|
);
|
|
2076
3400
|
}
|
|
2077
3401
|
const throughClass = relation.through() as new () => BaseEntity;
|
|
2078
|
-
const throughMeta =
|
|
2079
|
-
if (!throughMeta) {
|
|
2080
|
-
throw new Error(
|
|
2081
|
-
`Entity metadata missing on through class ${throughClass.name}`,
|
|
2082
|
-
);
|
|
2083
|
-
}
|
|
3402
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
2084
3403
|
const throughTable = throughMeta.tableName;
|
|
2085
3404
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
2086
|
-
const parentLocal = relation.localKey ?? parentPk;
|
|
3405
|
+
const parentLocal = resolveParent(relation.localKey ?? parentPk);
|
|
2087
3406
|
const firstKey =
|
|
2088
3407
|
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2089
3408
|
const secondKey =
|
|
2090
3409
|
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
2091
|
-
const secondLocal =
|
|
3410
|
+
const secondLocal = buildColumnResolver(throughClass)(
|
|
3411
|
+
relation.secondLocalKey ?? throughPk,
|
|
3412
|
+
);
|
|
2092
3413
|
sub.#pushWhereRaw(
|
|
2093
|
-
`${
|
|
2094
|
-
`(SELECT ${q(secondLocal)} FROM ${
|
|
2095
|
-
`WHERE ${
|
|
3414
|
+
`${qTable(relatedTable)}.${q(secondKey)} IN ` +
|
|
3415
|
+
`(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
|
|
3416
|
+
`WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`,
|
|
2096
3417
|
);
|
|
2097
3418
|
break;
|
|
2098
3419
|
}
|
|
@@ -2146,8 +3467,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2146
3467
|
}
|
|
2147
3468
|
|
|
2148
3469
|
crossJoin(table: string): this {
|
|
2149
|
-
const tq = this.#
|
|
2150
|
-
this.#joins.push(`CROSS JOIN ${tq}
|
|
3470
|
+
const tq = this.#quoteCol(table);
|
|
3471
|
+
this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
|
|
2151
3472
|
return this;
|
|
2152
3473
|
}
|
|
2153
3474
|
|
|
@@ -2167,14 +3488,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2167
3488
|
*
|
|
2168
3489
|
* @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
|
|
2169
3490
|
*/
|
|
2170
|
-
joinRaw(fragment: string): this {
|
|
3491
|
+
joinRaw(fragment: string, bindings: readonly unknown[] = []): this {
|
|
2171
3492
|
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
2172
3493
|
throw new Error(
|
|
2173
3494
|
"joinRaw() is disabled in Atlas strict mode. " +
|
|
2174
3495
|
"Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
|
|
2175
3496
|
);
|
|
2176
3497
|
}
|
|
2177
|
-
this.#joins.push(fragment);
|
|
3498
|
+
this.#joins.push({ sql: fragment, params: [...bindings] });
|
|
2178
3499
|
return this;
|
|
2179
3500
|
}
|
|
2180
3501
|
|
|
@@ -2200,11 +3521,47 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2200
3521
|
return this;
|
|
2201
3522
|
}
|
|
2202
3523
|
|
|
3524
|
+
/**
|
|
3525
|
+
* `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
|
|
3526
|
+
* `columns` (Lucid/Knex `distinctOn`). Takes precedence over
|
|
3527
|
+
* {@link distinct}.
|
|
3528
|
+
*
|
|
3529
|
+
* Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
|
|
3530
|
+
* would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
|
|
3531
|
+
* return a *different* result set rather than fail — a silent wrong answer
|
|
3532
|
+
* is worse than an error.
|
|
3533
|
+
*
|
|
3534
|
+
* Postgres also requires the leading `ORDER BY` terms to match `columns`;
|
|
3535
|
+
* that is left to the database to enforce.
|
|
3536
|
+
*/
|
|
3537
|
+
distinctOn(...columns: string[]): this {
|
|
3538
|
+
for (const c of columns) this.#distinctOn.push(this.#resolveColumn(c));
|
|
3539
|
+
return this;
|
|
3540
|
+
}
|
|
3541
|
+
|
|
2203
3542
|
/** `SELECT COUNT(DISTINCT col)`. */
|
|
2204
3543
|
async countDistinct(column: string): Promise<number> {
|
|
2205
3544
|
return Number(
|
|
2206
|
-
(await this.#runScalar(
|
|
3545
|
+
(await this.#runScalar(
|
|
3546
|
+
`COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
3547
|
+
)) ?? 0,
|
|
3548
|
+
);
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
/** `SUM(DISTINCT col)` (Lucid parity). */
|
|
3552
|
+
async sumDistinct(column: string): Promise<number | null> {
|
|
3553
|
+
const v = await this.#runScalar(
|
|
3554
|
+
`SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
3555
|
+
);
|
|
3556
|
+
return v === null || v === undefined ? null : Number(v);
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
/** `AVG(DISTINCT col)` (Lucid parity). */
|
|
3560
|
+
async avgDistinct(column: string): Promise<number | null> {
|
|
3561
|
+
const v = await this.#runScalar(
|
|
3562
|
+
`AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2207
3563
|
);
|
|
3564
|
+
return v === null || v === undefined ? null : Number(v);
|
|
2208
3565
|
}
|
|
2209
3566
|
|
|
2210
3567
|
/** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
|
|
@@ -2213,7 +3570,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2213
3570
|
clone.#select = ["1"];
|
|
2214
3571
|
clone.#limit = 1;
|
|
2215
3572
|
const { sql, params } = clone.toSQL();
|
|
2216
|
-
const rows = await this.#db.query<Record<string, unknown>>(
|
|
3573
|
+
const rows = await this.#db.query<Record<string, unknown>>(
|
|
3574
|
+
sql,
|
|
3575
|
+
params,
|
|
3576
|
+
this.#meta("exists"),
|
|
3577
|
+
);
|
|
2217
3578
|
return rows.length > 0;
|
|
2218
3579
|
}
|
|
2219
3580
|
|
|
@@ -2311,14 +3672,32 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2311
3672
|
// beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
|
|
2312
3673
|
// tenant scope) propagates into both the COUNT and the data fetch.
|
|
2313
3674
|
await fireHooks(this.#entityClass, "beforePaginate", this);
|
|
2314
|
-
//
|
|
3675
|
+
// COUNT(*) + data fetch
|
|
2315
3676
|
const countQ = this.clone();
|
|
2316
|
-
countQ.#select = ["COUNT(*) AS count"];
|
|
2317
3677
|
countQ.#limit = undefined;
|
|
2318
3678
|
countQ.#offset = undefined;
|
|
2319
3679
|
countQ.#orderBys = [];
|
|
2320
|
-
|
|
2321
|
-
|
|
3680
|
+
let cSql: string;
|
|
3681
|
+
let cParams: unknown[];
|
|
3682
|
+
if (countQ.#groupBy.length > 0) {
|
|
3683
|
+
// A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
|
|
3684
|
+
// group's own size), so `rows[0].count` would be the first group's size, not
|
|
3685
|
+
// the number of pages. Lucid counts via a subquery: wrap the grouped query
|
|
3686
|
+
// (select + groupBy + having preserved) and count its rows = group count.
|
|
3687
|
+
const inner = countQ.toSQL();
|
|
3688
|
+
cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
|
|
3689
|
+
cParams = inner.params;
|
|
3690
|
+
} else {
|
|
3691
|
+
countQ.#select = ["COUNT(*) AS count"];
|
|
3692
|
+
const flat = countQ.toSQL();
|
|
3693
|
+
cSql = flat.sql;
|
|
3694
|
+
cParams = flat.params;
|
|
3695
|
+
}
|
|
3696
|
+
const cRows = await this.#db.query<Record<string, unknown>>(
|
|
3697
|
+
cSql,
|
|
3698
|
+
cParams,
|
|
3699
|
+
this.#meta("paginate"),
|
|
3700
|
+
);
|
|
2322
3701
|
const total = Number(cRows[0]?.count ?? 0);
|
|
2323
3702
|
|
|
2324
3703
|
const dataQ = this.clone();
|
|
@@ -2328,7 +3707,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2328
3707
|
// top of the paginate hooks — paginate is its own terminal.
|
|
2329
3708
|
const items = await dataQ.#doExec();
|
|
2330
3709
|
await fireHooks(this.#entityClass, "afterPaginate", items);
|
|
2331
|
-
|
|
3710
|
+
const metaKeys = this.#entityClass
|
|
3711
|
+
? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
|
|
3712
|
+
: undefined;
|
|
3713
|
+
return new Paginator<T>(
|
|
3714
|
+
items,
|
|
3715
|
+
{ total, perPage: pp, currentPage: p },
|
|
3716
|
+
metaKeys,
|
|
3717
|
+
);
|
|
2332
3718
|
}
|
|
2333
3719
|
|
|
2334
3720
|
/**
|
|
@@ -2349,9 +3735,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2349
3735
|
limit: number;
|
|
2350
3736
|
orderBy: string | string[];
|
|
2351
3737
|
}): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
3738
|
+
// Keep BOTH forms: `props` (model property names) to read the cursor value
|
|
3739
|
+
// off the hydrated entity, and `cols` (resolved DB columns) for the SQL
|
|
3740
|
+
// ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
|
|
3741
|
+
// encode `undefined` into the cursor (entity exposes the property, not the
|
|
3742
|
+
// DB column) — an unstable / stuck cursor.
|
|
3743
|
+
const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
|
|
3744
|
+
const cols = props.map((c) => this.#resolveColumn(c));
|
|
2355
3745
|
if (cols.length === 0)
|
|
2356
3746
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
2357
3747
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
@@ -2400,9 +3790,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2400
3790
|
const last = items[items.length - 1] as Record<string, unknown> | undefined;
|
|
2401
3791
|
const nextCursor =
|
|
2402
3792
|
hasMore && last
|
|
2403
|
-
? Buffer.from(
|
|
2404
|
-
|
|
2405
|
-
)
|
|
3793
|
+
? Buffer.from(
|
|
3794
|
+
JSON.stringify({ v: props.map((p) => last[p]) }),
|
|
3795
|
+
).toString("base64")
|
|
2406
3796
|
: null;
|
|
2407
3797
|
return { items, nextCursor, hasMore };
|
|
2408
3798
|
}
|
|
@@ -2423,6 +3813,22 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2423
3813
|
return this;
|
|
2424
3814
|
}
|
|
2425
3815
|
|
|
3816
|
+
/**
|
|
3817
|
+
* Context attached to each statement this query runs, so a `db:query`
|
|
3818
|
+
* listener can say which model and which call produced it — and so
|
|
3819
|
+
* {@link debug} can force emission for this query alone.
|
|
3820
|
+
*
|
|
3821
|
+
* Note the connection's own `debug: true` emits every statement regardless;
|
|
3822
|
+
* `meta` only enriches the event and opens the per-query override.
|
|
3823
|
+
*/
|
|
3824
|
+
#meta(method: string): QueryMeta {
|
|
3825
|
+
return {
|
|
3826
|
+
model: this.#entityClass.name,
|
|
3827
|
+
method,
|
|
3828
|
+
debug: this.#debugFlag,
|
|
3829
|
+
};
|
|
3830
|
+
}
|
|
3831
|
+
|
|
2426
3832
|
/** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
|
|
2427
3833
|
toQuery(): string {
|
|
2428
3834
|
const { sql, params } = this.toSQL();
|
|
@@ -2443,6 +3849,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2443
3849
|
this.#resolveColumn,
|
|
2444
3850
|
this.#softDeletes,
|
|
2445
3851
|
this.#dialect,
|
|
3852
|
+
this.#prepareValue,
|
|
3853
|
+
this.#onDomainEvents,
|
|
2446
3854
|
);
|
|
2447
3855
|
c.#softScope = this.#softScope;
|
|
2448
3856
|
c.#wheres = structuredCloneSafe(this.#wheres);
|
|
@@ -2452,17 +3860,29 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2452
3860
|
c.#offset = this.#offset;
|
|
2453
3861
|
c.#preloads = new Map(this.#preloads);
|
|
2454
3862
|
c.#selectSubqueries = structuredClone(this.#selectSubqueries);
|
|
2455
|
-
c.#joins =
|
|
3863
|
+
c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
|
|
2456
3864
|
c.#lockMode = this.#lockMode;
|
|
3865
|
+
c.#lockModifier = this.#lockModifier;
|
|
3866
|
+
c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
|
|
2457
3867
|
c.#distinct = this.#distinct;
|
|
3868
|
+
c.#distinctOn = [...this.#distinctOn];
|
|
2458
3869
|
c.#groupBy = [...this.#groupBy];
|
|
2459
3870
|
c.#having = structuredCloneSafe(this.#having);
|
|
2460
|
-
c.#ctes = this.#ctes.map((e) => ({
|
|
3871
|
+
c.#ctes = this.#ctes.map((e) => ({
|
|
3872
|
+
name: e.name,
|
|
3873
|
+
query: e.query.clone(),
|
|
3874
|
+
recursive: e.recursive,
|
|
3875
|
+
materialized: e.materialized,
|
|
3876
|
+
}));
|
|
2461
3877
|
c.#unions = this.#unions.map((u) => ({
|
|
2462
3878
|
query: u.query.clone(),
|
|
2463
3879
|
all: u.all,
|
|
3880
|
+
op: u.op,
|
|
2464
3881
|
}));
|
|
2465
3882
|
c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
|
|
3883
|
+
// Pure closure over pivot metadata — safe to share by reference; it reads the
|
|
3884
|
+
// clone's own #pivotWheres at build time (passed in), holding no query state.
|
|
3885
|
+
c.#pivotExists = this.#pivotExists;
|
|
2466
3886
|
c.#debugFlag = this.#debugFlag;
|
|
2467
3887
|
return c;
|
|
2468
3888
|
}
|
|
@@ -2477,15 +3897,18 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2477
3897
|
if (!patch || Object.keys(patch).length === 0) {
|
|
2478
3898
|
throw new Error("update() requires a non-empty payload");
|
|
2479
3899
|
}
|
|
3900
|
+
// Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
|
|
3901
|
+
// like BaseRepository's write paths — the fluent update() must not bypass it.
|
|
2480
3902
|
const setPairs = Object.entries(patch).map(
|
|
2481
|
-
([k, v]) =>
|
|
3903
|
+
([k, v]) =>
|
|
3904
|
+
[this.#resolveColumn(k), this.#prepareValue(k, v)] as [string, unknown],
|
|
2482
3905
|
);
|
|
2483
3906
|
const spec = {
|
|
2484
3907
|
kind: "update",
|
|
2485
3908
|
table: this.#tableName,
|
|
2486
3909
|
set: setPairs,
|
|
2487
3910
|
wheres: this.#wheresForDml(),
|
|
2488
|
-
returning: returning ?? [],
|
|
3911
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
2489
3912
|
};
|
|
2490
3913
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
2491
3914
|
if (returning && returning.length > 0) {
|
|
@@ -2498,25 +3921,67 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2498
3921
|
return r.rowsAffected ?? 0;
|
|
2499
3922
|
}
|
|
2500
3923
|
|
|
2501
|
-
/**
|
|
3924
|
+
/**
|
|
3925
|
+
* Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
|
|
3926
|
+
* scoped rows (stamps `deleted_at`) — consistent with the entity-level
|
|
3927
|
+
* `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
|
|
3928
|
+
* model it issues a hard `DELETE`. Returns affected rows (or rows when
|
|
3929
|
+
* `returning` is set).
|
|
3930
|
+
*/
|
|
2502
3931
|
async delete(
|
|
2503
3932
|
returning?: string[],
|
|
3933
|
+
): Promise<number | Record<string, unknown>[]> {
|
|
3934
|
+
if (this.#softDeletes) {
|
|
3935
|
+
const spec = {
|
|
3936
|
+
kind: "update",
|
|
3937
|
+
table: this.#tableName,
|
|
3938
|
+
set: [[this.#deletedAtColumn(), new Date().toISOString()]],
|
|
3939
|
+
wheres: this.#wheresForDml(),
|
|
3940
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
3941
|
+
};
|
|
3942
|
+
return this.#runDml(spec, returning);
|
|
3943
|
+
}
|
|
3944
|
+
return this.forceDelete(returning);
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
/** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
|
|
3948
|
+
async forceDelete(
|
|
3949
|
+
returning?: string[],
|
|
2504
3950
|
): Promise<number | Record<string, unknown>[]> {
|
|
2505
3951
|
const spec = {
|
|
2506
3952
|
kind: "delete",
|
|
2507
3953
|
table: this.#tableName,
|
|
2508
3954
|
wheres: this.#wheresForDml(),
|
|
2509
|
-
returning: returning ?? [],
|
|
3955
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
2510
3956
|
};
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
3957
|
+
return this.#runDml(spec, returning);
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
/**
|
|
3961
|
+
* Bulk restore: clear `deleted_at` on the trashed rows matching the user's
|
|
3962
|
+
* predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
|
|
3963
|
+
* on a non-soft-delete model. Independent of the current soft-scope — it always
|
|
3964
|
+
* targets trashed rows (`deleted_at IS NOT NULL`).
|
|
3965
|
+
*/
|
|
3966
|
+
async restore(
|
|
3967
|
+
returning?: string[],
|
|
3968
|
+
): Promise<number | Record<string, unknown>[]> {
|
|
3969
|
+
if (!this.#softDeletes) return 0;
|
|
3970
|
+
const wheres = this.#userWheresForDml();
|
|
3971
|
+
wheres.push({
|
|
3972
|
+
column: this.#deletedAtColumn(),
|
|
3973
|
+
operator: "IS NOT NULL",
|
|
3974
|
+
value: null,
|
|
3975
|
+
type: "and",
|
|
3976
|
+
});
|
|
3977
|
+
const spec = {
|
|
3978
|
+
kind: "update",
|
|
3979
|
+
table: this.#tableName,
|
|
3980
|
+
set: [[this.#deletedAtColumn(), null]],
|
|
3981
|
+
wheres,
|
|
3982
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
3983
|
+
};
|
|
3984
|
+
return this.#runDml(spec, returning);
|
|
2520
3985
|
}
|
|
2521
3986
|
|
|
2522
3987
|
// === Story 30.3 — increment / decrement already implemented? check ================================
|
|
@@ -2563,6 +4028,56 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2563
4028
|
return this;
|
|
2564
4029
|
}
|
|
2565
4030
|
|
|
4031
|
+
/** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
|
|
4032
|
+
forNoKeyUpdate(): this {
|
|
4033
|
+
if (this.#dialect === "postgres") {
|
|
4034
|
+
this.#lockMode = "FOR NO KEY UPDATE";
|
|
4035
|
+
} else {
|
|
4036
|
+
console.warn(
|
|
4037
|
+
`[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`,
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
return this;
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
/** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
|
|
4044
|
+
forKeyShare(): this {
|
|
4045
|
+
if (this.#dialect === "postgres") {
|
|
4046
|
+
this.#lockMode = "FOR KEY SHARE";
|
|
4047
|
+
} else {
|
|
4048
|
+
console.warn(
|
|
4049
|
+
`[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`,
|
|
4050
|
+
);
|
|
4051
|
+
}
|
|
4052
|
+
return this;
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
/**
|
|
4056
|
+
* Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
|
|
4057
|
+
* waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
4058
|
+
*/
|
|
4059
|
+
skipLocked(): this {
|
|
4060
|
+
if (this.#dialect === "sqlite") {
|
|
4061
|
+
console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
|
|
4062
|
+
} else {
|
|
4063
|
+
this.#lockModifier = "SKIP LOCKED";
|
|
4064
|
+
}
|
|
4065
|
+
return this;
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
/**
|
|
4069
|
+
* Append `NOWAIT` to the lock clause — error immediately instead of waiting on
|
|
4070
|
+
* a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
4071
|
+
*/
|
|
4072
|
+
noWait(): this {
|
|
4073
|
+
if (this.#dialect === "sqlite") {
|
|
4074
|
+
console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
|
|
4075
|
+
} else {
|
|
4076
|
+
this.#lockModifier = "NOWAIT";
|
|
4077
|
+
}
|
|
4078
|
+
return this;
|
|
4079
|
+
}
|
|
4080
|
+
|
|
2566
4081
|
// === Private helpers ==============================================================================
|
|
2567
4082
|
|
|
2568
4083
|
#quote(name: string): string {
|
|
@@ -2571,11 +4086,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2571
4086
|
|
|
2572
4087
|
/** Quote a `table.column` reference on both sides of the dot. */
|
|
2573
4088
|
#quoteCol(ref: string): string {
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
4089
|
+
// Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
|
|
4090
|
+
// identifier smuggling a `"`/backtick would break out of the quoting on the
|
|
4091
|
+
// join path (which the Rust screen doesn't re-validate). Strict
|
|
4092
|
+
// `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
|
|
4093
|
+
// helpers injection-safe. Use joinRaw() for anything more complex.
|
|
4094
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
|
|
4095
|
+
throw new Error(
|
|
4096
|
+
`Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`,
|
|
4097
|
+
);
|
|
2577
4098
|
}
|
|
2578
|
-
return
|
|
4099
|
+
return ref
|
|
4100
|
+
.split(".")
|
|
4101
|
+
.map((seg) => this.#quote(seg))
|
|
4102
|
+
.join(".");
|
|
2579
4103
|
}
|
|
2580
4104
|
|
|
2581
4105
|
#pushJoin(
|
|
@@ -2584,7 +4108,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2584
4108
|
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
2585
4109
|
right?: string,
|
|
2586
4110
|
): this {
|
|
2587
|
-
const tq = this.#
|
|
4111
|
+
const tq = this.#quoteCol(table);
|
|
2588
4112
|
if (typeof leftOrBuild === "function") {
|
|
2589
4113
|
const jb: JoinBuilder = {
|
|
2590
4114
|
parts: [],
|
|
@@ -2596,28 +4120,61 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2596
4120
|
this.parts.push({ kind: "and", left: l, right: r });
|
|
2597
4121
|
return this;
|
|
2598
4122
|
},
|
|
2599
|
-
|
|
2600
|
-
this.parts.push({ kind: "
|
|
4123
|
+
orOn(l: string, r: string) {
|
|
4124
|
+
this.parts.push({ kind: "or", left: l, right: r });
|
|
4125
|
+
return this;
|
|
4126
|
+
},
|
|
4127
|
+
onVal(l: string, v: unknown) {
|
|
4128
|
+
this.parts.push({ kind: "and", left: l, value: { v } });
|
|
4129
|
+
return this;
|
|
4130
|
+
},
|
|
4131
|
+
andOnVal(l: string, v: unknown) {
|
|
4132
|
+
this.parts.push({ kind: "and", left: l, value: { v } });
|
|
4133
|
+
return this;
|
|
4134
|
+
},
|
|
4135
|
+
orOnVal(l: string, v: unknown) {
|
|
4136
|
+
this.parts.push({ kind: "or", left: l, value: { v } });
|
|
2601
4137
|
return this;
|
|
2602
4138
|
},
|
|
2603
4139
|
};
|
|
2604
4140
|
leftOrBuild(jb);
|
|
4141
|
+
// Collect the bound values in placeholder order as the fragment is built.
|
|
4142
|
+
const params: unknown[] = [];
|
|
2605
4143
|
const on = jb.parts
|
|
2606
4144
|
.map((p, i) => {
|
|
2607
4145
|
const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
|
|
2608
|
-
|
|
4146
|
+
if (p.value) {
|
|
4147
|
+
// A BASE-table column runs the full model prepare (DateTime→ISO +
|
|
4148
|
+
// @Column adapters/casts), keyed by its property. A FOREIGN join
|
|
4149
|
+
// column must NOT borrow the root model's adapter for a same-named
|
|
4150
|
+
// column on another table — apply only universal type-lowering
|
|
4151
|
+
// (Date/DateTime→ISO), matching Knex's model-agnostic join binding.
|
|
4152
|
+
const dot = p.left.lastIndexOf(".");
|
|
4153
|
+
const tablePrefix = dot >= 0 ? p.left.slice(0, dot) : "";
|
|
4154
|
+
const leaf = dot >= 0 ? p.left.slice(dot + 1) : p.left;
|
|
4155
|
+
const isBaseColumn =
|
|
4156
|
+
tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
|
|
4157
|
+
params.push(
|
|
4158
|
+
isBaseColumn
|
|
4159
|
+
? this.#prepareValue(leaf, p.value.v)
|
|
4160
|
+
: lowerJoinValue(p.value.v),
|
|
4161
|
+
);
|
|
4162
|
+
return `${prefix} ${this.#quoteCol(p.left)} = ?`;
|
|
4163
|
+
}
|
|
4164
|
+
return `${prefix} ${this.#quoteCol(p.left)} = ${this.#quoteCol(p.right ?? "")}`;
|
|
2609
4165
|
})
|
|
2610
4166
|
.join(" ");
|
|
2611
|
-
this.#joins.push(`${kind} JOIN ${tq} ${on}
|
|
4167
|
+
this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
|
|
2612
4168
|
return this;
|
|
2613
4169
|
}
|
|
2614
4170
|
if (right === undefined)
|
|
2615
4171
|
throw new Error(
|
|
2616
4172
|
"join() with string form requires both left and right operands",
|
|
2617
4173
|
);
|
|
2618
|
-
this.#joins.push(
|
|
2619
|
-
`${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
|
|
2620
|
-
|
|
4174
|
+
this.#joins.push({
|
|
4175
|
+
sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
|
|
4176
|
+
params: [],
|
|
4177
|
+
});
|
|
2621
4178
|
return this;
|
|
2622
4179
|
}
|
|
2623
4180
|
|
|
@@ -2663,7 +4220,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2663
4220
|
* still rejected because the DML compiler's WHERE lowering does not yet
|
|
2664
4221
|
* handle nested sub-queries or correlated EXISTS.
|
|
2665
4222
|
*/
|
|
2666
|
-
|
|
4223
|
+
/** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
|
|
4224
|
+
#userWheresForDml(): Array<Record<string, unknown>> {
|
|
2667
4225
|
const out: Array<Record<string, unknown>> = [];
|
|
2668
4226
|
for (const w of this.#wheres) {
|
|
2669
4227
|
if ("kind" in w) {
|
|
@@ -2691,6 +4249,50 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2691
4249
|
return out;
|
|
2692
4250
|
}
|
|
2693
4251
|
|
|
4252
|
+
#wheresForDml(): Array<Record<string, unknown>> {
|
|
4253
|
+
const out = this.#userWheresForDml();
|
|
4254
|
+
// Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
|
|
4255
|
+
// update/delete/increment/decrement must NOT touch trashed rows under the
|
|
4256
|
+
// default scope — otherwise `query().where(x)` would denote a different row
|
|
4257
|
+
// set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
|
|
4258
|
+
// `.onlyTrashed()` restricts to trashed (mirrors reads).
|
|
4259
|
+
if (this.#softDeletes) {
|
|
4260
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
4261
|
+
if (this.#softScope === "default") {
|
|
4262
|
+
out.push({
|
|
4263
|
+
column: deletedAtCol,
|
|
4264
|
+
operator: "IS NULL",
|
|
4265
|
+
value: null,
|
|
4266
|
+
type: "and",
|
|
4267
|
+
});
|
|
4268
|
+
} else if (this.#softScope === "only-trashed") {
|
|
4269
|
+
out.push({
|
|
4270
|
+
column: deletedAtCol,
|
|
4271
|
+
operator: "IS NOT NULL",
|
|
4272
|
+
value: null,
|
|
4273
|
+
type: "and",
|
|
4274
|
+
});
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
return out;
|
|
4278
|
+
}
|
|
4279
|
+
|
|
4280
|
+
/** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
|
|
4281
|
+
async #runDml(
|
|
4282
|
+
spec: Record<string, unknown>,
|
|
4283
|
+
returning?: string[],
|
|
4284
|
+
): Promise<number | Record<string, unknown>[]> {
|
|
4285
|
+
const compiled = compileStatementNative(spec, this.#dialect);
|
|
4286
|
+
if (returning && returning.length > 0) {
|
|
4287
|
+
return this.#db.query<Record<string, unknown>>(
|
|
4288
|
+
compiled.statements[0],
|
|
4289
|
+
compiled.params,
|
|
4290
|
+
);
|
|
4291
|
+
}
|
|
4292
|
+
const r = await this.#db.execute(compiled.statements[0], compiled.params);
|
|
4293
|
+
return r.rowsAffected ?? 0;
|
|
4294
|
+
}
|
|
4295
|
+
|
|
2694
4296
|
/**
|
|
2695
4297
|
* !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
|
|
2696
4298
|
*
|
|
@@ -2728,6 +4330,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2728
4330
|
this.#resolveColumn,
|
|
2729
4331
|
false,
|
|
2730
4332
|
this.#dialect,
|
|
4333
|
+
this.#prepareValue,
|
|
2731
4334
|
);
|
|
2732
4335
|
callback(scratch);
|
|
2733
4336
|
return { type, kind: "group", conditions: scratch.#wheres };
|
|
@@ -2781,19 +4384,30 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2781
4384
|
type,
|
|
2782
4385
|
column: resolved,
|
|
2783
4386
|
operator: "=",
|
|
2784
|
-
value: operatorOrValue,
|
|
4387
|
+
value: this.#prep(column, operatorOrValue),
|
|
2785
4388
|
});
|
|
2786
4389
|
} else {
|
|
2787
4390
|
this.#wheres.push({
|
|
2788
4391
|
type,
|
|
2789
4392
|
column: resolved,
|
|
2790
4393
|
operator: operatorOrValue as string,
|
|
2791
|
-
value,
|
|
4394
|
+
value: this.#prep(column, value),
|
|
2792
4395
|
});
|
|
2793
4396
|
}
|
|
2794
4397
|
return this;
|
|
2795
4398
|
}
|
|
2796
4399
|
|
|
4400
|
+
/**
|
|
4401
|
+
* Lower a WHERE/search value (or each element of an array) to its DB form via
|
|
4402
|
+
* the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
|
|
4403
|
+
* adapter column used as a predicate binds the same shape the write path stores.
|
|
4404
|
+
*/
|
|
4405
|
+
#prep(column: string, value: unknown): unknown {
|
|
4406
|
+
return Array.isArray(value)
|
|
4407
|
+
? value.map((v) => this.#prepareValue(column, v))
|
|
4408
|
+
: this.#prepareValue(column, value);
|
|
4409
|
+
}
|
|
4410
|
+
|
|
2797
4411
|
/**
|
|
2798
4412
|
* Resolve this ModelQuery's preloads against a pre-loaded set of entities.
|
|
2799
4413
|
* Used by the nested-preload machinery to recurse without re-running the root select.
|