@c9up/atlas 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -14
- package/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +6 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +2 -2
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +171 -7
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -31
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +91 -0
- package/dist/BaseModel.d.ts.map +1 -0
- package/dist/BaseModel.js +193 -0
- package/dist/BaseModel.js.map +1 -0
- package/dist/BaseRepository.d.ts +77 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1423 -354
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +429 -11
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +1733 -145
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +17 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +57 -5
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +33 -4
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +101 -11
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/migrationCommands.d.ts +48 -0
- package/dist/console/migrationCommands.d.ts.map +1 -0
- package/dist/console/migrationCommands.js +220 -0
- package/dist/console/migrationCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +37 -6
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +32 -2
- package/dist/decorators/entity.js.map +1 -1
- package/dist/events.d.ts +64 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +82 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata-keys.d.ts +3 -2
- package/dist/metadata-keys.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.d.ts +7 -0
- package/dist/naming/NamingStrategy.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.js +16 -0
- package/dist/naming/NamingStrategy.js.map +1 -1
- package/dist/schema/Migration.d.ts +26 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +33 -24
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +43 -32
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +211 -26
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +57 -0
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +138 -3
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts.map +1 -1
- package/dist/schema/SchemaCheck.js +3 -1
- package/dist/schema/SchemaCheck.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +247 -8
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +607 -41
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +47 -0
- package/dist/schema/catalog.d.ts.map +1 -0
- package/dist/schema/catalog.js +111 -0
- package/dist/schema/catalog.js.map +1 -0
- package/dist/schema/introspect.js.map +1 -1
- package/dist/schema/types.d.ts +150 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +11 -0
- package/dist/schema/types.js.map +1 -1
- package/dist/services/db.d.ts +6 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +17 -0
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +7 -4
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +21 -18
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/Factory.d.ts +70 -5
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +209 -10
- package/dist/testing/Factory.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +4 -1
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +8 -1
- package/src/BaseEntity.ts +449 -40
- package/src/BaseModel.ts +324 -0
- package/src/BaseRepository.ts +1659 -371
- package/src/ModelQuery.ts +2290 -203
- package/src/Transaction.ts +68 -5
- package/src/adapters/NapiDbAdapter.ts +159 -10
- package/src/console/migrationCommands.ts +258 -0
- package/src/decorators/entity.ts +53 -6
- package/src/events.ts +112 -0
- package/src/index.ts +19 -0
- package/src/metadata-keys.ts +3 -2
- package/src/naming/NamingStrategy.ts +23 -0
- package/src/schema/Migration.ts +42 -3
- package/src/schema/MigrationRunner.ts +270 -27
- package/src/schema/Schema.ts +210 -3
- package/src/schema/SchemaCheck.ts +7 -2
- package/src/schema/TableBuilder.ts +735 -41
- package/src/schema/catalog.ts +166 -0
- package/src/schema/introspect.ts +3 -4
- package/src/schema/types.ts +137 -2
- package/src/services/db.ts +28 -0
- package/src/testing/DatabaseCleanup.ts +23 -22
- package/src/testing/Factory.ts +332 -15
package/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";
|
|
@@ -180,27 +357,84 @@ interface HavingClause {
|
|
|
180
357
|
type: "and" | "or";
|
|
181
358
|
}
|
|
182
359
|
|
|
360
|
+
/** A raw SQL HAVING fragment with `?` bindings — kind-tagged for the Rust compiler. */
|
|
361
|
+
interface HavingRawClause {
|
|
362
|
+
kind: "raw";
|
|
363
|
+
sql: string;
|
|
364
|
+
bindings: unknown[];
|
|
365
|
+
type: "and" | "or";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
type HavingEntry = HavingClause | HavingRawClause;
|
|
369
|
+
|
|
370
|
+
/** A compiled CTE (`WITH name AS (...)`) — the sub-select is pre-compiled to SQL + params. */
|
|
371
|
+
interface CteSpec {
|
|
372
|
+
name: string;
|
|
373
|
+
sql: string;
|
|
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;
|
|
383
|
+
}
|
|
384
|
+
|
|
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
|
+
*/
|
|
390
|
+
interface UnionSpec {
|
|
391
|
+
sql: string;
|
|
392
|
+
params: unknown[];
|
|
393
|
+
all: boolean;
|
|
394
|
+
/** Defaults to `union` when absent/null, keeping the pre-existing wire format. */
|
|
395
|
+
op?: "union" | "intersect" | "except" | null;
|
|
396
|
+
}
|
|
397
|
+
|
|
183
398
|
interface SubqueryProjection {
|
|
184
399
|
alias: string;
|
|
185
400
|
subquery: SelectSpec;
|
|
186
401
|
}
|
|
187
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
|
+
|
|
188
419
|
interface SelectSpec {
|
|
189
420
|
kind: "select";
|
|
190
421
|
table: string;
|
|
191
422
|
select: string[];
|
|
192
423
|
selectSubqueries: SubqueryProjection[];
|
|
193
424
|
wheres: WhereClause[];
|
|
194
|
-
orderBy:
|
|
195
|
-
groupBy:
|
|
196
|
-
having:
|
|
425
|
+
orderBy: OrderByEntry[];
|
|
426
|
+
groupBy: GroupByEntry[];
|
|
427
|
+
having: HavingEntry[];
|
|
197
428
|
limit: number | null;
|
|
198
429
|
offset: number | null;
|
|
199
430
|
distinct: boolean;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
431
|
+
distinctOn: string[];
|
|
432
|
+
ctes: CteSpec[];
|
|
433
|
+
unions: UnionSpec[];
|
|
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;
|
|
204
438
|
}
|
|
205
439
|
|
|
206
440
|
type WhereClause =
|
|
@@ -208,15 +442,18 @@ type WhereClause =
|
|
|
208
442
|
| RawWhere
|
|
209
443
|
| ExistsWhere
|
|
210
444
|
| GroupWhere
|
|
211
|
-
| InSubWhere
|
|
445
|
+
| InSubWhere
|
|
446
|
+
| JsonWhere;
|
|
212
447
|
|
|
213
448
|
type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
|
|
214
449
|
|
|
215
450
|
/**
|
|
216
|
-
* Process-wide strict mode flag. When enabled, `whereRaw()
|
|
217
|
-
* throw unconditionally — forcing every
|
|
218
|
-
* `whereExpr()` / `joinOn()` /
|
|
219
|
-
*
|
|
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.
|
|
220
457
|
*
|
|
221
458
|
* Enable via:
|
|
222
459
|
* - `setAtlasStrictMode(true)` at app bootstrap
|
|
@@ -228,7 +465,7 @@ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
|
|
|
228
465
|
*/
|
|
229
466
|
let atlasStrictMode: boolean | undefined;
|
|
230
467
|
|
|
231
|
-
/** 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. */
|
|
232
469
|
export function setAtlasStrictMode(enabled: boolean): void {
|
|
233
470
|
atlasStrictMode = enabled;
|
|
234
471
|
}
|
|
@@ -262,12 +499,26 @@ function isInternalBypass(): boolean {
|
|
|
262
499
|
return atlasInternalBypass;
|
|
263
500
|
}
|
|
264
501
|
|
|
265
|
-
/**
|
|
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
|
+
*/
|
|
266
508
|
interface JoinBuilder {
|
|
267
|
-
|
|
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
|
+
}>;
|
|
268
516
|
on(left: string, right: string): JoinBuilder;
|
|
269
517
|
andOn(left: string, right: string): JoinBuilder;
|
|
518
|
+
orOn(left: string, right: string): JoinBuilder;
|
|
519
|
+
onVal(left: string, value: unknown): JoinBuilder;
|
|
270
520
|
andOnVal(left: string, value: unknown): JoinBuilder;
|
|
521
|
+
orOnVal(left: string, value: unknown): JoinBuilder;
|
|
271
522
|
}
|
|
272
523
|
|
|
273
524
|
/** Offset-based paginator (Story 29.10). */
|
|
@@ -282,23 +533,59 @@ export class Paginator<T> {
|
|
|
282
533
|
};
|
|
283
534
|
#baseUrl?: string;
|
|
284
535
|
#queryString: Record<string, unknown> = {};
|
|
536
|
+
#metaKeys?: Record<string, string>;
|
|
285
537
|
|
|
286
538
|
constructor(
|
|
287
539
|
items: T[],
|
|
288
540
|
base: { total: number; perPage: number; currentPage: number },
|
|
541
|
+
metaKeys?: Record<string, string>,
|
|
289
542
|
) {
|
|
290
543
|
this.items = items;
|
|
291
544
|
const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
|
|
292
545
|
this.meta = { ...base, lastPage, firstPage: 1 };
|
|
546
|
+
this.#metaKeys = metaKeys;
|
|
293
547
|
}
|
|
294
548
|
|
|
295
549
|
all(): T[] {
|
|
296
550
|
return this.items;
|
|
297
551
|
}
|
|
298
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
|
+
|
|
576
|
+
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
577
|
+
get hasPages(): boolean {
|
|
578
|
+
return this.meta.lastPage > 1;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** True when there is at least one more page after the current one (AdonisJS `hasMorePages`). */
|
|
582
|
+
get hasMorePages(): boolean {
|
|
583
|
+
return this.meta.currentPage < this.meta.lastPage;
|
|
584
|
+
}
|
|
585
|
+
|
|
299
586
|
serialize(opts?: { fields?: string[] }): {
|
|
300
587
|
data: unknown[];
|
|
301
|
-
meta:
|
|
588
|
+
meta: Record<string, unknown>;
|
|
302
589
|
} {
|
|
303
590
|
const data = this.items.map((item) => {
|
|
304
591
|
if (!opts?.fields) return item;
|
|
@@ -307,7 +594,9 @@ export class Paginator<T> {
|
|
|
307
594
|
picked[f] = (item as Record<string, unknown>)[f];
|
|
308
595
|
return picked;
|
|
309
596
|
});
|
|
310
|
-
|
|
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() };
|
|
311
600
|
}
|
|
312
601
|
|
|
313
602
|
baseUrl(url: string): this {
|
|
@@ -319,29 +608,76 @@ export class Paginator<T> {
|
|
|
319
608
|
return this;
|
|
320
609
|
}
|
|
321
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
|
+
|
|
322
676
|
toJSON(): {
|
|
323
677
|
data: unknown[];
|
|
324
|
-
meta:
|
|
678
|
+
meta: Record<string, unknown>;
|
|
325
679
|
} {
|
|
326
|
-
|
|
327
|
-
...this.meta,
|
|
328
|
-
};
|
|
329
|
-
if (this.#baseUrl) {
|
|
330
|
-
const build = (page: number) => {
|
|
331
|
-
const params = new URLSearchParams();
|
|
332
|
-
for (const [k, v] of Object.entries(this.#queryString))
|
|
333
|
-
params.set(k, String(v));
|
|
334
|
-
params.set("page", String(page));
|
|
335
|
-
return `${this.#baseUrl}?${params.toString()}`;
|
|
336
|
-
};
|
|
337
|
-
meta.firstPageUrl = build(1);
|
|
338
|
-
meta.lastPageUrl = build(this.meta.lastPage);
|
|
339
|
-
if (this.meta.currentPage < this.meta.lastPage)
|
|
340
|
-
meta.nextPageUrl = build(this.meta.currentPage + 1);
|
|
341
|
-
if (this.meta.currentPage > 1)
|
|
342
|
-
meta.previousPageUrl = build(this.meta.currentPage - 1);
|
|
343
|
-
}
|
|
344
|
-
return { data: this.items as unknown[], meta };
|
|
680
|
+
return { data: this.items as unknown[], meta: this.#buildMeta() };
|
|
345
681
|
}
|
|
346
682
|
}
|
|
347
683
|
|
|
@@ -359,7 +695,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
359
695
|
#softDeletes: boolean;
|
|
360
696
|
#softScope: SoftDeleteScope = "default";
|
|
361
697
|
#wheres: WhereClause[] = [];
|
|
362
|
-
#orderBys:
|
|
698
|
+
#orderBys: OrderByEntry[] = [];
|
|
363
699
|
#select: string[] = ["*"];
|
|
364
700
|
#limit?: number;
|
|
365
701
|
#offset?: number;
|
|
@@ -369,13 +705,62 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
369
705
|
/** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
|
|
370
706
|
#subqueryAlias?: string;
|
|
371
707
|
/** Raw JOIN fragments — Story 29.4. */
|
|
372
|
-
#joins: string[] = [];
|
|
373
|
-
/** Row lock mode — Story 30.8. */
|
|
374
|
-
#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;
|
|
375
720
|
/** Per-query debug flag — Story 29.11. */
|
|
376
721
|
#debugFlag = false;
|
|
377
722
|
/** Distinct flag — Story 29.5. */
|
|
378
723
|
#distinct = false;
|
|
724
|
+
#distinctOn: string[] = [];
|
|
725
|
+
/** GROUP BY columns (Lucid parity). */
|
|
726
|
+
#groupBy: GroupByEntry[] = [];
|
|
727
|
+
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
728
|
+
#having: HavingEntry[] = [];
|
|
729
|
+
/** CTEs registered via `.with()` (Lucid parity). */
|
|
730
|
+
#ctes: Array<{
|
|
731
|
+
name: string;
|
|
732
|
+
query: ModelQuery<BaseEntity>;
|
|
733
|
+
recursive?: boolean;
|
|
734
|
+
materialized?: boolean;
|
|
735
|
+
}> = [];
|
|
736
|
+
/** UNION / UNION ALL branches (Lucid parity). */
|
|
737
|
+
#unions: Array<{
|
|
738
|
+
query: ModelQuery<BaseEntity>;
|
|
739
|
+
all: boolean;
|
|
740
|
+
op?: "union" | "intersect" | "except";
|
|
741
|
+
}> = [];
|
|
742
|
+
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
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[] };
|
|
379
764
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
380
765
|
#dialect: AtlasDialect;
|
|
381
766
|
|
|
@@ -387,6 +772,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
387
772
|
resolveColumn: ColumnResolver = (c) => c,
|
|
388
773
|
softDeletes = false,
|
|
389
774
|
dialect: AtlasDialect = getAtlasDialect(),
|
|
775
|
+
prepareValue: ValuePreparer = (_c, v) => v,
|
|
776
|
+
onDomainEvents?: (events: DomainEvent[]) => Promise<void>,
|
|
390
777
|
) {
|
|
391
778
|
this.#tableName = tableName;
|
|
392
779
|
this.#db = db;
|
|
@@ -395,8 +782,15 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
395
782
|
this.#resolveColumn = resolveColumn;
|
|
396
783
|
this.#softDeletes = softDeletes;
|
|
397
784
|
this.#dialect = dialect;
|
|
785
|
+
this.#prepareValue = prepareValue;
|
|
786
|
+
this.#onDomainEvents = onDomainEvents;
|
|
398
787
|
}
|
|
399
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
|
+
|
|
400
794
|
/** Include soft-deleted rows in the result (default behavior excludes them). */
|
|
401
795
|
withTrashed(): this {
|
|
402
796
|
this.#softScope = "with-trashed";
|
|
@@ -424,12 +818,31 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
424
818
|
|
|
425
819
|
/** Select specific columns (default: `*`). Accepts a comma-separated string or an array. */
|
|
426
820
|
select(columns: string | string[]): this {
|
|
427
|
-
|
|
821
|
+
const list = Array.isArray(columns)
|
|
428
822
|
? columns
|
|
429
823
|
: columns.split(",").map((c) => c.trim());
|
|
824
|
+
this.#select = list.map((c) => this.#resolveSelect(c));
|
|
430
825
|
return this;
|
|
431
826
|
}
|
|
432
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
|
+
|
|
433
846
|
where(callback: WhereCallback): this;
|
|
434
847
|
where(column: string, value: unknown): this;
|
|
435
848
|
where(column: string, operator: string, value: unknown): this;
|
|
@@ -480,13 +893,88 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
480
893
|
return this;
|
|
481
894
|
}
|
|
482
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
|
+
|
|
483
971
|
/** `WHERE col != ?` — negation of `where`. */
|
|
484
972
|
whereNot(column: string, value: unknown): this {
|
|
485
973
|
this.#wheres.push({
|
|
486
974
|
type: "and",
|
|
487
975
|
column: this.#resolveColumn(column),
|
|
488
976
|
operator: "!=",
|
|
489
|
-
value,
|
|
977
|
+
value: this.#prep(column, value),
|
|
490
978
|
});
|
|
491
979
|
return this;
|
|
492
980
|
}
|
|
@@ -510,7 +998,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
510
998
|
type: "and",
|
|
511
999
|
column: this.#resolveColumn(column),
|
|
512
1000
|
operator: "IN",
|
|
513
|
-
value: [...source],
|
|
1001
|
+
value: this.#prep(column, [...source]),
|
|
514
1002
|
});
|
|
515
1003
|
return this;
|
|
516
1004
|
}
|
|
@@ -534,7 +1022,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
534
1022
|
type: "and",
|
|
535
1023
|
column: this.#resolveColumn(column),
|
|
536
1024
|
operator: "NOT IN",
|
|
537
|
-
value: [...source],
|
|
1025
|
+
value: this.#prep(column, [...source]),
|
|
538
1026
|
});
|
|
539
1027
|
return this;
|
|
540
1028
|
}
|
|
@@ -545,7 +1033,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
545
1033
|
type: "and",
|
|
546
1034
|
column: this.#resolveColumn(column),
|
|
547
1035
|
operator: "BETWEEN",
|
|
548
|
-
value: [...range],
|
|
1036
|
+
value: this.#prep(column, [...range]),
|
|
549
1037
|
});
|
|
550
1038
|
return this;
|
|
551
1039
|
}
|
|
@@ -556,7 +1044,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
556
1044
|
type: "and",
|
|
557
1045
|
column: this.#resolveColumn(column),
|
|
558
1046
|
operator: "NOT BETWEEN",
|
|
559
|
-
value: [...range],
|
|
1047
|
+
value: this.#prep(column, [...range]),
|
|
560
1048
|
});
|
|
561
1049
|
return this;
|
|
562
1050
|
}
|
|
@@ -587,6 +1075,135 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
587
1075
|
return this;
|
|
588
1076
|
}
|
|
589
1077
|
|
|
1078
|
+
// ─── OR-combined variants (AdonisJS orWhere* family) ─────────
|
|
1079
|
+
// Same predicates as the whereX methods above, combined with OR instead of
|
|
1080
|
+
// AND — the named ergonomics Lucid exposes (vs emulating with `orWhere(cb)`).
|
|
1081
|
+
|
|
1082
|
+
/** `OR col IS NULL`. */
|
|
1083
|
+
orWhereNull(column: string): this {
|
|
1084
|
+
this.#wheres.push({
|
|
1085
|
+
type: "or",
|
|
1086
|
+
column: this.#resolveColumn(column),
|
|
1087
|
+
operator: "IS NULL",
|
|
1088
|
+
value: null,
|
|
1089
|
+
});
|
|
1090
|
+
return this;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** `OR col IS NOT NULL`. */
|
|
1094
|
+
orWhereNotNull(column: string): this {
|
|
1095
|
+
this.#wheres.push({
|
|
1096
|
+
type: "or",
|
|
1097
|
+
column: this.#resolveColumn(column),
|
|
1098
|
+
operator: "IS NOT NULL",
|
|
1099
|
+
value: null,
|
|
1100
|
+
});
|
|
1101
|
+
return this;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** `OR col != ?`. */
|
|
1105
|
+
orWhereNot(column: string, value: unknown): this {
|
|
1106
|
+
this.#wheres.push({
|
|
1107
|
+
type: "or",
|
|
1108
|
+
column: this.#resolveColumn(column),
|
|
1109
|
+
operator: "!=",
|
|
1110
|
+
value: this.#prep(column, value),
|
|
1111
|
+
});
|
|
1112
|
+
return this;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** `OR col IN (...)` — array or `ModelQuery` subquery source. */
|
|
1116
|
+
orWhereIn(
|
|
1117
|
+
column: string,
|
|
1118
|
+
source: readonly unknown[] | ModelQuery<BaseEntity>,
|
|
1119
|
+
): this {
|
|
1120
|
+
if (source instanceof ModelQuery) {
|
|
1121
|
+
this.#wheres.push({
|
|
1122
|
+
type: "or",
|
|
1123
|
+
kind: "inSub",
|
|
1124
|
+
negated: false,
|
|
1125
|
+
column: this.#resolveColumn(column),
|
|
1126
|
+
subquery: source.#buildSpec(),
|
|
1127
|
+
});
|
|
1128
|
+
return this;
|
|
1129
|
+
}
|
|
1130
|
+
this.#wheres.push({
|
|
1131
|
+
type: "or",
|
|
1132
|
+
column: this.#resolveColumn(column),
|
|
1133
|
+
operator: "IN",
|
|
1134
|
+
value: this.#prep(column, [...source]),
|
|
1135
|
+
});
|
|
1136
|
+
return this;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** `OR col NOT IN (...)` — array or `ModelQuery` subquery source. */
|
|
1140
|
+
orWhereNotIn(
|
|
1141
|
+
column: string,
|
|
1142
|
+
source: readonly unknown[] | ModelQuery<BaseEntity>,
|
|
1143
|
+
): this {
|
|
1144
|
+
if (source instanceof ModelQuery) {
|
|
1145
|
+
this.#wheres.push({
|
|
1146
|
+
type: "or",
|
|
1147
|
+
kind: "inSub",
|
|
1148
|
+
negated: true,
|
|
1149
|
+
column: this.#resolveColumn(column),
|
|
1150
|
+
subquery: source.#buildSpec(),
|
|
1151
|
+
});
|
|
1152
|
+
return this;
|
|
1153
|
+
}
|
|
1154
|
+
this.#wheres.push({
|
|
1155
|
+
type: "or",
|
|
1156
|
+
column: this.#resolveColumn(column),
|
|
1157
|
+
operator: "NOT IN",
|
|
1158
|
+
value: this.#prep(column, [...source]),
|
|
1159
|
+
});
|
|
1160
|
+
return this;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** `OR col BETWEEN ? AND ?`. */
|
|
1164
|
+
orWhereBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
1165
|
+
this.#wheres.push({
|
|
1166
|
+
type: "or",
|
|
1167
|
+
column: this.#resolveColumn(column),
|
|
1168
|
+
operator: "BETWEEN",
|
|
1169
|
+
value: this.#prep(column, [...range]),
|
|
1170
|
+
});
|
|
1171
|
+
return this;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** `OR col NOT BETWEEN ? AND ?`. */
|
|
1175
|
+
orWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
1176
|
+
this.#wheres.push({
|
|
1177
|
+
type: "or",
|
|
1178
|
+
column: this.#resolveColumn(column),
|
|
1179
|
+
operator: "NOT BETWEEN",
|
|
1180
|
+
value: this.#prep(column, [...range]),
|
|
1181
|
+
});
|
|
1182
|
+
return this;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** `OR col LIKE ?`. */
|
|
1186
|
+
orWhereLike(column: string, pattern: string): this {
|
|
1187
|
+
this.#wheres.push({
|
|
1188
|
+
type: "or",
|
|
1189
|
+
column: this.#resolveColumn(column),
|
|
1190
|
+
operator: "LIKE",
|
|
1191
|
+
value: pattern,
|
|
1192
|
+
});
|
|
1193
|
+
return this;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** `OR col ILIKE ?` (rewritten to LOWER() LIKE LOWER() on sqlite/mysql). */
|
|
1197
|
+
orWhereILike(column: string, pattern: string): this {
|
|
1198
|
+
this.#wheres.push({
|
|
1199
|
+
type: "or",
|
|
1200
|
+
column: this.#resolveColumn(column),
|
|
1201
|
+
operator: "ILIKE",
|
|
1202
|
+
value: pattern,
|
|
1203
|
+
});
|
|
1204
|
+
return this;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
590
1207
|
/**
|
|
591
1208
|
* **⚠ UNSAFE** — append a raw SQL fragment to the WHERE clause with
|
|
592
1209
|
* `?`-style bindings. The Rust compiler re-indexes the placeholders so they
|
|
@@ -629,9 +1246,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
629
1246
|
* Not exported from the package barrel — only accessible inside the Atlas
|
|
630
1247
|
* codebase via direct ModelQuery instance access.
|
|
631
1248
|
*/
|
|
632
|
-
#pushWhereRaw(
|
|
1249
|
+
#pushWhereRaw(
|
|
1250
|
+
sql: string,
|
|
1251
|
+
bindings: readonly unknown[] = [],
|
|
1252
|
+
type: "and" | "or" = "and",
|
|
1253
|
+
): this {
|
|
633
1254
|
this.#wheres.push({
|
|
634
|
-
type
|
|
1255
|
+
type,
|
|
635
1256
|
kind: "raw",
|
|
636
1257
|
sql,
|
|
637
1258
|
bindings: [...bindings],
|
|
@@ -639,6 +1260,52 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
639
1260
|
return this;
|
|
640
1261
|
}
|
|
641
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
|
+
|
|
642
1309
|
/**
|
|
643
1310
|
* **SAFE** alternative to `whereRaw` for the common case of a single
|
|
644
1311
|
* SQL expression built from a validated column + operator + bound value.
|
|
@@ -689,6 +1356,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
689
1356
|
`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
|
|
690
1357
|
);
|
|
691
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
|
+
}
|
|
692
1373
|
// `op` is interpolated raw into the fragment below, so it MUST be
|
|
693
1374
|
// allow-listed — the 3-arg path gets this from the Rust operator
|
|
694
1375
|
// validation, but the raw 4-arg path bypasses Rust and would
|
|
@@ -707,16 +1388,328 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
707
1388
|
// the operator against the allow-list above.
|
|
708
1389
|
if (hasExtra) {
|
|
709
1390
|
const q = this.#quote(resolved);
|
|
710
|
-
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1391
|
+
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1392
|
+
this.#prep(column, value),
|
|
1393
|
+
]);
|
|
711
1394
|
}
|
|
712
|
-
this.#wheres.push({
|
|
1395
|
+
this.#wheres.push({
|
|
1396
|
+
type: "and",
|
|
1397
|
+
column: resolved,
|
|
1398
|
+
operator: op,
|
|
1399
|
+
value: this.#prep(column, value),
|
|
1400
|
+
});
|
|
713
1401
|
return this;
|
|
714
1402
|
}
|
|
715
1403
|
|
|
716
1404
|
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
1405
|
+
* Compare two COLUMNS (AdonisJS/Knex `whereColumn`) — `WHERE "a" op "b"`.
|
|
1406
|
+
* Both sides go through the identifier quoter (injection-safe) and the
|
|
1407
|
+
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
1408
|
+
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
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
|
+
|
|
1610
|
+
whereColumn(left: string, operator: string, right: string): this {
|
|
1611
|
+
return this.#whereColumn("and", left, operator, right);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** `OR`-combined {@link whereColumn}. */
|
|
1615
|
+
orWhereColumn(left: string, operator: string, right: string): this {
|
|
1616
|
+
return this.#whereColumn("or", left, operator, right);
|
|
1617
|
+
}
|
|
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
|
+
|
|
1634
|
+
#whereColumn(
|
|
1635
|
+
type: "and" | "or",
|
|
1636
|
+
left: string,
|
|
1637
|
+
operator: string,
|
|
1638
|
+
right: string,
|
|
1639
|
+
negated = false,
|
|
1640
|
+
): this {
|
|
1641
|
+
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`,
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
// Both operands are interpolated as raw identifiers (no value binding for a
|
|
1647
|
+
// column reference), and #quote is a plain wrapper that does NOT escape an
|
|
1648
|
+
// embedded quote — so validate each RESOLVED identifier against a strict
|
|
1649
|
+
// `[table.]column` charset. This closes the injection surface regardless of
|
|
1650
|
+
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
1651
|
+
const safe = (name: string): string => {
|
|
1652
|
+
const resolved = this.#resolveColumnReference(name);
|
|
1653
|
+
if (
|
|
1654
|
+
!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
|
|
1655
|
+
) {
|
|
1656
|
+
throw new Error(
|
|
1657
|
+
`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`,
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
// Quote each dotted segment separately → `"table"."column"`, never a
|
|
1661
|
+
// single mis-quoted `"table.column"`.
|
|
1662
|
+
return resolved
|
|
1663
|
+
.split(".")
|
|
1664
|
+
.map((part) => this.#quote(part))
|
|
1665
|
+
.join(".");
|
|
1666
|
+
};
|
|
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;
|
|
1671
|
+
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
1672
|
+
return this;
|
|
1673
|
+
}
|
|
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
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
1711
|
+
* by the existence of related rows, optionally constrained by a callback.
|
|
1712
|
+
*
|
|
720
1713
|
* userRepo.query().whereHas('comments', q => q.where('approved', true))
|
|
721
1714
|
*/
|
|
722
1715
|
whereHas(
|
|
@@ -761,6 +1754,22 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
761
1754
|
return this;
|
|
762
1755
|
}
|
|
763
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
|
+
|
|
764
1773
|
/**
|
|
765
1774
|
* Short form of `whereHas`. With an operator + count, emits a count threshold:
|
|
766
1775
|
* has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
|
|
@@ -800,6 +1809,26 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
800
1809
|
return this;
|
|
801
1810
|
}
|
|
802
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
|
+
|
|
803
1832
|
/**
|
|
804
1833
|
* Set this query's projection alias — only meaningful when this ModelQuery
|
|
805
1834
|
* is used as the sub-builder callback argument of `withCount` / `withAggregate`.
|
|
@@ -844,27 +1873,37 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
844
1873
|
/** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
|
|
845
1874
|
async count(column: string = "*"): Promise<number> {
|
|
846
1875
|
const expr =
|
|
847
|
-
column === "*"
|
|
1876
|
+
column === "*"
|
|
1877
|
+
? "COUNT(*)"
|
|
1878
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
|
|
848
1879
|
return Number((await this.#runScalar(expr)) ?? 0);
|
|
849
1880
|
}
|
|
850
1881
|
|
|
851
1882
|
async sum(column: string): Promise<number | null> {
|
|
852
|
-
const v = await this.#runScalar(
|
|
1883
|
+
const v = await this.#runScalar(
|
|
1884
|
+
`SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1885
|
+
);
|
|
853
1886
|
return v === null || v === undefined ? null : Number(v);
|
|
854
1887
|
}
|
|
855
1888
|
|
|
856
1889
|
async avg(column: string): Promise<number | null> {
|
|
857
|
-
const v = await this.#runScalar(
|
|
1890
|
+
const v = await this.#runScalar(
|
|
1891
|
+
`AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1892
|
+
);
|
|
858
1893
|
return v === null || v === undefined ? null : Number(v);
|
|
859
1894
|
}
|
|
860
1895
|
|
|
861
1896
|
async min(column: string): Promise<number | null> {
|
|
862
|
-
const v = await this.#runScalar(
|
|
1897
|
+
const v = await this.#runScalar(
|
|
1898
|
+
`MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1899
|
+
);
|
|
863
1900
|
return v === null || v === undefined ? null : Number(v);
|
|
864
1901
|
}
|
|
865
1902
|
|
|
866
1903
|
async max(column: string): Promise<number | null> {
|
|
867
|
-
const v = await this.#runScalar(
|
|
1904
|
+
const v = await this.#runScalar(
|
|
1905
|
+
`MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
1906
|
+
);
|
|
868
1907
|
return v === null || v === undefined ? null : Number(v);
|
|
869
1908
|
}
|
|
870
1909
|
|
|
@@ -881,42 +1920,417 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
881
1920
|
relationName: string,
|
|
882
1921
|
callback?: (query: ModelQuery<BaseEntity>) => void,
|
|
883
1922
|
): this {
|
|
884
|
-
this.#selectSubqueries.push(
|
|
885
|
-
this.#buildRelationSubquery(
|
|
886
|
-
relationName,
|
|
887
|
-
callback,
|
|
888
|
-
"count",
|
|
889
|
-
`${relationName}_count`,
|
|
890
|
-
),
|
|
891
|
-
);
|
|
892
|
-
return this;
|
|
1923
|
+
this.#selectSubqueries.push(
|
|
1924
|
+
this.#buildRelationSubquery(
|
|
1925
|
+
relationName,
|
|
1926
|
+
callback,
|
|
1927
|
+
"count",
|
|
1928
|
+
`${relationName}_count`,
|
|
1929
|
+
),
|
|
1930
|
+
);
|
|
1931
|
+
return this;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Project any aggregate (sum/avg/min/max/count) of a relation as an extra column.
|
|
1936
|
+
* The callback MUST set the aggregate via `.sum('col')` / `.avg(...)` etc. and
|
|
1937
|
+
* typically also set an alias via `.as('name')`.
|
|
1938
|
+
*
|
|
1939
|
+
* userRepo.query().withAggregate('posts', q => q.sum('views').as('total_views'))
|
|
1940
|
+
*/
|
|
1941
|
+
withAggregate(
|
|
1942
|
+
relationName: string,
|
|
1943
|
+
callback: (query: ModelQuery<BaseEntity>) => void,
|
|
1944
|
+
): this {
|
|
1945
|
+
this.#selectSubqueries.push(
|
|
1946
|
+
this.#buildRelationSubquery(
|
|
1947
|
+
relationName,
|
|
1948
|
+
callback,
|
|
1949
|
+
"aggregate",
|
|
1950
|
+
relationName,
|
|
1951
|
+
),
|
|
1952
|
+
);
|
|
1953
|
+
return this;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
|
|
1957
|
+
this.#orderBys.push({ column: this.#resolveColumn(column), direction });
|
|
1958
|
+
return this;
|
|
1959
|
+
}
|
|
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
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
1983
|
+
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
1984
|
+
* For a grouping expression with no typed form, see {@link groupByRaw}.
|
|
1985
|
+
*/
|
|
1986
|
+
groupBy(...columns: string[]): this {
|
|
1987
|
+
for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
|
|
1988
|
+
return this;
|
|
1989
|
+
}
|
|
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
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
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.
|
|
2014
|
+
*/
|
|
2015
|
+
having(column: string, operator: string, value: unknown): this {
|
|
2016
|
+
this.#having.push({
|
|
2017
|
+
column: this.#resolveHavingCol(column),
|
|
2018
|
+
operator,
|
|
2019
|
+
value: this.#prep(column, value),
|
|
2020
|
+
type: "and",
|
|
2021
|
+
});
|
|
2022
|
+
return this;
|
|
2023
|
+
}
|
|
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
|
+
|
|
2040
|
+
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
2041
|
+
orHaving(column: string, operator: string, value: unknown): this {
|
|
2042
|
+
this.#having.push({
|
|
2043
|
+
column: this.#resolveHavingCol(column),
|
|
2044
|
+
operator,
|
|
2045
|
+
value: this.#prep(column, value),
|
|
2046
|
+
type: "or",
|
|
2047
|
+
});
|
|
2048
|
+
return this;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/**
|
|
2052
|
+
* **⚠ UNSAFE** — append a raw SQL `HAVING` fragment with `?` bindings
|
|
2053
|
+
* (AdonisJS/Lucid `havingRaw`). The Rust compiler re-indexes the placeholders;
|
|
2054
|
+
* everything else in `sql` is trusted verbatim. All values must go through
|
|
2055
|
+
* `bindings`.
|
|
2056
|
+
*
|
|
2057
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
2058
|
+
*/
|
|
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
|
+
}
|
|
2068
|
+
this.#having.push({
|
|
2069
|
+
kind: "raw",
|
|
2070
|
+
sql,
|
|
2071
|
+
bindings: [...bindings],
|
|
2072
|
+
type: "and",
|
|
2073
|
+
});
|
|
2074
|
+
return this;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* `UNION (<query>)` (AdonisJS/Lucid `union`). The other query is compiled and
|
|
2079
|
+
* appended as a parenthesised UNION branch; its bindings are re-indexed into
|
|
2080
|
+
* the outer parameter list.
|
|
2081
|
+
*/
|
|
2082
|
+
union(query: ModelQuery<BaseEntity>): this {
|
|
2083
|
+
this.#unions.push({ query, all: false });
|
|
2084
|
+
return this;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
|
|
2088
|
+
unionAll(query: ModelQuery<BaseEntity>): this {
|
|
2089
|
+
this.#unions.push({ query, all: true });
|
|
2090
|
+
return this;
|
|
2091
|
+
}
|
|
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
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
2125
|
+
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
2126
|
+
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
2127
|
+
*/
|
|
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 {
|
|
2173
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
2174
|
+
throw new Error(
|
|
2175
|
+
`${method}(): CTE name '${name}' is not a valid identifier`,
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
this.#ctes.push({ name, query, ...options });
|
|
2179
|
+
return this;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* `@ManyToMany` only — filter loaded relations by a PIVOT-table column
|
|
2184
|
+
* (AdonisJS/Lucid `wherePivot`). Recorded separately from the related-table
|
|
2185
|
+
* WHEREs and applied to the pivot lookup query by the m2m preload resolver;
|
|
2186
|
+
* inert on non-m2m relations.
|
|
2187
|
+
*
|
|
2188
|
+
* userRepo.query().preload('roles', q => q.wherePivot('active', true))
|
|
2189
|
+
*/
|
|
2190
|
+
wherePivot(column: string, value: unknown): this;
|
|
2191
|
+
wherePivot(column: string, operator: string, value: unknown): this;
|
|
2192
|
+
wherePivot(column: string, operatorOrValue: unknown, value?: unknown): this {
|
|
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]);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/** Alias of {@link whereInPivot} kept for the earlier atlas name. */
|
|
2240
|
+
wherePivotIn(column: string, values: readonly unknown[]): 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);
|
|
893
2314
|
}
|
|
894
2315
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
* userRepo.query().withAggregate('posts', q => q.sum('views').as('total_views'))
|
|
901
|
-
*/
|
|
902
|
-
withAggregate(
|
|
903
|
-
relationName: string,
|
|
904
|
-
callback: (query: ModelQuery<BaseEntity>) => void,
|
|
2316
|
+
#pushPivotOp(
|
|
2317
|
+
type: "and" | "or",
|
|
2318
|
+
column: string,
|
|
2319
|
+
operator: string,
|
|
2320
|
+
value: unknown,
|
|
905
2321
|
): this {
|
|
906
|
-
this.#
|
|
907
|
-
this.#buildRelationSubquery(
|
|
908
|
-
relationName,
|
|
909
|
-
callback,
|
|
910
|
-
"aggregate",
|
|
911
|
-
relationName,
|
|
912
|
-
),
|
|
913
|
-
);
|
|
2322
|
+
this.#pivotWheres.push({ column, operator, value, type });
|
|
914
2323
|
return this;
|
|
915
2324
|
}
|
|
916
2325
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
2326
|
+
/** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
|
|
2327
|
+
get pivotConstraints(): ReadonlyArray<{
|
|
2328
|
+
column: string;
|
|
2329
|
+
operator: string;
|
|
2330
|
+
value: unknown;
|
|
2331
|
+
type: "and" | "or";
|
|
2332
|
+
}> {
|
|
2333
|
+
return this.#pivotWheres;
|
|
920
2334
|
}
|
|
921
2335
|
|
|
922
2336
|
limit(n: number): this {
|
|
@@ -957,6 +2371,24 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
957
2371
|
return result;
|
|
958
2372
|
}
|
|
959
2373
|
|
|
2374
|
+
/**
|
|
2375
|
+
* Return the single matching row, or throw if there are zero OR more than one
|
|
2376
|
+
* (AdonisJS/Laravel `sole`). Use when exactly one row is a correctness
|
|
2377
|
+
* invariant — a second match signals a bug the silent `first()` would hide.
|
|
2378
|
+
*/
|
|
2379
|
+
async sole(): Promise<T> {
|
|
2380
|
+
const rows = await this.limit(2).exec();
|
|
2381
|
+
if (rows.length === 0) {
|
|
2382
|
+
throw new Error(`No ${this.#tableName} found matching query (sole()).`);
|
|
2383
|
+
}
|
|
2384
|
+
if (rows.length > 1) {
|
|
2385
|
+
throw new Error(
|
|
2386
|
+
`Expected exactly one ${this.#tableName} but the query matched multiple rows (sole()).`,
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
return rows[0];
|
|
2390
|
+
}
|
|
2391
|
+
|
|
960
2392
|
/**
|
|
961
2393
|
* Thenable — `await someQuery` is equivalent to `await someQuery.exec()`.
|
|
962
2394
|
* A chain like `await repo.query().where('active', true).orderBy('id')`
|
|
@@ -983,21 +2415,99 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
983
2415
|
}
|
|
984
2416
|
|
|
985
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
|
+
|
|
986
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
|
+
}
|
|
987
2486
|
const wheres: WhereClause[] = [...this.#wheres];
|
|
988
|
-
//
|
|
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).
|
|
989
2498
|
if (this.#softDeletes) {
|
|
2499
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
990
2500
|
if (this.#softScope === "default") {
|
|
991
2501
|
wheres.push({
|
|
992
2502
|
type: "and",
|
|
993
|
-
column:
|
|
2503
|
+
column: deletedAtCol,
|
|
994
2504
|
operator: "IS NULL",
|
|
995
2505
|
value: null,
|
|
996
2506
|
});
|
|
997
2507
|
} else if (this.#softScope === "only-trashed") {
|
|
998
2508
|
wheres.push({
|
|
999
2509
|
type: "and",
|
|
1000
|
-
column:
|
|
2510
|
+
column: deletedAtCol,
|
|
1001
2511
|
operator: "IS NOT NULL",
|
|
1002
2512
|
value: null,
|
|
1003
2513
|
});
|
|
@@ -1008,19 +2518,36 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1008
2518
|
return {
|
|
1009
2519
|
kind: "select",
|
|
1010
2520
|
table: this.#tableName,
|
|
1011
|
-
select:
|
|
2521
|
+
select: selectCols,
|
|
1012
2522
|
selectSubqueries: this.#selectSubqueries,
|
|
1013
2523
|
wheres,
|
|
1014
2524
|
orderBy: this.#orderBys,
|
|
1015
|
-
groupBy:
|
|
1016
|
-
having:
|
|
2525
|
+
groupBy: this.#groupBy,
|
|
2526
|
+
having: this.#having,
|
|
1017
2527
|
limit: this.#limit ?? null,
|
|
1018
2528
|
offset: this.#offset ?? null,
|
|
1019
2529
|
distinct: this.#distinct,
|
|
1020
|
-
|
|
1021
|
-
|
|
2530
|
+
distinctOn: this.#distinctOn,
|
|
2531
|
+
ctes: this.#ctes.map((c) => {
|
|
2532
|
+
const { sql, params } = c.query.toSQL();
|
|
2533
|
+
return {
|
|
2534
|
+
name: c.name,
|
|
2535
|
+
sql,
|
|
2536
|
+
params,
|
|
2537
|
+
recursive: c.recursive ?? false,
|
|
2538
|
+
materialized: c.materialized ?? null,
|
|
2539
|
+
};
|
|
2540
|
+
}),
|
|
2541
|
+
unions: this.#unions.map((u) => {
|
|
2542
|
+
const { sql, params } = u.query.toSQL();
|
|
2543
|
+
return { sql, params, all: u.all, op: u.op ?? null };
|
|
2544
|
+
}),
|
|
1022
2545
|
joins: this.#joins,
|
|
1023
|
-
lockMode: this.#lockMode
|
|
2546
|
+
lockMode: this.#lockMode
|
|
2547
|
+
? this.#lockModifier
|
|
2548
|
+
? `${this.#lockMode} ${this.#lockModifier}`
|
|
2549
|
+
: this.#lockMode
|
|
2550
|
+
: null,
|
|
1024
2551
|
};
|
|
1025
2552
|
}
|
|
1026
2553
|
|
|
@@ -1056,7 +2583,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1056
2583
|
|
|
1057
2584
|
async #doExec(): Promise<T[]> {
|
|
1058
2585
|
const { sql, params } = this.toSQL();
|
|
1059
|
-
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
|
+
);
|
|
1060
2591
|
// Peel withCount / withAggregate alias columns off the raw row into $extras
|
|
1061
2592
|
// BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
|
|
1062
2593
|
const extraKeys = this.#selectSubqueries.map((s) => s.alias);
|
|
@@ -1070,6 +2601,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1070
2601
|
}
|
|
1071
2602
|
const entity = this.#hydrateFn(row);
|
|
1072
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 };
|
|
1073
2606
|
return entity;
|
|
1074
2607
|
});
|
|
1075
2608
|
|
|
@@ -1081,6 +2614,27 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1081
2614
|
return entities;
|
|
1082
2615
|
}
|
|
1083
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
|
+
|
|
1084
2638
|
/** Resolve preloaded relations via batched subqueries (no N+1). */
|
|
1085
2639
|
async #resolvePreloads(entities: T[]): Promise<void> {
|
|
1086
2640
|
if (!this.#entityClass) return;
|
|
@@ -1109,8 +2663,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1109
2663
|
relationName: string,
|
|
1110
2664
|
): PreloadContext | null {
|
|
1111
2665
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1112
|
-
|
|
1113
|
-
|
|
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);
|
|
1114
2671
|
|
|
1115
2672
|
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1116
2673
|
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
@@ -1118,24 +2675,79 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1118
2675
|
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1119
2676
|
const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
|
|
1120
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);
|
|
1121
2691
|
for (const col of getColumnMetadata(relatedClass)) {
|
|
2692
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
1122
2693
|
validColumns.add(col.propertyKey);
|
|
1123
|
-
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;
|
|
1124
2700
|
}
|
|
1125
2701
|
validColumns.add(relatedPkName);
|
|
1126
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
|
+
};
|
|
1127
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;
|
|
1128
2728
|
const hydrate = (row: Record<string, unknown>): BaseEntity => {
|
|
1129
2729
|
const entity = new relatedClass();
|
|
1130
2730
|
for (const [key, value] of Object.entries(row)) {
|
|
1131
2731
|
const camelKey = snakeToCamel(key);
|
|
1132
|
-
const targetKey =
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
?
|
|
1136
|
-
:
|
|
1137
|
-
|
|
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));
|
|
1138
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
|
+
});
|
|
1139
2751
|
return entity;
|
|
1140
2752
|
};
|
|
1141
2753
|
|
|
@@ -1144,7 +2756,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1144
2756
|
relationName,
|
|
1145
2757
|
relatedClass,
|
|
1146
2758
|
relatedTable: relatedMeta.tableName,
|
|
1147
|
-
|
|
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,
|
|
1148
2762
|
hydrate,
|
|
1149
2763
|
runInQuery: (table, column, values) =>
|
|
1150
2764
|
this.#runInQuery(table, column, values),
|
|
@@ -1206,11 +2820,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1206
2820
|
);
|
|
1207
2821
|
}
|
|
1208
2822
|
const throughClass = relation.through() as new () => BaseEntity;
|
|
1209
|
-
const throughMeta =
|
|
1210
|
-
if (!throughMeta)
|
|
1211
|
-
throw new Error(
|
|
1212
|
-
`Entity metadata missing on through class ${throughClass.name}`,
|
|
1213
|
-
);
|
|
2823
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1214
2824
|
const throughTable = throughMeta.tableName;
|
|
1215
2825
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1216
2826
|
const parentLocal =
|
|
@@ -1219,7 +2829,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1219
2829
|
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1220
2830
|
const secondKey =
|
|
1221
2831
|
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1222
|
-
|
|
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
|
+
);
|
|
1223
2839
|
|
|
1224
2840
|
const parentIds = entities
|
|
1225
2841
|
.map((e) => e[parentLocal])
|
|
@@ -1388,13 +3004,72 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1388
3004
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1389
3005
|
const otherKey =
|
|
1390
3006
|
pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1391
|
-
|
|
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";
|
|
1392
3012
|
|
|
1393
3013
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
1394
3014
|
if (ids.length === 0) return [];
|
|
1395
3015
|
|
|
1396
|
-
//
|
|
1397
|
-
|
|
3016
|
+
// Extract PIVOT-table constraints (wherePivot / wherePivotIn) from the
|
|
3017
|
+
// preload callback by replaying it on a throwaway builder. The callback
|
|
3018
|
+
// also runs (again) inside runRelationQuery against the related table; both
|
|
3019
|
+
// runs are pure builder mutations, and pivot constraints are inert there.
|
|
3020
|
+
const pivotWheres: Array<{
|
|
3021
|
+
column: string;
|
|
3022
|
+
operator: string;
|
|
3023
|
+
value: unknown;
|
|
3024
|
+
}> = [];
|
|
3025
|
+
if (ctx.nestedCallback) {
|
|
3026
|
+
const scratch = new ModelQuery<BaseEntity>(
|
|
3027
|
+
ctx.relatedTable,
|
|
3028
|
+
this.#db,
|
|
3029
|
+
(r) => r as BaseEntity,
|
|
3030
|
+
ctx.relatedClass,
|
|
3031
|
+
buildColumnResolver(ctx.relatedClass),
|
|
3032
|
+
false,
|
|
3033
|
+
this.#dialect,
|
|
3034
|
+
buildValuePreparer(ctx.relatedClass),
|
|
3035
|
+
);
|
|
3036
|
+
ctx.nestedCallback(scratch);
|
|
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
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
3067
|
+
const pivotRows = await this.#runInQuery(
|
|
3068
|
+
pivot.pivotTable,
|
|
3069
|
+
foreignKey,
|
|
3070
|
+
ids,
|
|
3071
|
+
pivotWheres,
|
|
3072
|
+
);
|
|
1398
3073
|
if (pivotRows.length === 0) {
|
|
1399
3074
|
for (const entity of entities) entity.setProp(relationName, []);
|
|
1400
3075
|
return [];
|
|
@@ -1405,18 +3080,47 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1405
3080
|
|
|
1406
3081
|
// Step 2 — load all related entities in one query
|
|
1407
3082
|
const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
|
|
3083
|
+
const pivotCols = pivot.pivotColumns ?? [];
|
|
3084
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
3085
|
+
// When pivot extras are projected, each (parent, related) edge gets its OWN
|
|
3086
|
+
// hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
|
|
3087
|
+
// across parents (Lucid gives distinct pivot-bearing instances). Otherwise a
|
|
3088
|
+
// single shared instance per related PK is reused (cheaper, current behaviour).
|
|
3089
|
+
const projectPivot = pivotCols.length > 0;
|
|
3090
|
+
const rawByRelatedPk = new Map<unknown, Record<string, unknown>>();
|
|
1408
3091
|
const byRelatedPk = new Map<unknown, BaseEntity>();
|
|
1409
3092
|
const allRelated: BaseEntity[] = [];
|
|
1410
3093
|
for (const row of relRows) {
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
3094
|
+
rawByRelatedPk.set(row[ctx.relatedPk], row);
|
|
3095
|
+
if (!projectPivot) {
|
|
3096
|
+
const hydrated = ctx.hydrate(row);
|
|
3097
|
+
byRelatedPk.set(row[ctx.relatedPk], hydrated);
|
|
3098
|
+
allRelated.push(hydrated);
|
|
3099
|
+
}
|
|
1414
3100
|
}
|
|
1415
3101
|
|
|
1416
|
-
// Step 3 — group via the pivot
|
|
3102
|
+
// Step 3 — group via the pivot, projecting declared pivotColumns into
|
|
3103
|
+
// `$extras.pivot_<col>` (running each column's `consume` adapter if any).
|
|
1417
3104
|
const grouped = new Map<unknown, BaseEntity[]>();
|
|
1418
3105
|
for (const pivotRow of pivotRows) {
|
|
1419
|
-
|
|
3106
|
+
let related: BaseEntity | undefined;
|
|
3107
|
+
if (projectPivot) {
|
|
3108
|
+
const raw = rawByRelatedPk.get(pivotRow[otherKey]);
|
|
3109
|
+
if (!raw) continue;
|
|
3110
|
+
related = ctx.hydrate(raw);
|
|
3111
|
+
for (const col of pivotCols) {
|
|
3112
|
+
const rawVal = pivotRow[col];
|
|
3113
|
+
const adapter = pivotAdapters[col];
|
|
3114
|
+
related.setExtra(
|
|
3115
|
+
`pivot_${col}`,
|
|
3116
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
3117
|
+
adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal,
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
allRelated.push(related);
|
|
3121
|
+
} else {
|
|
3122
|
+
related = byRelatedPk.get(pivotRow[otherKey]);
|
|
3123
|
+
}
|
|
1420
3124
|
if (!related) continue;
|
|
1421
3125
|
const parentId = pivotRow[foreignKey];
|
|
1422
3126
|
if (!grouped.has(parentId)) grouped.set(parentId, []);
|
|
@@ -1440,6 +3144,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1440
3144
|
this.#db,
|
|
1441
3145
|
(r) => ctx.hydrate(r),
|
|
1442
3146
|
ctx.relatedClass,
|
|
3147
|
+
buildColumnResolver(ctx.relatedClass),
|
|
3148
|
+
hasSoftDeletes(ctx.relatedClass),
|
|
3149
|
+
this.#dialect,
|
|
3150
|
+
buildValuePreparer(ctx.relatedClass),
|
|
1443
3151
|
);
|
|
1444
3152
|
ctx.nestedCallback(sub);
|
|
1445
3153
|
if (sub.#preloads.size > 0) {
|
|
@@ -1452,13 +3160,40 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1452
3160
|
table: string,
|
|
1453
3161
|
column: string,
|
|
1454
3162
|
values: unknown[],
|
|
3163
|
+
extraWheres: ReadonlyArray<{
|
|
3164
|
+
column: string;
|
|
3165
|
+
operator: string;
|
|
3166
|
+
value: unknown;
|
|
3167
|
+
type?: "and" | "or";
|
|
3168
|
+
}> = [],
|
|
1455
3169
|
): Promise<Record<string, unknown>[]> {
|
|
3170
|
+
const wheres: Array<Record<string, unknown>> = [
|
|
3171
|
+
{ column, operator: "IN", value: values, type: "and" },
|
|
3172
|
+
];
|
|
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) {
|
|
3180
|
+
wheres.push({
|
|
3181
|
+
kind: "group",
|
|
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
|
+
})),
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
1456
3191
|
const spec = {
|
|
1457
3192
|
kind: "select",
|
|
1458
3193
|
table,
|
|
1459
3194
|
select: ["*"],
|
|
1460
3195
|
selectSubqueries: [],
|
|
1461
|
-
wheres
|
|
3196
|
+
wheres,
|
|
1462
3197
|
orderBy: [],
|
|
1463
3198
|
groupBy: [],
|
|
1464
3199
|
having: [],
|
|
@@ -1501,7 +3236,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1501
3236
|
this.#db,
|
|
1502
3237
|
(row) => row as BaseEntity,
|
|
1503
3238
|
relatedClass,
|
|
1504
|
-
|
|
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),
|
|
1505
3243
|
// Propagate the RELATED entity's soft-delete flag — hardcoding
|
|
1506
3244
|
// false here meant `preload('posts')` returned soft-deleted
|
|
1507
3245
|
// posts even when Post is @SoftDeletes (a data leak). The
|
|
@@ -1511,6 +3249,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1511
3249
|
// preload callback.)
|
|
1512
3250
|
hasSoftDeletes(relatedClass),
|
|
1513
3251
|
this.#dialect,
|
|
3252
|
+
buildValuePreparer(relatedClass),
|
|
1514
3253
|
);
|
|
1515
3254
|
sub.whereIn(column, values);
|
|
1516
3255
|
if (relation.onQuery) relation.onQuery(sub as unknown);
|
|
@@ -1559,44 +3298,67 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1559
3298
|
);
|
|
1560
3299
|
}
|
|
1561
3300
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1562
|
-
const relatedMeta =
|
|
1563
|
-
if (!relatedMeta) {
|
|
1564
|
-
throw new Error(
|
|
1565
|
-
`Entity metadata missing on related class ${relatedClass.name}`,
|
|
1566
|
-
);
|
|
1567
|
-
}
|
|
3301
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1568
3302
|
const relatedTable = relatedMeta.tableName;
|
|
1569
3303
|
const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
1570
3304
|
const parentTable = this.#tableName;
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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(".");
|
|
1575
3322
|
|
|
1576
3323
|
const sub = new ModelQuery<BaseEntity>(
|
|
1577
3324
|
relatedTable,
|
|
1578
3325
|
this.#db,
|
|
1579
3326
|
(row) => row as BaseEntity,
|
|
1580
3327
|
relatedClass,
|
|
1581
|
-
|
|
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),
|
|
1582
3331
|
false,
|
|
1583
3332
|
this.#dialect,
|
|
3333
|
+
buildValuePreparer(relatedClass),
|
|
1584
3334
|
);
|
|
1585
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);
|
|
1586
3341
|
switch (relation.type) {
|
|
1587
3342
|
case "hasOne":
|
|
1588
3343
|
case "hasMany": {
|
|
1589
|
-
|
|
3344
|
+
// Honour custom foreignKey/localKey exactly like the eager loader —
|
|
3345
|
+
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
3346
|
+
const fk =
|
|
3347
|
+
relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
3348
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
1590
3349
|
sub.#pushWhereRaw(
|
|
1591
|
-
`${
|
|
3350
|
+
`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
|
|
1592
3351
|
);
|
|
1593
3352
|
break;
|
|
1594
3353
|
}
|
|
1595
3354
|
case "belongsTo": {
|
|
1596
|
-
const fk =
|
|
1597
|
-
|
|
3355
|
+
const fk =
|
|
3356
|
+
relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
3357
|
+
const ownerKey = buildColumnResolver(relatedClass)(
|
|
3358
|
+
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
|
|
3359
|
+
);
|
|
1598
3360
|
sub.#pushWhereRaw(
|
|
1599
|
-
`${
|
|
3361
|
+
`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`,
|
|
1600
3362
|
);
|
|
1601
3363
|
break;
|
|
1602
3364
|
}
|
|
@@ -1613,24 +3375,48 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1613
3375
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1614
3376
|
const otherKey =
|
|
1615
3377
|
pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1616
|
-
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);
|
|
1617
3384
|
sub.#pushWhereRaw(
|
|
1618
|
-
`${
|
|
1619
|
-
`(SELECT ${q(otherKey)} FROM ${
|
|
1620
|
-
`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)})`,
|
|
1621
3388
|
);
|
|
1622
3389
|
break;
|
|
1623
3390
|
}
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
3391
|
+
case "hasOneThrough":
|
|
3392
|
+
case "hasManyThrough": {
|
|
3393
|
+
// Two-hop correlated EXISTS: parent → through → related. Mirrors the
|
|
3394
|
+
// eager loader's key resolution (`#resolveThrough`) exactly so
|
|
3395
|
+
// whereHas/withCount agree with what preload() would return.
|
|
3396
|
+
if (!relation.through) {
|
|
3397
|
+
throw new Error(
|
|
3398
|
+
`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`,
|
|
3399
|
+
);
|
|
3400
|
+
}
|
|
3401
|
+
const throughClass = relation.through() as new () => BaseEntity;
|
|
3402
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
3403
|
+
const throughTable = throughMeta.tableName;
|
|
3404
|
+
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
3405
|
+
const parentLocal = resolveParent(relation.localKey ?? parentPk);
|
|
3406
|
+
const firstKey =
|
|
3407
|
+
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
3408
|
+
const secondKey =
|
|
3409
|
+
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
3410
|
+
const secondLocal = buildColumnResolver(throughClass)(
|
|
3411
|
+
relation.secondLocalKey ?? throughPk,
|
|
1633
3412
|
);
|
|
3413
|
+
sub.#pushWhereRaw(
|
|
3414
|
+
`${qTable(relatedTable)}.${q(secondKey)} IN ` +
|
|
3415
|
+
`(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
|
|
3416
|
+
`WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`,
|
|
3417
|
+
);
|
|
3418
|
+
break;
|
|
3419
|
+
}
|
|
1634
3420
|
}
|
|
1635
3421
|
return sub;
|
|
1636
3422
|
}
|
|
@@ -1681,8 +3467,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1681
3467
|
}
|
|
1682
3468
|
|
|
1683
3469
|
crossJoin(table: string): this {
|
|
1684
|
-
const tq = this.#
|
|
1685
|
-
this.#joins.push(`CROSS JOIN ${tq}
|
|
3470
|
+
const tq = this.#quoteCol(table);
|
|
3471
|
+
this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
|
|
1686
3472
|
return this;
|
|
1687
3473
|
}
|
|
1688
3474
|
|
|
@@ -1702,14 +3488,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1702
3488
|
*
|
|
1703
3489
|
* @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
|
|
1704
3490
|
*/
|
|
1705
|
-
joinRaw(fragment: string): this {
|
|
3491
|
+
joinRaw(fragment: string, bindings: readonly unknown[] = []): this {
|
|
1706
3492
|
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1707
3493
|
throw new Error(
|
|
1708
3494
|
"joinRaw() is disabled in Atlas strict mode. " +
|
|
1709
3495
|
"Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
|
|
1710
3496
|
);
|
|
1711
3497
|
}
|
|
1712
|
-
this.#joins.push(fragment);
|
|
3498
|
+
this.#joins.push({ sql: fragment, params: [...bindings] });
|
|
1713
3499
|
return this;
|
|
1714
3500
|
}
|
|
1715
3501
|
|
|
@@ -1735,11 +3521,47 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1735
3521
|
return this;
|
|
1736
3522
|
}
|
|
1737
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
|
+
|
|
1738
3542
|
/** `SELECT COUNT(DISTINCT col)`. */
|
|
1739
3543
|
async countDistinct(column: string): Promise<number> {
|
|
1740
3544
|
return Number(
|
|
1741
|
-
(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))})`,
|
|
1742
3563
|
);
|
|
3564
|
+
return v === null || v === undefined ? null : Number(v);
|
|
1743
3565
|
}
|
|
1744
3566
|
|
|
1745
3567
|
/** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
|
|
@@ -1748,7 +3570,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1748
3570
|
clone.#select = ["1"];
|
|
1749
3571
|
clone.#limit = 1;
|
|
1750
3572
|
const { sql, params } = clone.toSQL();
|
|
1751
|
-
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
|
+
);
|
|
1752
3578
|
return rows.length > 0;
|
|
1753
3579
|
}
|
|
1754
3580
|
|
|
@@ -1846,14 +3672,32 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1846
3672
|
// beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
|
|
1847
3673
|
// tenant scope) propagates into both the COUNT and the data fetch.
|
|
1848
3674
|
await fireHooks(this.#entityClass, "beforePaginate", this);
|
|
1849
|
-
//
|
|
3675
|
+
// COUNT(*) + data fetch
|
|
1850
3676
|
const countQ = this.clone();
|
|
1851
|
-
countQ.#select = ["COUNT(*) AS count"];
|
|
1852
3677
|
countQ.#limit = undefined;
|
|
1853
3678
|
countQ.#offset = undefined;
|
|
1854
3679
|
countQ.#orderBys = [];
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
+
);
|
|
1857
3701
|
const total = Number(cRows[0]?.count ?? 0);
|
|
1858
3702
|
|
|
1859
3703
|
const dataQ = this.clone();
|
|
@@ -1863,7 +3707,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1863
3707
|
// top of the paginate hooks — paginate is its own terminal.
|
|
1864
3708
|
const items = await dataQ.#doExec();
|
|
1865
3709
|
await fireHooks(this.#entityClass, "afterPaginate", items);
|
|
1866
|
-
|
|
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
|
+
);
|
|
1867
3718
|
}
|
|
1868
3719
|
|
|
1869
3720
|
/**
|
|
@@ -1884,9 +3735,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1884
3735
|
limit: number;
|
|
1885
3736
|
orderBy: string | string[];
|
|
1886
3737
|
}): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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));
|
|
1890
3745
|
if (cols.length === 0)
|
|
1891
3746
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
1892
3747
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
@@ -1935,9 +3790,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1935
3790
|
const last = items[items.length - 1] as Record<string, unknown> | undefined;
|
|
1936
3791
|
const nextCursor =
|
|
1937
3792
|
hasMore && last
|
|
1938
|
-
? Buffer.from(
|
|
1939
|
-
|
|
1940
|
-
)
|
|
3793
|
+
? Buffer.from(
|
|
3794
|
+
JSON.stringify({ v: props.map((p) => last[p]) }),
|
|
3795
|
+
).toString("base64")
|
|
1941
3796
|
: null;
|
|
1942
3797
|
return { items, nextCursor, hasMore };
|
|
1943
3798
|
}
|
|
@@ -1958,6 +3813,22 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1958
3813
|
return this;
|
|
1959
3814
|
}
|
|
1960
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
|
+
|
|
1961
3832
|
/** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
|
|
1962
3833
|
toQuery(): string {
|
|
1963
3834
|
const { sql, params } = this.toSQL();
|
|
@@ -1978,6 +3849,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1978
3849
|
this.#resolveColumn,
|
|
1979
3850
|
this.#softDeletes,
|
|
1980
3851
|
this.#dialect,
|
|
3852
|
+
this.#prepareValue,
|
|
3853
|
+
this.#onDomainEvents,
|
|
1981
3854
|
);
|
|
1982
3855
|
c.#softScope = this.#softScope;
|
|
1983
3856
|
c.#wheres = structuredCloneSafe(this.#wheres);
|
|
@@ -1987,9 +3860,29 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1987
3860
|
c.#offset = this.#offset;
|
|
1988
3861
|
c.#preloads = new Map(this.#preloads);
|
|
1989
3862
|
c.#selectSubqueries = structuredClone(this.#selectSubqueries);
|
|
1990
|
-
c.#joins =
|
|
3863
|
+
c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
|
|
1991
3864
|
c.#lockMode = this.#lockMode;
|
|
3865
|
+
c.#lockModifier = this.#lockModifier;
|
|
3866
|
+
c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
|
|
1992
3867
|
c.#distinct = this.#distinct;
|
|
3868
|
+
c.#distinctOn = [...this.#distinctOn];
|
|
3869
|
+
c.#groupBy = [...this.#groupBy];
|
|
3870
|
+
c.#having = structuredCloneSafe(this.#having);
|
|
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
|
+
}));
|
|
3877
|
+
c.#unions = this.#unions.map((u) => ({
|
|
3878
|
+
query: u.query.clone(),
|
|
3879
|
+
all: u.all,
|
|
3880
|
+
op: u.op,
|
|
3881
|
+
}));
|
|
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;
|
|
1993
3886
|
c.#debugFlag = this.#debugFlag;
|
|
1994
3887
|
return c;
|
|
1995
3888
|
}
|
|
@@ -2004,15 +3897,18 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2004
3897
|
if (!patch || Object.keys(patch).length === 0) {
|
|
2005
3898
|
throw new Error("update() requires a non-empty payload");
|
|
2006
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.
|
|
2007
3902
|
const setPairs = Object.entries(patch).map(
|
|
2008
|
-
([k, v]) =>
|
|
3903
|
+
([k, v]) =>
|
|
3904
|
+
[this.#resolveColumn(k), this.#prepareValue(k, v)] as [string, unknown],
|
|
2009
3905
|
);
|
|
2010
3906
|
const spec = {
|
|
2011
3907
|
kind: "update",
|
|
2012
3908
|
table: this.#tableName,
|
|
2013
3909
|
set: setPairs,
|
|
2014
3910
|
wheres: this.#wheresForDml(),
|
|
2015
|
-
returning: returning ?? [],
|
|
3911
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
2016
3912
|
};
|
|
2017
3913
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
2018
3914
|
if (returning && returning.length > 0) {
|
|
@@ -2025,25 +3921,67 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2025
3921
|
return r.rowsAffected ?? 0;
|
|
2026
3922
|
}
|
|
2027
3923
|
|
|
2028
|
-
/**
|
|
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
|
+
*/
|
|
2029
3931
|
async delete(
|
|
2030
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[],
|
|
2031
3950
|
): Promise<number | Record<string, unknown>[]> {
|
|
2032
3951
|
const spec = {
|
|
2033
3952
|
kind: "delete",
|
|
2034
3953
|
table: this.#tableName,
|
|
2035
3954
|
wheres: this.#wheresForDml(),
|
|
2036
|
-
returning: returning ?? [],
|
|
3955
|
+
returning: (returning ?? []).map((c) => this.#resolveSelect(c)),
|
|
2037
3956
|
};
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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);
|
|
2047
3985
|
}
|
|
2048
3986
|
|
|
2049
3987
|
// === Story 30.3 — increment / decrement already implemented? check ================================
|
|
@@ -2090,6 +4028,56 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2090
4028
|
return this;
|
|
2091
4029
|
}
|
|
2092
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
|
+
|
|
2093
4081
|
// === Private helpers ==============================================================================
|
|
2094
4082
|
|
|
2095
4083
|
#quote(name: string): string {
|
|
@@ -2098,11 +4086,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2098
4086
|
|
|
2099
4087
|
/** Quote a `table.column` reference on both sides of the dot. */
|
|
2100
4088
|
#quoteCol(ref: string): string {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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
|
+
);
|
|
2104
4098
|
}
|
|
2105
|
-
return
|
|
4099
|
+
return ref
|
|
4100
|
+
.split(".")
|
|
4101
|
+
.map((seg) => this.#quote(seg))
|
|
4102
|
+
.join(".");
|
|
2106
4103
|
}
|
|
2107
4104
|
|
|
2108
4105
|
#pushJoin(
|
|
@@ -2111,7 +4108,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2111
4108
|
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
2112
4109
|
right?: string,
|
|
2113
4110
|
): this {
|
|
2114
|
-
const tq = this.#
|
|
4111
|
+
const tq = this.#quoteCol(table);
|
|
2115
4112
|
if (typeof leftOrBuild === "function") {
|
|
2116
4113
|
const jb: JoinBuilder = {
|
|
2117
4114
|
parts: [],
|
|
@@ -2123,28 +4120,61 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2123
4120
|
this.parts.push({ kind: "and", left: l, right: r });
|
|
2124
4121
|
return this;
|
|
2125
4122
|
},
|
|
2126
|
-
|
|
2127
|
-
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 } });
|
|
2128
4137
|
return this;
|
|
2129
4138
|
},
|
|
2130
4139
|
};
|
|
2131
4140
|
leftOrBuild(jb);
|
|
4141
|
+
// Collect the bound values in placeholder order as the fragment is built.
|
|
4142
|
+
const params: unknown[] = [];
|
|
2132
4143
|
const on = jb.parts
|
|
2133
4144
|
.map((p, i) => {
|
|
2134
4145
|
const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
|
|
2135
|
-
|
|
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 ?? "")}`;
|
|
2136
4165
|
})
|
|
2137
4166
|
.join(" ");
|
|
2138
|
-
this.#joins.push(`${kind} JOIN ${tq} ${on}
|
|
4167
|
+
this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
|
|
2139
4168
|
return this;
|
|
2140
4169
|
}
|
|
2141
4170
|
if (right === undefined)
|
|
2142
4171
|
throw new Error(
|
|
2143
4172
|
"join() with string form requires both left and right operands",
|
|
2144
4173
|
);
|
|
2145
|
-
this.#joins.push(
|
|
2146
|
-
`${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
|
|
2147
|
-
|
|
4174
|
+
this.#joins.push({
|
|
4175
|
+
sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(leftOrBuild)} = ${this.#quoteCol(right)}`,
|
|
4176
|
+
params: [],
|
|
4177
|
+
});
|
|
2148
4178
|
return this;
|
|
2149
4179
|
}
|
|
2150
4180
|
|
|
@@ -2190,7 +4220,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2190
4220
|
* still rejected because the DML compiler's WHERE lowering does not yet
|
|
2191
4221
|
* handle nested sub-queries or correlated EXISTS.
|
|
2192
4222
|
*/
|
|
2193
|
-
|
|
4223
|
+
/** The user's own WHERE predicates mapped for DML (no soft-delete scope). */
|
|
4224
|
+
#userWheresForDml(): Array<Record<string, unknown>> {
|
|
2194
4225
|
const out: Array<Record<string, unknown>> = [];
|
|
2195
4226
|
for (const w of this.#wheres) {
|
|
2196
4227
|
if ("kind" in w) {
|
|
@@ -2218,6 +4249,50 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2218
4249
|
return out;
|
|
2219
4250
|
}
|
|
2220
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
|
+
|
|
2221
4296
|
/**
|
|
2222
4297
|
* !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
|
|
2223
4298
|
*
|
|
@@ -2255,6 +4330,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2255
4330
|
this.#resolveColumn,
|
|
2256
4331
|
false,
|
|
2257
4332
|
this.#dialect,
|
|
4333
|
+
this.#prepareValue,
|
|
2258
4334
|
);
|
|
2259
4335
|
callback(scratch);
|
|
2260
4336
|
return { type, kind: "group", conditions: scratch.#wheres };
|
|
@@ -2308,19 +4384,30 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2308
4384
|
type,
|
|
2309
4385
|
column: resolved,
|
|
2310
4386
|
operator: "=",
|
|
2311
|
-
value: operatorOrValue,
|
|
4387
|
+
value: this.#prep(column, operatorOrValue),
|
|
2312
4388
|
});
|
|
2313
4389
|
} else {
|
|
2314
4390
|
this.#wheres.push({
|
|
2315
4391
|
type,
|
|
2316
4392
|
column: resolved,
|
|
2317
4393
|
operator: operatorOrValue as string,
|
|
2318
|
-
value,
|
|
4394
|
+
value: this.#prep(column, value),
|
|
2319
4395
|
});
|
|
2320
4396
|
}
|
|
2321
4397
|
return this;
|
|
2322
4398
|
}
|
|
2323
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
|
+
|
|
2324
4411
|
/**
|
|
2325
4412
|
* Resolve this ModelQuery's preloads against a pre-loaded set of entities.
|
|
2326
4413
|
* Used by the nested-preload machinery to recurse without re-running the root select.
|