@c9up/atlas 0.1.19 → 0.2.1
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 +63 -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 +66 -7
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +205 -39
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +181 -5
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -33
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +127 -0
- package/dist/BaseModel.d.ts.map +1 -0
- package/dist/BaseModel.js +250 -0
- package/dist/BaseModel.js.map +1 -0
- package/dist/BaseRepository.d.ts +81 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1433 -387
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ConnectionManager.d.ts +106 -0
- package/dist/ConnectionManager.d.ts.map +1 -0
- package/dist/ConnectionManager.js +228 -0
- package/dist/ConnectionManager.js.map +1 -0
- package/dist/ModelQuery.d.ts +577 -41
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +2188 -291
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +71 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +138 -24
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +44 -4
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +131 -13
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/contract.d.ts +62 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +38 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/factoryCommands.d.ts +23 -0
- package/dist/console/factoryCommands.d.ts.map +1 -0
- package/dist/console/factoryCommands.js +62 -0
- package/dist/console/factoryCommands.js.map +1 -0
- package/dist/console/migrationCommands.d.ts +78 -0
- package/dist/console/migrationCommands.d.ts.map +1 -0
- package/dist/console/migrationCommands.js +327 -0
- package/dist/console/migrationCommands.js.map +1 -0
- package/dist/console/schemaCheckCommand.d.ts +11 -17
- package/dist/console/schemaCheckCommand.d.ts.map +1 -1
- package/dist/console/schemaCheckCommand.js +21 -15
- package/dist/console/schemaCheckCommand.js.map +1 -1
- package/dist/console/schemaDumpCommand.d.ts +30 -0
- package/dist/console/schemaDumpCommand.d.ts.map +1 -0
- package/dist/console/schemaDumpCommand.js +69 -0
- package/dist/console/schemaDumpCommand.js.map +1 -0
- package/dist/console/schemaGenerateCommand.d.ts +100 -0
- package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
- package/dist/console/schemaGenerateCommand.js +246 -0
- package/dist/console/schemaGenerateCommand.js.map +1 -0
- package/dist/console/seederCommands.d.ts +46 -0
- package/dist/console/seederCommands.d.ts.map +1 -0
- package/dist/console/seederCommands.js +136 -0
- package/dist/console/seederCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +50 -8
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +42 -2
- package/dist/decorators/entity.js.map +1 -1
- package/dist/decorators/hooks.d.ts +3 -3
- package/dist/decorators/hooks.d.ts.map +1 -1
- package/dist/decorators/hooks.js.map +1 -1
- package/dist/events.d.ts +69 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +82 -0
- package/dist/events.js.map +1 -0
- package/dist/factories.d.ts +8 -0
- package/dist/factories.d.ts.map +1 -0
- package/dist/factories.js +8 -0
- package/dist/factories.js.map +1 -0
- package/dist/index.d.ts +15 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/lucid-schema.d.ts +15 -0
- package/dist/lucid-schema.d.ts.map +1 -0
- package/dist/lucid-schema.js +16 -0
- package/dist/lucid-schema.js.map +1 -0
- 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/orm.d.ts +14 -0
- package/dist/orm.d.ts.map +1 -0
- package/dist/orm.js +14 -0
- package/dist/orm.js.map +1 -0
- package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
- package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
- package/dist/query/DatabaseQueryBuilder.js +1798 -0
- package/dist/query/DatabaseQueryBuilder.js.map +1 -0
- package/dist/query/DmlBuilder.d.ts +62 -0
- package/dist/query/DmlBuilder.d.ts.map +1 -0
- package/dist/query/DmlBuilder.js +84 -0
- package/dist/query/DmlBuilder.js.map +1 -0
- package/dist/query/QueryBuilder.d.ts +5 -0
- package/dist/query/QueryBuilder.d.ts.map +1 -1
- package/dist/query/QueryBuilder.js +7 -0
- package/dist/query/QueryBuilder.js.map +1 -1
- package/dist/query/RawQueryBuilder.d.ts +47 -0
- package/dist/query/RawQueryBuilder.d.ts.map +1 -0
- package/dist/query/RawQueryBuilder.js +138 -0
- package/dist/query/RawQueryBuilder.js.map +1 -0
- package/dist/query/interpolate.d.ts +45 -0
- package/dist/query/interpolate.d.ts.map +1 -0
- package/dist/query/interpolate.js +51 -0
- package/dist/query/interpolate.js.map +1 -0
- package/dist/query/native.d.ts +8 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +14 -0
- package/dist/query/native.js.map +1 -1
- package/dist/query/operators.d.ts +4 -0
- package/dist/query/operators.d.ts.map +1 -0
- package/dist/query/operators.js +27 -0
- package/dist/query/operators.js.map +1 -0
- package/dist/schema/Migration.d.ts +50 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +60 -24
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +95 -34
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +523 -68
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +114 -0
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +272 -3
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaDumper.d.ts +121 -0
- package/dist/schema/SchemaDumper.d.ts.map +1 -0
- package/dist/schema/SchemaDumper.js +365 -0
- package/dist/schema/SchemaDumper.js.map +1 -0
- package/dist/schema/Seeder.d.ts +13 -0
- package/dist/schema/Seeder.d.ts.map +1 -1
- package/dist/schema/Seeder.js +28 -5
- package/dist/schema/Seeder.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +274 -16
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +656 -52
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +71 -0
- package/dist/schema/catalog.d.ts.map +1 -0
- package/dist/schema/catalog.js +158 -0
- package/dist/schema/catalog.js.map +1 -0
- package/dist/schema/introspect.d.ts +1 -1
- package/dist/schema/introspect.d.ts.map +1 -1
- package/dist/schema/introspect.js +26 -11
- 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/seeders.d.ts +7 -0
- package/dist/seeders.d.ts.map +1 -0
- package/dist/seeders.js +7 -0
- package/dist/seeders.js.map +1 -0
- package/dist/services/db.d.ts +122 -5
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +187 -14
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +33 -7
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +54 -24
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/DbAssertions.d.ts +48 -0
- package/dist/testing/DbAssertions.d.ts.map +1 -0
- package/dist/testing/DbAssertions.js +80 -0
- package/dist/testing/DbAssertions.js.map +1 -0
- package/dist/testing/Factory.d.ts +166 -15
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +497 -23
- package/dist/testing/Factory.js.map +1 -1
- package/dist/testing/TestUtils.d.ts +49 -0
- package/dist/testing/TestUtils.d.ts.map +1 -0
- package/dist/testing/TestUtils.js +79 -0
- package/dist/testing/TestUtils.js.map +1 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +3 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types/relations.d.ts +31 -0
- package/dist/types/relations.d.ts.map +1 -0
- package/dist/types/relations.js +20 -0
- package/dist/types/relations.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +32 -2
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +305 -52
- package/src/BaseEntity.ts +460 -41
- package/src/BaseModel.ts +461 -0
- package/src/BaseRepository.ts +1710 -421
- package/src/ConnectionManager.ts +298 -0
- package/src/ModelQuery.ts +2998 -335
- package/src/Transaction.ts +227 -30
- package/src/adapters/NapiDbAdapter.ts +224 -17
- package/src/console/contract.ts +96 -0
- package/src/console/factoryCommands.ts +73 -0
- package/src/console/migrationCommands.ts +448 -0
- package/src/console/schemaCheckCommand.ts +26 -22
- package/src/console/schemaDumpCommand.ts +96 -0
- package/src/console/schemaGenerateCommand.ts +380 -0
- package/src/console/seederCommands.ts +174 -0
- package/src/decorators/entity.ts +71 -8
- package/src/decorators/hooks.ts +5 -3
- package/src/events.ts +117 -0
- package/src/factories.ts +10 -0
- package/src/index.ts +69 -3
- package/src/lucid-schema.ts +22 -0
- package/src/metadata-keys.ts +3 -2
- package/src/naming/NamingStrategy.ts +23 -0
- package/src/orm.ts +13 -0
- package/src/query/DatabaseQueryBuilder.ts +2724 -0
- package/src/query/DmlBuilder.ts +131 -0
- package/src/query/QueryBuilder.ts +8 -0
- package/src/query/RawQueryBuilder.ts +173 -0
- package/src/query/interpolate.ts +73 -0
- package/src/query/native.ts +29 -0
- package/src/query/operators.ts +27 -0
- package/src/schema/Migration.ts +71 -3
- package/src/schema/MigrationRunner.ts +676 -69
- package/src/schema/Schema.ts +359 -3
- package/src/schema/SchemaDumper.ts +518 -0
- package/src/schema/Seeder.ts +47 -10
- package/src/schema/TableBuilder.ts +783 -52
- package/src/schema/catalog.ts +236 -0
- package/src/schema/introspect.ts +26 -9
- package/src/schema/types.ts +137 -2
- package/src/seeders.ts +16 -0
- package/src/services/db.ts +361 -21
- package/src/testing/DatabaseCleanup.ts +78 -25
- package/src/testing/DbAssertions.ts +127 -0
- package/src/testing/Factory.ts +837 -40
- package/src/testing/TestUtils.ts +123 -0
- package/src/testing/index.ts +12 -1
- package/src/types/relations.ts +43 -0
package/src/ModelQuery.ts
CHANGED
|
@@ -7,22 +7,43 @@
|
|
|
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";
|
|
32
|
+
import { DmlBuilder, type DmlChainHooks } from "./query/DmlBuilder.js";
|
|
33
|
+
import {
|
|
34
|
+
type CompiledStatement,
|
|
35
|
+
compiledStatement,
|
|
36
|
+
interpolateQuery,
|
|
37
|
+
} from "./query/interpolate.js";
|
|
21
38
|
import {
|
|
22
39
|
type AtlasDialect,
|
|
23
40
|
compileStatementNative,
|
|
41
|
+
type DialectName,
|
|
24
42
|
getAtlasDialect,
|
|
43
|
+
normalizeDialect,
|
|
25
44
|
} from "./query/native.js";
|
|
45
|
+
import { negateOperator } from "./query/operators.js";
|
|
46
|
+
import { RawSql } from "./query/QueryBuilder.js";
|
|
26
47
|
import { camelToSnake, snakeToCamel } from "./utils/casing.js";
|
|
27
48
|
|
|
28
49
|
/**
|
|
@@ -43,6 +64,50 @@ const WHEREEXPR_OPERATORS = new Set<string>([
|
|
|
43
64
|
"NOT LIKE",
|
|
44
65
|
]);
|
|
45
66
|
|
|
67
|
+
/**
|
|
68
|
+
* SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
|
|
69
|
+
* They are just letters (pass the charset guard) but would let the fragment alter
|
|
70
|
+
* the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
|
|
71
|
+
* alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
|
|
72
|
+
*/
|
|
73
|
+
const WHEREEXPR_FORBIDDEN_WORDS = new Set<string>([
|
|
74
|
+
"OR",
|
|
75
|
+
"AND",
|
|
76
|
+
"NOT",
|
|
77
|
+
"IS",
|
|
78
|
+
"NULL",
|
|
79
|
+
"IN",
|
|
80
|
+
"LIKE",
|
|
81
|
+
"ILIKE",
|
|
82
|
+
"BETWEEN",
|
|
83
|
+
"EXISTS",
|
|
84
|
+
"ANY",
|
|
85
|
+
"ALL",
|
|
86
|
+
"SOME",
|
|
87
|
+
"CASE",
|
|
88
|
+
"WHEN",
|
|
89
|
+
"THEN",
|
|
90
|
+
"ELSE",
|
|
91
|
+
"END",
|
|
92
|
+
"SELECT",
|
|
93
|
+
"FROM",
|
|
94
|
+
"WHERE",
|
|
95
|
+
"JOIN",
|
|
96
|
+
"UNION",
|
|
97
|
+
"INTERSECT",
|
|
98
|
+
"EXCEPT",
|
|
99
|
+
"HAVING",
|
|
100
|
+
"GROUP",
|
|
101
|
+
"ORDER",
|
|
102
|
+
"BY",
|
|
103
|
+
"LIMIT",
|
|
104
|
+
"OFFSET",
|
|
105
|
+
"AS",
|
|
106
|
+
"DISTINCT",
|
|
107
|
+
"TRUE",
|
|
108
|
+
"FALSE",
|
|
109
|
+
]);
|
|
110
|
+
|
|
46
111
|
/** True when every `(` in `s` has a matching `)` and none closes early. */
|
|
47
112
|
function hasBalancedParens(s: string): boolean {
|
|
48
113
|
let depth = 0;
|
|
@@ -59,6 +124,112 @@ function hasBalancedParens(s: string): boolean {
|
|
|
59
124
|
type PreloadCallback = (query: ModelQuery<BaseEntity>) => void;
|
|
60
125
|
|
|
61
126
|
type ColumnResolver = (column: string) => string;
|
|
127
|
+
/**
|
|
128
|
+
* Lower a value bound for a given property to its DB form — mirrors
|
|
129
|
+
* `BaseRepository.#applyPrepare` (a `@column.dateTime` DateTime → ISO string, a
|
|
130
|
+
* `@Column({ prepare })` adapter runs). Threaded into ModelQuery so the fluent
|
|
131
|
+
* `update()` / WHERE paths don't bypass prepare the way direct repo writes don't.
|
|
132
|
+
*/
|
|
133
|
+
type ValuePreparer = (column: string, value: unknown) => unknown;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
|
|
137
|
+
* and the snake_case convention. Used to build correlated/preload subqueries on a
|
|
138
|
+
* RELATED model so their WHERE/join columns resolve like a direct query would.
|
|
139
|
+
*/
|
|
140
|
+
function buildColumnResolver(
|
|
141
|
+
entityClass: new () => BaseEntity,
|
|
142
|
+
): ColumnResolver {
|
|
143
|
+
const map = new Map<string, string>();
|
|
144
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
145
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
146
|
+
map.set(col.propertyKey, db);
|
|
147
|
+
map.set(db, db);
|
|
148
|
+
}
|
|
149
|
+
return (col) => map.get(col) ?? camelToSnake(col);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
|
|
154
|
+
* (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
|
|
155
|
+
* preload/whereHas constraint on a RELATED model prepares its values like a direct query.
|
|
156
|
+
*/
|
|
157
|
+
function buildValuePreparer(entityClass: new () => BaseEntity): ValuePreparer {
|
|
158
|
+
const prepares = new Map<
|
|
159
|
+
string,
|
|
160
|
+
(v: unknown, attribute?: string, model?: unknown) => unknown
|
|
161
|
+
>();
|
|
162
|
+
// Reverse map (db column → property) so a caller passing a DB name or an
|
|
163
|
+
// explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
|
|
164
|
+
// still routes through the property-keyed prepare/date maps — mirrors
|
|
165
|
+
// BaseRepository.#applyPrepare.
|
|
166
|
+
const byDbName = new Map<string, string>();
|
|
167
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
168
|
+
if (col.prepare) prepares.set(col.propertyKey, col.prepare);
|
|
169
|
+
byDbName.set(
|
|
170
|
+
col.columnName ?? camelToSnake(col.propertyKey),
|
|
171
|
+
col.propertyKey,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const dateCols = getDateColumnConfig(entityClass);
|
|
175
|
+
return (key, value) => {
|
|
176
|
+
const prop = byDbName.get(key) ?? key;
|
|
177
|
+
const p = prepares.get(prop);
|
|
178
|
+
// Query-builder value transform — no model instance, but the attribute is
|
|
179
|
+
// known (Adonis Lucid signature: value, attribute, model).
|
|
180
|
+
if (p) return p(value, prop, undefined);
|
|
181
|
+
if (dateCols[prop] && value != null) {
|
|
182
|
+
if (value instanceof Date) return value.toISOString();
|
|
183
|
+
return dateTimeAtlasAdapter.prepare(value);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
|
|
190
|
+
function joinValueHasToISO(v: unknown): v is { toISO(): string } {
|
|
191
|
+
return (
|
|
192
|
+
typeof v === "object" &&
|
|
193
|
+
v !== null &&
|
|
194
|
+
"toISO" in v &&
|
|
195
|
+
typeof v.toISO === "function"
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
|
|
201
|
+
* `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
|
|
202
|
+
* column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
|
|
203
|
+
* borrow the root model's adapter for a same-named column on a different table
|
|
204
|
+
* (Knex binds join values model-agnostically; we add only safe universal
|
|
205
|
+
* serialization so a DateTime still lowers to ISO like `where()`).
|
|
206
|
+
*/
|
|
207
|
+
function lowerJoinValue(value: unknown): unknown {
|
|
208
|
+
if (value instanceof Date) return value.toISOString();
|
|
209
|
+
if (joinValueHasToISO(value)) return value.toISO();
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Does a join column's table reference (`ref`) denote the root model's own table
|
|
215
|
+
* (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
|
|
216
|
+
* model declares (default schema) — `orders` matches a `public.orders` model — but
|
|
217
|
+
* it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
|
|
218
|
+
* accepts `orders.col`, while an unqualified `orders` model rejects
|
|
219
|
+
* `archive.orders.col` (a different schema the model never named) — keeping it
|
|
220
|
+
* foreign so the root model's `@Column` adapters aren't misapplied to it.
|
|
221
|
+
*/
|
|
222
|
+
function sameTableRef(ref: string, modelTable: string): boolean {
|
|
223
|
+
const rs = ref.split(".");
|
|
224
|
+
const ms = modelTable.split(".");
|
|
225
|
+
// The reference cannot be MORE qualified than the model (it can only drop the
|
|
226
|
+
// schema, never assert a new one) — otherwise treat it as a foreign table.
|
|
227
|
+
if (rs.length > ms.length) return false;
|
|
228
|
+
for (let i = 1; i <= rs.length; i++) {
|
|
229
|
+
if (rs[rs.length - i] !== ms[ms.length - i]) return false;
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
62
233
|
|
|
63
234
|
/** Per-preload-relation locals shared by the resolver helpers. Built once per relation, then passed by ref. */
|
|
64
235
|
interface PreloadContext {
|
|
@@ -148,6 +319,22 @@ interface RawWhere {
|
|
|
148
319
|
bindings: unknown[];
|
|
149
320
|
}
|
|
150
321
|
|
|
322
|
+
/**
|
|
323
|
+
* A JSON predicate — path comparison or containment. The column is a quoted
|
|
324
|
+
* identifier; the path and value cross the boundary as bound params. Mirrors
|
|
325
|
+
* the Rust `json` WHERE kind.
|
|
326
|
+
*/
|
|
327
|
+
interface JsonWhere {
|
|
328
|
+
type: "and" | "or";
|
|
329
|
+
kind: "json";
|
|
330
|
+
jsonOp: "path" | "superset" | "subset" | "equals";
|
|
331
|
+
column: string;
|
|
332
|
+
negated: boolean;
|
|
333
|
+
path?: string;
|
|
334
|
+
operator?: string;
|
|
335
|
+
value: unknown;
|
|
336
|
+
}
|
|
337
|
+
|
|
151
338
|
/** An EXISTS / NOT EXISTS correlated subquery — used by whereHas / doesntHave. */
|
|
152
339
|
interface ExistsWhere {
|
|
153
340
|
type: "and" | "or";
|
|
@@ -156,11 +343,13 @@ interface ExistsWhere {
|
|
|
156
343
|
subquery: SelectSpec;
|
|
157
344
|
}
|
|
158
345
|
|
|
159
|
-
/** Parenthesised group of WHERE conditions — built via `where(cb)`. */
|
|
346
|
+
/** Parenthesised group of WHERE conditions — built via `where(cb)` / `whereNot(cb)`. */
|
|
160
347
|
interface GroupWhere {
|
|
161
348
|
type: "and" | "or";
|
|
162
349
|
kind: "group";
|
|
163
350
|
conditions: WhereClause[];
|
|
351
|
+
/** `whereNot(cb)` wraps the group in `NOT (…)` (honoured by the Rust compiler). */
|
|
352
|
+
negated?: boolean;
|
|
164
353
|
}
|
|
165
354
|
|
|
166
355
|
/** `col IN (SELECT ...)` / `col NOT IN (SELECT ...)` — built via `whereIn(col, subQ)`. */
|
|
@@ -195,13 +384,27 @@ interface CteSpec {
|
|
|
195
384
|
name: string;
|
|
196
385
|
sql: string;
|
|
197
386
|
params: unknown[];
|
|
387
|
+
/** One recursive CTE makes the whole WITH clause recursive — see `withRecursive`. */
|
|
388
|
+
recursive?: boolean;
|
|
389
|
+
/**
|
|
390
|
+
* `true` → AS MATERIALIZED, `false` → AS NOT MATERIALIZED, `null` →
|
|
391
|
+
* planner's choice. Null rather than absent because this crosses the NAPI
|
|
392
|
+
* boundary, where serde reads a missing key and an explicit null alike.
|
|
393
|
+
*/
|
|
394
|
+
materialized?: boolean | null;
|
|
198
395
|
}
|
|
199
396
|
|
|
200
|
-
/**
|
|
397
|
+
/**
|
|
398
|
+
* A compiled set-operation branch — pre-compiled to SQL + params. Still named
|
|
399
|
+
* `UnionSpec` (and sent under `unions`) because that wire field predates
|
|
400
|
+
* INTERSECT/EXCEPT; renaming it would break the contract for no gain.
|
|
401
|
+
*/
|
|
201
402
|
interface UnionSpec {
|
|
202
403
|
sql: string;
|
|
203
404
|
params: unknown[];
|
|
204
405
|
all: boolean;
|
|
406
|
+
/** Defaults to `union` when absent/null, keeping the pre-existing wire format. */
|
|
407
|
+
op?: "union" | "intersect" | "except" | null;
|
|
205
408
|
}
|
|
206
409
|
|
|
207
410
|
interface SubqueryProjection {
|
|
@@ -209,22 +412,52 @@ interface SubqueryProjection {
|
|
|
209
412
|
subquery: SelectSpec;
|
|
210
413
|
}
|
|
211
414
|
|
|
415
|
+
/**
|
|
416
|
+
* One ORDER BY term: a resolved column + direction, or a verbatim fragment
|
|
417
|
+
* (`orderByRaw`). Both share one list so a raw term keeps its position among
|
|
418
|
+
* the plain ones. Mirrors the Rust `OrderByClause`.
|
|
419
|
+
*/
|
|
420
|
+
type OrderByEntry =
|
|
421
|
+
| { column: string; direction: "asc" | "desc" }
|
|
422
|
+
| { raw: string };
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* One GROUP BY term: a resolved column, or a verbatim fragment (`groupByRaw`).
|
|
426
|
+
* Mirrors the Rust `GroupByItem` — untagged, so a bare string stays a column
|
|
427
|
+
* and the pre-existing wire format is unchanged.
|
|
428
|
+
*/
|
|
429
|
+
type GroupByEntry = string | { raw: string };
|
|
430
|
+
|
|
212
431
|
interface SelectSpec {
|
|
213
432
|
kind: "select";
|
|
214
433
|
table: string;
|
|
215
434
|
select: string[];
|
|
216
435
|
selectSubqueries: SubqueryProjection[];
|
|
217
436
|
wheres: WhereClause[];
|
|
218
|
-
orderBy:
|
|
219
|
-
groupBy:
|
|
437
|
+
orderBy: OrderByEntry[];
|
|
438
|
+
groupBy: GroupByEntry[];
|
|
220
439
|
having: HavingEntry[];
|
|
221
440
|
limit: number | null;
|
|
222
441
|
offset: number | null;
|
|
223
442
|
distinct: boolean;
|
|
443
|
+
distinctOn: string[];
|
|
224
444
|
ctes: CteSpec[];
|
|
225
445
|
unions: UnionSpec[];
|
|
226
|
-
|
|
227
|
-
|
|
446
|
+
/** JOIN fragments; each carries its own `?`-style bound params (e.g. `onVal`). */
|
|
447
|
+
joins: Array<{ sql: string; params: unknown[] }>;
|
|
448
|
+
/** Verbatim SELECT fragments with their own params — `select(raw)` / `select(subquery.as())`. */
|
|
449
|
+
selectRaw?: Array<{ sql: string; params: unknown[] }>;
|
|
450
|
+
/** Composite lock clause, e.g. `FOR UPDATE`, `FOR NO KEY UPDATE SKIP LOCKED`. */
|
|
451
|
+
lockMode: string | null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** `(col1, col2) IN ((v11, v12), …)` / NOT IN — built via `whereIn([cols], [rows])`. */
|
|
455
|
+
interface InTupleWhere {
|
|
456
|
+
type: "and" | "or";
|
|
457
|
+
kind: "inTuple";
|
|
458
|
+
columns: string[];
|
|
459
|
+
rows: unknown[][];
|
|
460
|
+
negated: boolean;
|
|
228
461
|
}
|
|
229
462
|
|
|
230
463
|
type WhereClause =
|
|
@@ -232,15 +465,22 @@ type WhereClause =
|
|
|
232
465
|
| RawWhere
|
|
233
466
|
| ExistsWhere
|
|
234
467
|
| GroupWhere
|
|
235
|
-
| InSubWhere
|
|
468
|
+
| InSubWhere
|
|
469
|
+
| InTupleWhere
|
|
470
|
+
| JsonWhere;
|
|
236
471
|
|
|
237
472
|
type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
|
|
238
473
|
|
|
474
|
+
/** A compound-query (union/intersect/except) arg — a query OR a callback building one. */
|
|
475
|
+
type UnionArg = ModelQuery<BaseEntity> | ((q: ModelQuery<BaseEntity>) => void);
|
|
476
|
+
|
|
239
477
|
/**
|
|
240
|
-
* Process-wide strict mode flag. When enabled, `whereRaw()
|
|
241
|
-
* throw unconditionally — forcing every
|
|
242
|
-
* `whereExpr()` / `joinOn()` /
|
|
243
|
-
*
|
|
478
|
+
* Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
|
|
479
|
+
* `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
|
|
480
|
+
* call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
|
|
481
|
+
* builder paths. The connection-level `db.query()` / `db.execute()` stay available
|
|
482
|
+
* as the explicit, parameterised break-glass. Intended for prod hardening on apps
|
|
483
|
+
* that can't audit every call site manually.
|
|
244
484
|
*
|
|
245
485
|
* Enable via:
|
|
246
486
|
* - `setAtlasStrictMode(true)` at app bootstrap
|
|
@@ -252,7 +492,7 @@ type WhereCallback = (q: ModelQuery<BaseEntity>) => void;
|
|
|
252
492
|
*/
|
|
253
493
|
let atlasStrictMode: boolean | undefined;
|
|
254
494
|
|
|
255
|
-
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
|
|
495
|
+
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
|
|
256
496
|
export function setAtlasStrictMode(enabled: boolean): void {
|
|
257
497
|
atlasStrictMode = enabled;
|
|
258
498
|
}
|
|
@@ -286,12 +526,54 @@ function isInternalBypass(): boolean {
|
|
|
286
526
|
return atlasInternalBypass;
|
|
287
527
|
}
|
|
288
528
|
|
|
289
|
-
/**
|
|
529
|
+
/**
|
|
530
|
+
* Multi-condition join builder passed to innerJoin/leftJoin/rightJoin callbacks.
|
|
531
|
+
* `on`/`andOn`/`orOn` join two COLUMNS; `onVal`/`andOnVal`/`orOnVal` join a column
|
|
532
|
+
* to a bound VALUE (AdonisJS/Knex parity) — the value flows through the join-params
|
|
533
|
+
* channel into the compiled parameter list.
|
|
534
|
+
*/
|
|
535
|
+
interface JoinPartMQ {
|
|
536
|
+
kind: "and" | "or";
|
|
537
|
+
left?: string;
|
|
538
|
+
operator?: string;
|
|
539
|
+
right?: string;
|
|
540
|
+
value?: { v: unknown };
|
|
541
|
+
values?: unknown[];
|
|
542
|
+
notIn?: boolean;
|
|
543
|
+
between?: [unknown, unknown];
|
|
544
|
+
notBetween?: boolean;
|
|
545
|
+
nullOp?: "IS NULL" | "IS NOT NULL";
|
|
546
|
+
exists?: { sql: string; params: unknown[]; not: boolean };
|
|
547
|
+
}
|
|
548
|
+
|
|
290
549
|
interface JoinBuilder {
|
|
291
|
-
|
|
550
|
+
/** Accumulated `ON` parts — column-to-column, column-to-value, IN, BETWEEN, NULL, EXISTS. */
|
|
551
|
+
parts: JoinPartMQ[];
|
|
292
552
|
on(left: string, right: string): JoinBuilder;
|
|
553
|
+
on(left: string, operator: string, right: string): JoinBuilder;
|
|
293
554
|
andOn(left: string, right: string): JoinBuilder;
|
|
555
|
+
andOn(left: string, operator: string, right: string): JoinBuilder;
|
|
556
|
+
orOn(left: string, right: string): JoinBuilder;
|
|
557
|
+
orOn(left: string, operator: string, right: string): JoinBuilder;
|
|
558
|
+
onVal(left: string, value: unknown): JoinBuilder;
|
|
294
559
|
andOnVal(left: string, value: unknown): JoinBuilder;
|
|
560
|
+
orOnVal(left: string, value: unknown): JoinBuilder;
|
|
561
|
+
/** `ON col IN (?, ?)` (Lucid/Knex `onIn`). */
|
|
562
|
+
onIn(left: string, values: unknown[]): JoinBuilder;
|
|
563
|
+
/** `ON col NOT IN (?, ?)` (Lucid/Knex `onNotIn`). */
|
|
564
|
+
onNotIn(left: string, values: unknown[]): JoinBuilder;
|
|
565
|
+
/** `ON col IS NULL` (Lucid/Knex `onNull`). */
|
|
566
|
+
onNull(left: string): JoinBuilder;
|
|
567
|
+
/** `ON col IS NOT NULL` (Lucid/Knex `onNotNull`). */
|
|
568
|
+
onNotNull(left: string): JoinBuilder;
|
|
569
|
+
/** `ON col BETWEEN ? AND ?` — inclusive (Lucid/Knex `onBetween`). */
|
|
570
|
+
onBetween(left: string, range: readonly [unknown, unknown]): JoinBuilder;
|
|
571
|
+
/** `ON col NOT BETWEEN ? AND ?` (Lucid/Knex `onNotBetween`). */
|
|
572
|
+
onNotBetween(left: string, range: readonly [unknown, unknown]): JoinBuilder;
|
|
573
|
+
/** `ON EXISTS (subquery)` — a builder or a callback (Lucid/Knex `onExists`). */
|
|
574
|
+
onExists(subquery: UnionArg): JoinBuilder;
|
|
575
|
+
/** `ON NOT EXISTS (subquery)` (Lucid/Knex `onNotExists`). */
|
|
576
|
+
onNotExists(subquery: UnionArg): JoinBuilder;
|
|
295
577
|
}
|
|
296
578
|
|
|
297
579
|
/** Offset-based paginator (Story 29.10). */
|
|
@@ -306,20 +588,46 @@ export class Paginator<T> {
|
|
|
306
588
|
};
|
|
307
589
|
#baseUrl?: string;
|
|
308
590
|
#queryString: Record<string, unknown> = {};
|
|
591
|
+
#metaKeys?: Record<string, string>;
|
|
309
592
|
|
|
310
593
|
constructor(
|
|
311
594
|
items: T[],
|
|
312
595
|
base: { total: number; perPage: number; currentPage: number },
|
|
596
|
+
metaKeys?: Record<string, string>,
|
|
313
597
|
) {
|
|
314
598
|
this.items = items;
|
|
315
599
|
const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
|
|
316
600
|
this.meta = { ...base, lastPage, firstPage: 1 };
|
|
601
|
+
this.#metaKeys = metaKeys;
|
|
317
602
|
}
|
|
318
603
|
|
|
319
604
|
all(): T[] {
|
|
320
605
|
return this.items;
|
|
321
606
|
}
|
|
322
607
|
|
|
608
|
+
// Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
|
|
609
|
+
// carried in `.meta`, exposed directly on the instance for convenience.
|
|
610
|
+
/** Total row count across all pages. */
|
|
611
|
+
get total(): number {
|
|
612
|
+
return this.meta.total;
|
|
613
|
+
}
|
|
614
|
+
/** Rows per page. */
|
|
615
|
+
get perPage(): number {
|
|
616
|
+
return this.meta.perPage;
|
|
617
|
+
}
|
|
618
|
+
/** The current page number. */
|
|
619
|
+
get currentPage(): number {
|
|
620
|
+
return this.meta.currentPage;
|
|
621
|
+
}
|
|
622
|
+
/** The last page number. */
|
|
623
|
+
get lastPage(): number {
|
|
624
|
+
return this.meta.lastPage;
|
|
625
|
+
}
|
|
626
|
+
/** The first page number (always 1). */
|
|
627
|
+
get firstPage(): number {
|
|
628
|
+
return this.meta.firstPage;
|
|
629
|
+
}
|
|
630
|
+
|
|
323
631
|
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
324
632
|
get hasPages(): boolean {
|
|
325
633
|
return this.meta.lastPage > 1;
|
|
@@ -332,7 +640,7 @@ export class Paginator<T> {
|
|
|
332
640
|
|
|
333
641
|
serialize(opts?: { fields?: string[] }): {
|
|
334
642
|
data: unknown[];
|
|
335
|
-
meta:
|
|
643
|
+
meta: Record<string, unknown>;
|
|
336
644
|
} {
|
|
337
645
|
const data = this.items.map((item) => {
|
|
338
646
|
if (!opts?.fields) return item;
|
|
@@ -341,7 +649,9 @@ export class Paginator<T> {
|
|
|
341
649
|
picked[f] = (item as Record<string, unknown>)[f];
|
|
342
650
|
return picked;
|
|
343
651
|
});
|
|
344
|
-
|
|
652
|
+
// Same meta shape as toJSON — snake_case keys via the naming strategy's
|
|
653
|
+
// paginationMetaKeys, plus page URLs when a baseUrl is set.
|
|
654
|
+
return { data, meta: this.#buildMeta() };
|
|
345
655
|
}
|
|
346
656
|
|
|
347
657
|
baseUrl(url: string): this {
|
|
@@ -353,29 +663,76 @@ export class Paginator<T> {
|
|
|
353
663
|
return this;
|
|
354
664
|
}
|
|
355
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Build the URL for a page number, honouring `baseUrl` + `queryString`.
|
|
668
|
+
* Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
|
|
669
|
+
*/
|
|
670
|
+
getUrl(page: number): string {
|
|
671
|
+
if (!this.#baseUrl) return "";
|
|
672
|
+
const params = new URLSearchParams();
|
|
673
|
+
for (const [k, v] of Object.entries(this.#queryString))
|
|
674
|
+
params.set(k, String(v));
|
|
675
|
+
params.set("page", String(page));
|
|
676
|
+
return `${this.#baseUrl}?${params.toString()}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
|
|
680
|
+
getNextPageUrl(): string | null {
|
|
681
|
+
return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
|
|
685
|
+
getPreviousPageUrl(): string | null {
|
|
686
|
+
return this.meta.currentPage > 1
|
|
687
|
+
? this.getUrl(this.meta.currentPage - 1)
|
|
688
|
+
: null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
|
|
692
|
+
getUrlsForRange(
|
|
693
|
+
start: number,
|
|
694
|
+
end: number,
|
|
695
|
+
): Array<{ page: number; url: string; isActive: boolean }> {
|
|
696
|
+
const lo = Math.max(1, start);
|
|
697
|
+
const hi = Math.min(this.meta.lastPage, end);
|
|
698
|
+
const range: Array<{ page: number; url: string; isActive: boolean }> = [];
|
|
699
|
+
for (let page = lo; page <= hi; page++)
|
|
700
|
+
range.push({
|
|
701
|
+
page,
|
|
702
|
+
url: this.getUrl(page),
|
|
703
|
+
isActive: page === this.meta.currentPage,
|
|
704
|
+
});
|
|
705
|
+
return range;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Build the serialized `meta` object: the raw camelCase fields plus page URLs
|
|
710
|
+
* (when a baseUrl is set), remapped through the naming strategy's
|
|
711
|
+
* `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
|
|
712
|
+
* Shared by {@link toJSON} and {@link serialize} so they never diverge.
|
|
713
|
+
*/
|
|
714
|
+
#buildMeta(): Record<string, unknown> {
|
|
715
|
+
const raw: Record<string, unknown> = { ...this.meta };
|
|
716
|
+
if (this.#baseUrl) {
|
|
717
|
+
raw.firstPageUrl = this.getUrl(1);
|
|
718
|
+
raw.lastPageUrl = this.getUrl(this.meta.lastPage);
|
|
719
|
+
const next = this.getNextPageUrl();
|
|
720
|
+
const prev = this.getPreviousPageUrl();
|
|
721
|
+
if (next) raw.nextPageUrl = next;
|
|
722
|
+
if (prev) raw.previousPageUrl = prev;
|
|
723
|
+
}
|
|
724
|
+
const keys = this.#metaKeys;
|
|
725
|
+
if (!keys) return raw;
|
|
726
|
+
const meta: Record<string, unknown> = {};
|
|
727
|
+
for (const [k, v] of Object.entries(raw)) meta[keys[k] ?? k] = v;
|
|
728
|
+
return meta;
|
|
729
|
+
}
|
|
730
|
+
|
|
356
731
|
toJSON(): {
|
|
357
732
|
data: unknown[];
|
|
358
|
-
meta:
|
|
733
|
+
meta: Record<string, unknown>;
|
|
359
734
|
} {
|
|
360
|
-
|
|
361
|
-
...this.meta,
|
|
362
|
-
};
|
|
363
|
-
if (this.#baseUrl) {
|
|
364
|
-
const build = (page: number) => {
|
|
365
|
-
const params = new URLSearchParams();
|
|
366
|
-
for (const [k, v] of Object.entries(this.#queryString))
|
|
367
|
-
params.set(k, String(v));
|
|
368
|
-
params.set("page", String(page));
|
|
369
|
-
return `${this.#baseUrl}?${params.toString()}`;
|
|
370
|
-
};
|
|
371
|
-
meta.firstPageUrl = build(1);
|
|
372
|
-
meta.lastPageUrl = build(this.meta.lastPage);
|
|
373
|
-
if (this.meta.currentPage < this.meta.lastPage)
|
|
374
|
-
meta.nextPageUrl = build(this.meta.currentPage + 1);
|
|
375
|
-
if (this.meta.currentPage > 1)
|
|
376
|
-
meta.previousPageUrl = build(this.meta.currentPage - 1);
|
|
377
|
-
}
|
|
378
|
-
return { data: this.items as unknown[], meta };
|
|
735
|
+
return { data: this.items as unknown[], meta: this.#buildMeta() };
|
|
379
736
|
}
|
|
380
737
|
}
|
|
381
738
|
|
|
@@ -393,34 +750,96 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
393
750
|
#softDeletes: boolean;
|
|
394
751
|
#softScope: SoftDeleteScope = "default";
|
|
395
752
|
#wheres: WhereClause[] = [];
|
|
396
|
-
#orderBys:
|
|
753
|
+
#orderBys: OrderByEntry[] = [];
|
|
397
754
|
#select: string[] = ["*"];
|
|
398
755
|
#limit?: number;
|
|
756
|
+
/** Top-N-per-parent limit for a has-many preload (Lucid `groupLimit`). */
|
|
757
|
+
#groupLimit?: number;
|
|
758
|
+
/** ORDER BY for the per-parent window (Lucid `groupOrderBy`). */
|
|
759
|
+
#groupOrderBy?: { column: string; direction: "asc" | "desc" };
|
|
399
760
|
#offset?: number;
|
|
400
761
|
#preloads = new Map<string, PreloadCallback | undefined>();
|
|
762
|
+
// Per-query row transformers (Adonis Lucid `rowTransformer`). Stored via a
|
|
763
|
+
// method-signature wrapper (not a bare `(row: T) => void` property) so T stays
|
|
764
|
+
// in a bivariant position — a function-typed property would force ModelQuery<T>
|
|
765
|
+
// invariant and break the `this: ModelClass<T>` bound on every static finder.
|
|
766
|
+
#rowTransformers: Array<{ run(row: T): void }> = [];
|
|
401
767
|
/** Correlated subquery projections (withCount / withAggregate). */
|
|
402
768
|
#selectSubqueries: SubqueryProjection[] = [];
|
|
769
|
+
/** Raw / subquery SELECT fragments carrying their own params (Lucid `select(raw)`, `select(subquery.as())`). */
|
|
770
|
+
#selectRaw: Array<{ sql: string; params: unknown[] }> = [];
|
|
771
|
+
/** Caller-facing statement timeout in ms (Lucid `timeout(ms)`), applied via a race in exec. */
|
|
772
|
+
#timeoutMs?: number;
|
|
773
|
+
/** `timeout(ms, { cancel: true })` — also apply a SERVER-side statement timeout. */
|
|
774
|
+
#cancelTimeout = false;
|
|
775
|
+
/** Columns from a chainable `.returning(...)` on a lazy DML builder. */
|
|
776
|
+
#dmlReturning: string[] = [];
|
|
403
777
|
/** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
|
|
404
778
|
#subqueryAlias?: string;
|
|
405
779
|
/** Raw JOIN fragments — Story 29.4. */
|
|
406
|
-
#joins: string[] = [];
|
|
407
|
-
/** Row lock mode — Story 30.8. */
|
|
408
|
-
#lockMode:
|
|
780
|
+
#joins: Array<{ sql: string; params: unknown[] }> = [];
|
|
781
|
+
/** Row lock base mode — Story 30.8. */
|
|
782
|
+
#lockMode:
|
|
783
|
+
| "FOR UPDATE"
|
|
784
|
+
| "FOR SHARE"
|
|
785
|
+
| "FOR NO KEY UPDATE"
|
|
786
|
+
| "FOR KEY SHARE"
|
|
787
|
+
| null = null;
|
|
788
|
+
/** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
|
|
789
|
+
#lockModifier: "SKIP LOCKED" | "NOWAIT" | null = null;
|
|
790
|
+
/** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
|
|
791
|
+
#sideloaded: Record<string, unknown> | null = null;
|
|
409
792
|
/** Per-query debug flag — Story 29.11. */
|
|
410
793
|
#debugFlag = false;
|
|
794
|
+
/** Metadata attached to the db:query event (Adonis Lucid `reporterData`). */
|
|
795
|
+
#reporterData?: Record<string, unknown>;
|
|
796
|
+
/** SQL comments prepended to the compiled query (Lucid/Knex `comment`). */
|
|
797
|
+
#comments: string[] = [];
|
|
411
798
|
/** Distinct flag — Story 29.5. */
|
|
412
799
|
#distinct = false;
|
|
800
|
+
#distinctOn: string[] = [];
|
|
413
801
|
/** GROUP BY columns (Lucid parity). */
|
|
414
|
-
#groupBy:
|
|
802
|
+
#groupBy: GroupByEntry[] = [];
|
|
415
803
|
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
416
804
|
#having: HavingEntry[] = [];
|
|
417
805
|
/** CTEs registered via `.with()` (Lucid parity). */
|
|
418
|
-
#ctes: Array<{
|
|
806
|
+
#ctes: Array<{
|
|
807
|
+
name: string;
|
|
808
|
+
query: ModelQuery<BaseEntity>;
|
|
809
|
+
recursive?: boolean;
|
|
810
|
+
materialized?: boolean;
|
|
811
|
+
columns?: string[];
|
|
812
|
+
}> = [];
|
|
419
813
|
/** UNION / UNION ALL branches (Lucid parity). */
|
|
420
|
-
#unions: Array<{
|
|
814
|
+
#unions: Array<{
|
|
815
|
+
query: ModelQuery<BaseEntity>;
|
|
816
|
+
all: boolean;
|
|
817
|
+
op?: "union" | "intersect" | "except";
|
|
818
|
+
}> = [];
|
|
421
819
|
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
422
|
-
#pivotWheres: Array<{
|
|
423
|
-
|
|
820
|
+
#pivotWheres: Array<{
|
|
821
|
+
column: string;
|
|
822
|
+
operator: string;
|
|
823
|
+
value: unknown;
|
|
824
|
+
/** AND/OR within the parenthesised pivot-filter group — see `#runInQuery`. */
|
|
825
|
+
type: "and" | "or";
|
|
826
|
+
}> = [];
|
|
827
|
+
/** Extra pivot columns requested at query time (Lucid `pivotColumns([...])`). */
|
|
828
|
+
#extraPivotColumns: string[] = [];
|
|
829
|
+
/**
|
|
830
|
+
* Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
|
|
831
|
+
* the relation proxy's scoped query; invoked at `#buildSpec()` time with the
|
|
832
|
+
* CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
|
|
833
|
+
* back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
|
|
834
|
+
* time would freeze the predicate before those calls and silently drop them).
|
|
835
|
+
*/
|
|
836
|
+
#pivotExists?: (
|
|
837
|
+
pivotWheres: ReadonlyArray<{
|
|
838
|
+
column: string;
|
|
839
|
+
operator: string;
|
|
840
|
+
value: unknown;
|
|
841
|
+
}>,
|
|
842
|
+
) => { sql: string; bindings: unknown[] };
|
|
424
843
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
425
844
|
#dialect: AtlasDialect;
|
|
426
845
|
|
|
@@ -432,6 +851,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
432
851
|
resolveColumn: ColumnResolver = (c) => c,
|
|
433
852
|
softDeletes = false,
|
|
434
853
|
dialect: AtlasDialect = getAtlasDialect(),
|
|
854
|
+
prepareValue: ValuePreparer = (_c, v) => v,
|
|
855
|
+
onDomainEvents?: (events: DomainEvent[]) => Promise<void>,
|
|
435
856
|
) {
|
|
436
857
|
this.#tableName = tableName;
|
|
437
858
|
this.#db = db;
|
|
@@ -440,8 +861,15 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
440
861
|
this.#resolveColumn = resolveColumn;
|
|
441
862
|
this.#softDeletes = softDeletes;
|
|
442
863
|
this.#dialect = dialect;
|
|
864
|
+
this.#prepareValue = prepareValue;
|
|
865
|
+
this.#onDomainEvents = onDomainEvents;
|
|
443
866
|
}
|
|
444
867
|
|
|
868
|
+
/** @see ValuePreparer — identity unless the owning repository wires prepare in. */
|
|
869
|
+
#prepareValue: ValuePreparer;
|
|
870
|
+
/** Domain-event bus threaded from the owning repository — propagated to preload repos. */
|
|
871
|
+
#onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
|
|
872
|
+
|
|
445
873
|
/** Include soft-deleted rows in the result (default behavior excludes them). */
|
|
446
874
|
withTrashed(): this {
|
|
447
875
|
this.#softScope = "with-trashed";
|
|
@@ -454,6 +882,27 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
454
882
|
return this;
|
|
455
883
|
}
|
|
456
884
|
|
|
885
|
+
/**
|
|
886
|
+
* Apply the first `[guard, callback]` whose guard is truthy (Adonis Lucid
|
|
887
|
+
* `match`); a trailing bare callback is the default, run when none matched.
|
|
888
|
+
*/
|
|
889
|
+
match(
|
|
890
|
+
...blocks: Array<[unknown, (query: this) => void] | ((query: this) => void)>
|
|
891
|
+
): this {
|
|
892
|
+
for (const block of blocks) {
|
|
893
|
+
if (typeof block === "function") {
|
|
894
|
+
block(this);
|
|
895
|
+
return this;
|
|
896
|
+
}
|
|
897
|
+
const [guard, callback] = block;
|
|
898
|
+
if (guard) {
|
|
899
|
+
callback(this);
|
|
900
|
+
return this;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return this;
|
|
904
|
+
}
|
|
905
|
+
|
|
457
906
|
/**
|
|
458
907
|
* Eager-load a relation (AdonisJS-style).
|
|
459
908
|
* Relations are never loaded automatically — you must call .preload() explicitly.
|
|
@@ -467,14 +916,108 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
467
916
|
return this;
|
|
468
917
|
}
|
|
469
918
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
919
|
+
/**
|
|
920
|
+
* Eager-load a relation only if it hasn't been registered yet (Adonis Lucid
|
|
921
|
+
* `preloadOnce`) — a later `preload`/`preloadOnce` for the same relation is
|
|
922
|
+
* ignored, so a shared scope can safely add a preload without clobbering one
|
|
923
|
+
* the caller already set.
|
|
924
|
+
*/
|
|
925
|
+
preloadOnce(relationName: string, callback?: PreloadCallback): this {
|
|
926
|
+
if (!this.#preloads.has(relationName)) {
|
|
927
|
+
this.#preloads.set(relationName, callback);
|
|
928
|
+
}
|
|
929
|
+
return this;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Register a callback run for every hydrated instance after loading (preloads
|
|
934
|
+
* included) but before the query resolves (Adonis Lucid `rowTransformer`). The
|
|
935
|
+
* callback mutates the instance in place — decorate rows with per-query
|
|
936
|
+
* computed values without a model hook/accessor. Multiple transformers run in
|
|
937
|
+
* registration order.
|
|
938
|
+
*/
|
|
939
|
+
rowTransformer(callback: (row: T) => void): this {
|
|
940
|
+
this.#rowTransformers.push({ run: callback });
|
|
941
|
+
return this;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Select columns (default: `*`). The model query builder extends the database
|
|
946
|
+
* query builder's `select` surface (Lucid parity): bare names, multiple args,
|
|
947
|
+
* a comma-separated string, an array, an `{ alias: 'column' }` object map, a
|
|
948
|
+
* `db.raw(...)` fragment, and a named subquery — `select(subquery.as('x'))`.
|
|
949
|
+
* Plain columns are resolved to their DB column (honouring `@Column`).
|
|
950
|
+
*/
|
|
951
|
+
select(
|
|
952
|
+
...columns: Array<
|
|
953
|
+
| string
|
|
954
|
+
| string[]
|
|
955
|
+
| Record<string, string>
|
|
956
|
+
| RawSql
|
|
957
|
+
| ModelQuery<BaseEntity>
|
|
958
|
+
>
|
|
959
|
+
): this {
|
|
960
|
+
const plain: string[] = [];
|
|
961
|
+
for (const col of columns) {
|
|
962
|
+
if (typeof col === "string") {
|
|
963
|
+
plain.push(...col.split(",").map((c) => c.trim()));
|
|
964
|
+
} else if (Array.isArray(col)) {
|
|
965
|
+
plain.push(...col);
|
|
966
|
+
} else if (col instanceof RawSql) {
|
|
967
|
+
// Lucid `select(db.raw(sql, bindings))` — verbatim fragment + params.
|
|
968
|
+
this.#selectRaw.push({ sql: col.sql, params: [...col.params] });
|
|
969
|
+
} else if (col instanceof ModelQuery) {
|
|
970
|
+
// Lucid `select(subquery.as('alias'))` — a correlated subquery column.
|
|
971
|
+
const alias = col.#subqueryAlias;
|
|
972
|
+
if (!alias) {
|
|
973
|
+
throw new Error(
|
|
974
|
+
"select(subquery) requires the subquery to be named with .as('alias')",
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
const { sql, params } = col.#compiledNative();
|
|
978
|
+
this.#selectRaw.push({
|
|
979
|
+
sql: `(${sql}) AS ${this.#quoteAliasName(alias)}`,
|
|
980
|
+
params,
|
|
981
|
+
});
|
|
982
|
+
} else {
|
|
983
|
+
for (const [alias, expr] of Object.entries(col)) {
|
|
984
|
+
plain.push(`${expr} AS ${alias}`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (plain.length > 0) {
|
|
989
|
+
this.#select = plain.map((c) => this.#resolveSelect(c));
|
|
990
|
+
}
|
|
475
991
|
return this;
|
|
476
992
|
}
|
|
477
993
|
|
|
994
|
+
/** Validate + dialect-quote a bare alias identifier. */
|
|
995
|
+
#quoteAliasName(alias: string): string {
|
|
996
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
|
|
997
|
+
throw new Error(`Invalid alias '${alias}' — expected a bare identifier.`);
|
|
998
|
+
}
|
|
999
|
+
const q = this.#dialect === "mysql" ? "`" : '"';
|
|
1000
|
+
return `${q}${alias}${q}`;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Resolve a bare model-property select/returning target to its DB column
|
|
1005
|
+
* (honouring `@Column({ columnName })`), leaving expressions / aliases /
|
|
1006
|
+
* qualified names / `*` untouched. A bare identifier IS validated through the
|
|
1007
|
+
* column resolver — so a typo like `select('lable')` raises the same Atlas
|
|
1008
|
+
* error as `where`/`orderBy`, rather than reaching the DB.
|
|
1009
|
+
*/
|
|
1010
|
+
#resolveSelect(col: string): string {
|
|
1011
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col)) return this.#resolveColumn(col);
|
|
1012
|
+
// `col as alias` — resolve the (bare) column part to its DB name, keep the
|
|
1013
|
+
// alias verbatim, so `select('label as name')` honours a columnName override.
|
|
1014
|
+
const aliased = col.match(
|
|
1015
|
+
/^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i,
|
|
1016
|
+
);
|
|
1017
|
+
if (aliased) return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
|
|
1018
|
+
return col;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
478
1021
|
where(callback: WhereCallback): this;
|
|
479
1022
|
where(column: string, value: unknown): this;
|
|
480
1023
|
where(column: string, operator: string, value: unknown): this;
|
|
@@ -525,37 +1068,175 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
525
1068
|
return this;
|
|
526
1069
|
}
|
|
527
1070
|
|
|
528
|
-
|
|
529
|
-
|
|
1071
|
+
// ─── AND aliases ──────────────────────────────────────────
|
|
1072
|
+
//
|
|
1073
|
+
// Lucid documents an `and*` spelling alongside every `where*`. They are
|
|
1074
|
+
// exact synonyms — the base methods already default to AND — and exist so a
|
|
1075
|
+
// chain can say so out loud: `.where(a).andWhere(b)`. Kept as thin
|
|
1076
|
+
// delegations rather than duplicated bodies, so they cannot drift.
|
|
1077
|
+
|
|
1078
|
+
andWhere(callback: WhereCallback): this;
|
|
1079
|
+
andWhere(column: string, value: unknown): this;
|
|
1080
|
+
andWhere(column: string, operator: string, value: unknown): this;
|
|
1081
|
+
andWhere(
|
|
1082
|
+
columnOrCb: string | WhereCallback,
|
|
1083
|
+
operatorOrValue?: unknown,
|
|
1084
|
+
value?: unknown,
|
|
1085
|
+
): this {
|
|
1086
|
+
// The 2-arg overload must not forward a phantom third argument: `where`
|
|
1087
|
+
// switches on `value === undefined` to tell `(col, value)` from
|
|
1088
|
+
// `(col, operator, value)`.
|
|
1089
|
+
return typeof columnOrCb === "function"
|
|
1090
|
+
? this.where(columnOrCb)
|
|
1091
|
+
: value === undefined
|
|
1092
|
+
? this.where(columnOrCb, operatorOrValue)
|
|
1093
|
+
: this.where(columnOrCb, operatorOrValue as string, value);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Alias of {@link whereNot} (Lucid parity). */
|
|
1097
|
+
andWhereNot(column: string, value: unknown): this {
|
|
1098
|
+
return this.whereNot(column, value);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Alias of {@link whereIn} (Lucid parity). */
|
|
1102
|
+
andWhereIn(column: string, values: readonly unknown[]): this {
|
|
1103
|
+
return this.whereIn(column, values);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Alias of {@link whereNotIn} (Lucid parity). */
|
|
1107
|
+
andWhereNotIn(column: string, values: readonly unknown[]): this {
|
|
1108
|
+
return this.whereNotIn(column, values);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Alias of {@link whereNull} (Lucid parity). */
|
|
1112
|
+
andWhereNull(column: string): this {
|
|
1113
|
+
return this.whereNull(column);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Alias of {@link whereNotNull} (Lucid parity). */
|
|
1117
|
+
andWhereNotNull(column: string): this {
|
|
1118
|
+
return this.whereNotNull(column);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Alias of {@link whereBetween} (Lucid parity). */
|
|
1122
|
+
andWhereBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
1123
|
+
return this.whereBetween(column, range);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Alias of {@link whereNotBetween} (Lucid parity). */
|
|
1127
|
+
andWhereNotBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
1128
|
+
return this.whereNotBetween(column, range);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Alias of {@link whereLike} (Lucid parity). */
|
|
1132
|
+
andWhereLike(column: string, pattern: string): this {
|
|
1133
|
+
return this.whereLike(column, pattern);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Alias of {@link whereILike} (Lucid parity). */
|
|
1137
|
+
andWhereILike(column: string, pattern: string): this {
|
|
1138
|
+
return this.whereILike(column, pattern);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Alias of {@link whereColumn} (Lucid parity). */
|
|
1142
|
+
andWhereColumn(left: string, operator: string, right: string): this {
|
|
1143
|
+
return this.whereColumn(left, operator, right);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Negated WHERE (Lucid/Knex `whereNot`) — the same forms as {@link where}: a
|
|
1148
|
+
* `(column, [operator,] value)` comparison, an object (`whereNot({ a: 1 })` →
|
|
1149
|
+
* `a != 1`), or a callback group (`whereNot((q) => …)` → `NOT (…)`).
|
|
1150
|
+
*/
|
|
1151
|
+
whereNot(callback: WhereCallback): this;
|
|
1152
|
+
whereNot(conditions: Record<string, unknown>): this;
|
|
1153
|
+
whereNot(column: string, value: unknown): this;
|
|
1154
|
+
whereNot(column: string, operator: string, value: unknown): this;
|
|
1155
|
+
whereNot(
|
|
1156
|
+
columnOrCbOrObj: string | WhereCallback | Record<string, unknown>,
|
|
1157
|
+
operatorOrValue?: unknown,
|
|
1158
|
+
value?: unknown,
|
|
1159
|
+
): this {
|
|
1160
|
+
if (typeof columnOrCbOrObj === "function") {
|
|
1161
|
+
this.#wheres.push({
|
|
1162
|
+
...this.#buildGroup("and", columnOrCbOrObj),
|
|
1163
|
+
negated: true,
|
|
1164
|
+
});
|
|
1165
|
+
return this;
|
|
1166
|
+
}
|
|
1167
|
+
if (typeof columnOrCbOrObj === "object") {
|
|
1168
|
+
for (const [col, val] of Object.entries(columnOrCbOrObj)) {
|
|
1169
|
+
this.#wheres.push({
|
|
1170
|
+
type: "and",
|
|
1171
|
+
column: this.#resolveColumn(col),
|
|
1172
|
+
operator: "!=",
|
|
1173
|
+
value: this.#prep(col, val),
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
return this;
|
|
1177
|
+
}
|
|
1178
|
+
if (value === undefined) {
|
|
1179
|
+
this.#wheres.push({
|
|
1180
|
+
type: "and",
|
|
1181
|
+
column: this.#resolveColumn(columnOrCbOrObj),
|
|
1182
|
+
operator: "!=",
|
|
1183
|
+
value: this.#prep(columnOrCbOrObj, operatorOrValue),
|
|
1184
|
+
});
|
|
1185
|
+
return this;
|
|
1186
|
+
}
|
|
1187
|
+
// (column, operator, value): NOT (col <op> value) via a negated group so any
|
|
1188
|
+
// operator negates correctly without an operator-inversion table.
|
|
1189
|
+
const col = columnOrCbOrObj;
|
|
1190
|
+
const op = operatorOrValue;
|
|
530
1191
|
this.#wheres.push({
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
operator: "!=",
|
|
534
|
-
value,
|
|
1192
|
+
...this.#buildGroup("and", (q) => q.where(col, op as string, value)),
|
|
1193
|
+
negated: true,
|
|
535
1194
|
});
|
|
536
1195
|
return this;
|
|
537
1196
|
}
|
|
538
1197
|
|
|
539
1198
|
/** `WHERE col IN (...)` — accepts an array of values OR a `ModelQuery` subquery source. */
|
|
1199
|
+
whereIn(columns: string[], rows: unknown[][]): this;
|
|
540
1200
|
whereIn(
|
|
541
1201
|
column: string,
|
|
542
1202
|
source: readonly unknown[] | ModelQuery<BaseEntity>,
|
|
1203
|
+
): this;
|
|
1204
|
+
whereIn(
|
|
1205
|
+
columnOrColumns: string | string[],
|
|
1206
|
+
source: readonly unknown[] | unknown[][] | ModelQuery<BaseEntity>,
|
|
543
1207
|
): this {
|
|
1208
|
+
// Tuple form (Lucid `whereIn(['id','email'], [[1,'a@b.com']])`).
|
|
1209
|
+
if (Array.isArray(columnOrColumns)) {
|
|
1210
|
+
const cols = columnOrColumns.map((c) => this.#resolveColumn(c));
|
|
1211
|
+
const rows = (Array.isArray(source) ? source : []).map((r) =>
|
|
1212
|
+
(Array.isArray(r) ? r : [r]).map((v, j) =>
|
|
1213
|
+
this.#prep(cols[j] ?? "", v),
|
|
1214
|
+
),
|
|
1215
|
+
);
|
|
1216
|
+
this.#wheres.push({
|
|
1217
|
+
type: "and",
|
|
1218
|
+
kind: "inTuple",
|
|
1219
|
+
columns: cols,
|
|
1220
|
+
rows,
|
|
1221
|
+
negated: false,
|
|
1222
|
+
});
|
|
1223
|
+
return this;
|
|
1224
|
+
}
|
|
544
1225
|
if (source instanceof ModelQuery) {
|
|
545
1226
|
this.#wheres.push({
|
|
546
1227
|
type: "and",
|
|
547
1228
|
kind: "inSub",
|
|
548
1229
|
negated: false,
|
|
549
|
-
column: this.#resolveColumn(
|
|
1230
|
+
column: this.#resolveColumn(columnOrColumns),
|
|
550
1231
|
subquery: source.#buildSpec(),
|
|
551
1232
|
});
|
|
552
1233
|
return this;
|
|
553
1234
|
}
|
|
554
1235
|
this.#wheres.push({
|
|
555
1236
|
type: "and",
|
|
556
|
-
column: this.#resolveColumn(
|
|
1237
|
+
column: this.#resolveColumn(columnOrColumns),
|
|
557
1238
|
operator: "IN",
|
|
558
|
-
value: [...source],
|
|
1239
|
+
value: this.#prep(columnOrColumns, [...source]),
|
|
559
1240
|
});
|
|
560
1241
|
return this;
|
|
561
1242
|
}
|
|
@@ -579,7 +1260,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
579
1260
|
type: "and",
|
|
580
1261
|
column: this.#resolveColumn(column),
|
|
581
1262
|
operator: "NOT IN",
|
|
582
|
-
value: [...source],
|
|
1263
|
+
value: this.#prep(column, [...source]),
|
|
583
1264
|
});
|
|
584
1265
|
return this;
|
|
585
1266
|
}
|
|
@@ -590,7 +1271,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
590
1271
|
type: "and",
|
|
591
1272
|
column: this.#resolveColumn(column),
|
|
592
1273
|
operator: "BETWEEN",
|
|
593
|
-
value: [...range],
|
|
1274
|
+
value: this.#prep(column, [...range]),
|
|
594
1275
|
});
|
|
595
1276
|
return this;
|
|
596
1277
|
}
|
|
@@ -601,7 +1282,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
601
1282
|
type: "and",
|
|
602
1283
|
column: this.#resolveColumn(column),
|
|
603
1284
|
operator: "NOT BETWEEN",
|
|
604
|
-
value: [...range],
|
|
1285
|
+
value: this.#prep(column, [...range]),
|
|
605
1286
|
});
|
|
606
1287
|
return this;
|
|
607
1288
|
}
|
|
@@ -664,7 +1345,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
664
1345
|
type: "or",
|
|
665
1346
|
column: this.#resolveColumn(column),
|
|
666
1347
|
operator: "!=",
|
|
667
|
-
value,
|
|
1348
|
+
value: this.#prep(column, value),
|
|
668
1349
|
});
|
|
669
1350
|
return this;
|
|
670
1351
|
}
|
|
@@ -688,7 +1369,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
688
1369
|
type: "or",
|
|
689
1370
|
column: this.#resolveColumn(column),
|
|
690
1371
|
operator: "IN",
|
|
691
|
-
value: [...source],
|
|
1372
|
+
value: this.#prep(column, [...source]),
|
|
692
1373
|
});
|
|
693
1374
|
return this;
|
|
694
1375
|
}
|
|
@@ -712,7 +1393,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
712
1393
|
type: "or",
|
|
713
1394
|
column: this.#resolveColumn(column),
|
|
714
1395
|
operator: "NOT IN",
|
|
715
|
-
value: [...source],
|
|
1396
|
+
value: this.#prep(column, [...source]),
|
|
716
1397
|
});
|
|
717
1398
|
return this;
|
|
718
1399
|
}
|
|
@@ -723,7 +1404,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
723
1404
|
type: "or",
|
|
724
1405
|
column: this.#resolveColumn(column),
|
|
725
1406
|
operator: "BETWEEN",
|
|
726
|
-
value: [...range],
|
|
1407
|
+
value: this.#prep(column, [...range]),
|
|
727
1408
|
});
|
|
728
1409
|
return this;
|
|
729
1410
|
}
|
|
@@ -734,7 +1415,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
734
1415
|
type: "or",
|
|
735
1416
|
column: this.#resolveColumn(column),
|
|
736
1417
|
operator: "NOT BETWEEN",
|
|
737
|
-
value: [...range],
|
|
1418
|
+
value: this.#prep(column, [...range]),
|
|
738
1419
|
});
|
|
739
1420
|
return this;
|
|
740
1421
|
}
|
|
@@ -803,9 +1484,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
803
1484
|
* Not exported from the package barrel — only accessible inside the Atlas
|
|
804
1485
|
* codebase via direct ModelQuery instance access.
|
|
805
1486
|
*/
|
|
806
|
-
#pushWhereRaw(
|
|
1487
|
+
#pushWhereRaw(
|
|
1488
|
+
sql: string,
|
|
1489
|
+
bindings: readonly unknown[] = [],
|
|
1490
|
+
type: "and" | "or" = "and",
|
|
1491
|
+
): this {
|
|
807
1492
|
this.#wheres.push({
|
|
808
|
-
type
|
|
1493
|
+
type,
|
|
809
1494
|
kind: "raw",
|
|
810
1495
|
sql,
|
|
811
1496
|
bindings: [...bindings],
|
|
@@ -813,6 +1498,52 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
813
1498
|
return this;
|
|
814
1499
|
}
|
|
815
1500
|
|
|
1501
|
+
/** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
|
|
1502
|
+
andWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
|
|
1503
|
+
return this.whereRaw(sql, bindings);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* `OR <raw fragment>` (Lucid parity).
|
|
1508
|
+
*
|
|
1509
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1510
|
+
* Subject to the same strict-mode gate as {@link whereRaw}.
|
|
1511
|
+
*/
|
|
1512
|
+
orWhereRaw(sql: string, bindings: readonly unknown[] = []): this {
|
|
1513
|
+
this.#assertRawAllowed("orWhereRaw");
|
|
1514
|
+
return this.#pushWhereRaw(sql, bindings, "or");
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** Shared strict-mode gate for the raw WHERE entry points. */
|
|
1518
|
+
#assertRawAllowed(method: string): void {
|
|
1519
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1520
|
+
throw new Error(
|
|
1521
|
+
`${method}() is disabled in Atlas strict mode. ` +
|
|
1522
|
+
"Use whereExpr() or a structured builder method instead. " +
|
|
1523
|
+
"Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.",
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Framework-internal: register the deferred m2m EXISTS predicate for a lazy
|
|
1530
|
+
* `related().query()`. The builder is re-invoked on every `#buildSpec()` with
|
|
1531
|
+
* the pivot constraints known at that moment, so `.wherePivot()` added after
|
|
1532
|
+
* the proxy returned still applies. Not exported from the barrel.
|
|
1533
|
+
*/
|
|
1534
|
+
setPivotExistsBuilder(
|
|
1535
|
+
builder: (
|
|
1536
|
+
pivotWheres: ReadonlyArray<{
|
|
1537
|
+
column: string;
|
|
1538
|
+
operator: string;
|
|
1539
|
+
value: unknown;
|
|
1540
|
+
}>,
|
|
1541
|
+
) => { sql: string; bindings: unknown[] },
|
|
1542
|
+
): this {
|
|
1543
|
+
this.#pivotExists = builder;
|
|
1544
|
+
return this;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
816
1547
|
/**
|
|
817
1548
|
* **SAFE** alternative to `whereRaw` for the common case of a single
|
|
818
1549
|
* SQL expression built from a validated column + operator + bound value.
|
|
@@ -863,6 +1594,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
863
1594
|
`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`,
|
|
864
1595
|
);
|
|
865
1596
|
}
|
|
1597
|
+
// The charset blocks comparison/quote symbols, but bare SQL keywords
|
|
1598
|
+
// (OR / AND / IS / NOT / SELECT …) are just letters and would slip
|
|
1599
|
+
// through, letting `extra` alter the predicate's logical structure
|
|
1600
|
+
// (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
|
|
1601
|
+
// SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
|
|
1602
|
+
// token — arithmetic on columns/numbers/functions only.
|
|
1603
|
+
for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
|
1604
|
+
if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
|
|
1605
|
+
throw new Error(
|
|
1606
|
+
`whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
|
|
1607
|
+
"whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.",
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
866
1611
|
// `op` is interpolated raw into the fragment below, so it MUST be
|
|
867
1612
|
// allow-listed — the 3-arg path gets this from the Rust operator
|
|
868
1613
|
// validation, but the raw 4-arg path bypasses Rust and would
|
|
@@ -881,9 +1626,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
881
1626
|
// the operator against the allow-list above.
|
|
882
1627
|
if (hasExtra) {
|
|
883
1628
|
const q = this.#quote(resolved);
|
|
884
|
-
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1629
|
+
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1630
|
+
this.#prep(column, value),
|
|
1631
|
+
]);
|
|
885
1632
|
}
|
|
886
|
-
this.#wheres.push({
|
|
1633
|
+
this.#wheres.push({
|
|
1634
|
+
type: "and",
|
|
1635
|
+
column: resolved,
|
|
1636
|
+
operator: op,
|
|
1637
|
+
value: this.#prep(column, value),
|
|
1638
|
+
});
|
|
887
1639
|
return this;
|
|
888
1640
|
}
|
|
889
1641
|
|
|
@@ -893,22 +1645,352 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
893
1645
|
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
894
1646
|
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
895
1647
|
*/
|
|
896
|
-
|
|
897
|
-
|
|
1648
|
+
// ─── EXISTS ───────────────────────────────────────────────
|
|
1649
|
+
//
|
|
1650
|
+
// `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
|
|
1651
|
+
// builder `repo.query()` actually hands back, so it was unreachable from
|
|
1652
|
+
// normal use. The subquery is another `ModelQuery`; correlate it to the
|
|
1653
|
+
// outer table with `whereColumn`:
|
|
1654
|
+
//
|
|
1655
|
+
// userRepo.query().whereExists(
|
|
1656
|
+
// postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
|
|
1657
|
+
// )
|
|
1658
|
+
//
|
|
1659
|
+
// For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
|
|
1660
|
+
// join predicate from the relation metadata.
|
|
1661
|
+
|
|
1662
|
+
/** `WHERE EXISTS (subquery)` (Lucid parity). */
|
|
1663
|
+
whereExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1664
|
+
return this.#pushExists("and", false, subquery);
|
|
898
1665
|
}
|
|
899
1666
|
|
|
900
|
-
/**
|
|
901
|
-
|
|
902
|
-
return this.#
|
|
1667
|
+
/** Alias of {@link whereExists} (Lucid parity). */
|
|
1668
|
+
andWhereExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1669
|
+
return this.#pushExists("and", false, subquery);
|
|
903
1670
|
}
|
|
904
1671
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1672
|
+
/** `OR EXISTS (subquery)` (Lucid parity). */
|
|
1673
|
+
orWhereExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1674
|
+
return this.#pushExists("or", false, subquery);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
|
|
1678
|
+
whereNotExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1679
|
+
return this.#pushExists("and", true, subquery);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Alias of {@link whereNotExists} (Lucid parity). */
|
|
1683
|
+
andWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1684
|
+
return this.#pushExists("and", true, subquery);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** `OR NOT EXISTS (subquery)` (Lucid parity). */
|
|
1688
|
+
orWhereNotExists(subquery: ModelQuery<BaseEntity>): this {
|
|
1689
|
+
return this.#pushExists("or", true, subquery);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// ─── JSON ─────────────────────────────────────────────────
|
|
1693
|
+
//
|
|
1694
|
+
// Every value crosses the boundary as a bound param — the path and the
|
|
1695
|
+
// compared value both. Only the column is a quoted identifier. Path access
|
|
1696
|
+
// and containment are each spelled per dialect, and SQLite has no
|
|
1697
|
+
// containment operator, so `*JsonSupersetOf`/`*JsonSubsetOf` refuse there.
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* `WHERE <col at path> <op> ?` — compare a value inside a JSON column
|
|
1701
|
+
* (Lucid/Knex `whereJsonPath`). `path` is a JSONPath (`$.a.b`, `$.items[0]`).
|
|
1702
|
+
*
|
|
1703
|
+
* query.whereJsonPath('data', '$.address.city', '=', 'Paris')
|
|
1704
|
+
*/
|
|
1705
|
+
whereJsonPath(column: string, path: string, value: unknown): this;
|
|
1706
|
+
whereJsonPath(
|
|
1707
|
+
column: string,
|
|
1708
|
+
path: string,
|
|
908
1709
|
operator: string,
|
|
909
|
-
|
|
1710
|
+
value: unknown,
|
|
1711
|
+
): this;
|
|
1712
|
+
whereJsonPath(
|
|
1713
|
+
column: string,
|
|
1714
|
+
path: string,
|
|
1715
|
+
operatorOrValue: unknown,
|
|
1716
|
+
value?: unknown,
|
|
910
1717
|
): this {
|
|
911
|
-
|
|
1718
|
+
return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Alias of {@link whereJsonPath} (Lucid parity). Operator defaults to `=`. */
|
|
1722
|
+
andWhereJsonPath(column: string, path: string, value: unknown): this;
|
|
1723
|
+
andWhereJsonPath(
|
|
1724
|
+
column: string,
|
|
1725
|
+
path: string,
|
|
1726
|
+
operator: string,
|
|
1727
|
+
value: unknown,
|
|
1728
|
+
): this;
|
|
1729
|
+
andWhereJsonPath(
|
|
1730
|
+
column: string,
|
|
1731
|
+
path: string,
|
|
1732
|
+
operatorOrValue: unknown,
|
|
1733
|
+
value?: unknown,
|
|
1734
|
+
): this {
|
|
1735
|
+
return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/** `OR <col at path> <op> ?` (Lucid parity). Operator defaults to `=`. */
|
|
1739
|
+
orWhereJsonPath(column: string, path: string, value: unknown): this;
|
|
1740
|
+
orWhereJsonPath(
|
|
1741
|
+
column: string,
|
|
1742
|
+
path: string,
|
|
1743
|
+
operator: string,
|
|
1744
|
+
value: unknown,
|
|
1745
|
+
): this;
|
|
1746
|
+
orWhereJsonPath(
|
|
1747
|
+
column: string,
|
|
1748
|
+
path: string,
|
|
1749
|
+
operatorOrValue: unknown,
|
|
1750
|
+
value?: unknown,
|
|
1751
|
+
): this {
|
|
1752
|
+
return this.#pushJsonPathArgs("or", column, path, operatorOrValue, value);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/** Resolve the optional-operator JSONPath form, then push (default op `=`). */
|
|
1756
|
+
#pushJsonPathArgs(
|
|
1757
|
+
type: "and" | "or",
|
|
1758
|
+
column: string,
|
|
1759
|
+
path: string,
|
|
1760
|
+
operatorOrValue: unknown,
|
|
1761
|
+
value?: unknown,
|
|
1762
|
+
): this {
|
|
1763
|
+
const [operator, val] =
|
|
1764
|
+
value === undefined
|
|
1765
|
+
? ["=", operatorOrValue]
|
|
1766
|
+
: [String(operatorOrValue), value];
|
|
1767
|
+
return this.#pushJson(
|
|
1768
|
+
type,
|
|
1769
|
+
false,
|
|
1770
|
+
"path",
|
|
1771
|
+
column,
|
|
1772
|
+
val,
|
|
1773
|
+
path,
|
|
1774
|
+
String(operator),
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Structural JSON match (Lucid `whereJson`) — the column's JSON must equal
|
|
1780
|
+
* `value` (canonical comparison on Postgres/MySQL). AND is the default.
|
|
1781
|
+
*/
|
|
1782
|
+
whereJson(column: string, value: unknown): this {
|
|
1783
|
+
return this.#pushJson("and", false, "equals", column, value);
|
|
1784
|
+
}
|
|
1785
|
+
/** Alias of {@link whereJson} (Lucid `andWhereJson`). */
|
|
1786
|
+
andWhereJson(column: string, value: unknown): this {
|
|
1787
|
+
return this.#pushJson("and", false, "equals", column, value);
|
|
1788
|
+
}
|
|
1789
|
+
/** OR form of {@link whereJson} (Lucid `orWhereJson`). */
|
|
1790
|
+
orWhereJson(column: string, value: unknown): this {
|
|
1791
|
+
return this.#pushJson("or", false, "equals", column, value);
|
|
1792
|
+
}
|
|
1793
|
+
/** WHERE NOT structural JSON match (Lucid `whereNotJson`). */
|
|
1794
|
+
whereNotJson(column: string, value: unknown): this {
|
|
1795
|
+
return this.#pushJson("and", true, "equals", column, value);
|
|
1796
|
+
}
|
|
1797
|
+
/** Alias of {@link whereNotJson} (Lucid `andWhereNotJson`). */
|
|
1798
|
+
andWhereNotJson(column: string, value: unknown): this {
|
|
1799
|
+
return this.#pushJson("and", true, "equals", column, value);
|
|
1800
|
+
}
|
|
1801
|
+
/** OR NOT structural JSON match (Lucid `orWhereNotJson`). */
|
|
1802
|
+
orWhereNotJson(column: string, value: unknown): this {
|
|
1803
|
+
return this.#pushJson("or", true, "equals", column, value);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
|
|
1808
|
+
* `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
|
|
1809
|
+
*
|
|
1810
|
+
* Postgres and MySQL only — SQLite has no JSON containment operator and the
|
|
1811
|
+
* compiler raises `E_UNSUPPORTED` there.
|
|
1812
|
+
*/
|
|
1813
|
+
whereJsonSupersetOf(column: string, value: unknown): this {
|
|
1814
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
|
|
1818
|
+
andWhereJsonSupersetOf(column: string, value: unknown): this {
|
|
1819
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
|
|
1823
|
+
orWhereJsonSupersetOf(column: string, value: unknown): this {
|
|
1824
|
+
return this.#pushJson("or", false, "superset", column, value);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** `WHERE NOT (<col> @> ?)` (Lucid parity). */
|
|
1828
|
+
whereNotJsonSupersetOf(column: string, value: unknown): this {
|
|
1829
|
+
return this.#pushJson("and", true, "superset", column, value);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** `OR NOT (<col> @> ?)` (Lucid parity). */
|
|
1833
|
+
orWhereNotJsonSupersetOf(column: string, value: unknown): this {
|
|
1834
|
+
return this.#pushJson("or", true, "superset", column, value);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
|
|
1839
|
+
* `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
|
|
1840
|
+
*/
|
|
1841
|
+
whereJsonSubsetOf(column: string, value: unknown): this {
|
|
1842
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
|
|
1846
|
+
andWhereJsonSubsetOf(column: string, value: unknown): this {
|
|
1847
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
|
|
1851
|
+
orWhereJsonSubsetOf(column: string, value: unknown): this {
|
|
1852
|
+
return this.#pushJson("or", false, "subset", column, value);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
|
|
1856
|
+
whereNotJsonSubsetOf(column: string, value: unknown): this {
|
|
1857
|
+
return this.#pushJson("and", true, "subset", column, value);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/** `OR NOT (<col> <@ ?)` (Lucid parity). */
|
|
1861
|
+
orWhereNotJsonSubsetOf(column: string, value: unknown): this {
|
|
1862
|
+
return this.#pushJson("or", true, "subset", column, value);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Lucid's canonical containment names drop the `Of` suffix — kept as aliases.
|
|
1866
|
+
/** Lucid `whereJsonSuperset` (alias of {@link whereJsonSupersetOf}). */
|
|
1867
|
+
whereJsonSuperset(column: string, value: unknown): this {
|
|
1868
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1869
|
+
}
|
|
1870
|
+
/** Lucid `andWhereJsonSuperset`. */
|
|
1871
|
+
andWhereJsonSuperset(column: string, value: unknown): this {
|
|
1872
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1873
|
+
}
|
|
1874
|
+
/** Lucid `orWhereJsonSuperset`. */
|
|
1875
|
+
orWhereJsonSuperset(column: string, value: unknown): this {
|
|
1876
|
+
return this.#pushJson("or", false, "superset", column, value);
|
|
1877
|
+
}
|
|
1878
|
+
/** Lucid `whereNotJsonSuperset`. */
|
|
1879
|
+
whereNotJsonSuperset(column: string, value: unknown): this {
|
|
1880
|
+
return this.#pushJson("and", true, "superset", column, value);
|
|
1881
|
+
}
|
|
1882
|
+
/** Lucid `orWhereNotJsonSuperset`. */
|
|
1883
|
+
orWhereNotJsonSuperset(column: string, value: unknown): this {
|
|
1884
|
+
return this.#pushJson("or", true, "superset", column, value);
|
|
1885
|
+
}
|
|
1886
|
+
/** Lucid `whereJsonSubset` (alias of {@link whereJsonSubsetOf}). */
|
|
1887
|
+
whereJsonSubset(column: string, value: unknown): this {
|
|
1888
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1889
|
+
}
|
|
1890
|
+
/** Lucid `andWhereJsonSubset`. */
|
|
1891
|
+
andWhereJsonSubset(column: string, value: unknown): this {
|
|
1892
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1893
|
+
}
|
|
1894
|
+
/** Lucid `orWhereJsonSubset`. */
|
|
1895
|
+
orWhereJsonSubset(column: string, value: unknown): this {
|
|
1896
|
+
return this.#pushJson("or", false, "subset", column, value);
|
|
1897
|
+
}
|
|
1898
|
+
/** Lucid `whereNotJsonSubset`. */
|
|
1899
|
+
whereNotJsonSubset(column: string, value: unknown): this {
|
|
1900
|
+
return this.#pushJson("and", true, "subset", column, value);
|
|
1901
|
+
}
|
|
1902
|
+
/** Lucid `orWhereNotJsonSubset`. */
|
|
1903
|
+
orWhereNotJsonSubset(column: string, value: unknown): this {
|
|
1904
|
+
return this.#pushJson("or", true, "subset", column, value);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
#pushJson(
|
|
1908
|
+
type: "and" | "or",
|
|
1909
|
+
negated: boolean,
|
|
1910
|
+
jsonOp: "path" | "superset" | "subset" | "equals",
|
|
1911
|
+
column: string,
|
|
1912
|
+
value: unknown,
|
|
1913
|
+
path?: string,
|
|
1914
|
+
operator?: string,
|
|
1915
|
+
): this {
|
|
1916
|
+
// A JSONPath is bound, not interpolated, so injection is not the concern
|
|
1917
|
+
// here — a clear early error for a malformed path is. Lucid/Knex paths
|
|
1918
|
+
// start at the document root.
|
|
1919
|
+
if (path !== undefined && !path.startsWith("$")) {
|
|
1920
|
+
throw new Error(
|
|
1921
|
+
`whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`,
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
// Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
|
|
1925
|
+
// and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
|
|
1926
|
+
// as-is would not cast. A path comparison keeps its scalar value.
|
|
1927
|
+
const bound =
|
|
1928
|
+
jsonOp === "path"
|
|
1929
|
+
? value
|
|
1930
|
+
: typeof value === "string"
|
|
1931
|
+
? value
|
|
1932
|
+
: JSON.stringify(value);
|
|
1933
|
+
this.#wheres.push({
|
|
1934
|
+
type,
|
|
1935
|
+
kind: "json",
|
|
1936
|
+
jsonOp,
|
|
1937
|
+
column: this.#resolveColumn(column),
|
|
1938
|
+
negated,
|
|
1939
|
+
path,
|
|
1940
|
+
operator,
|
|
1941
|
+
value: bound,
|
|
1942
|
+
});
|
|
1943
|
+
return this;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
#pushExists(
|
|
1947
|
+
type: "and" | "or",
|
|
1948
|
+
negated: boolean,
|
|
1949
|
+
subquery: ModelQuery<BaseEntity>,
|
|
1950
|
+
): this {
|
|
1951
|
+
// `#buildSpec` is private, but private access is per-class, not per
|
|
1952
|
+
// instance: another ModelQuery's spec is reachable from here.
|
|
1953
|
+
this.#wheres.push({
|
|
1954
|
+
type,
|
|
1955
|
+
kind: "exists",
|
|
1956
|
+
negated,
|
|
1957
|
+
subquery: subquery.#buildSpec(),
|
|
1958
|
+
});
|
|
1959
|
+
return this;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
whereColumn(left: string, operator: string, right: string): this {
|
|
1963
|
+
return this.#whereColumn("and", left, operator, right);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** `OR`-combined {@link whereColumn}. */
|
|
1967
|
+
orWhereColumn(left: string, operator: string, right: string): this {
|
|
1968
|
+
return this.#whereColumn("or", left, operator, right);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
|
|
1972
|
+
whereNotColumn(left: string, operator: string, right: string): this {
|
|
1973
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** Alias of {@link whereNotColumn} (Lucid parity). */
|
|
1977
|
+
andWhereNotColumn(left: string, operator: string, right: string): this {
|
|
1978
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/** `OR NOT (left <op> right)` (Lucid parity). */
|
|
1982
|
+
orWhereNotColumn(left: string, operator: string, right: string): this {
|
|
1983
|
+
return this.#whereColumn("or", left, operator, right, true);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
#whereColumn(
|
|
1987
|
+
type: "and" | "or",
|
|
1988
|
+
left: string,
|
|
1989
|
+
operator: string,
|
|
1990
|
+
right: string,
|
|
1991
|
+
negated = false,
|
|
1992
|
+
): this {
|
|
1993
|
+
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
912
1994
|
throw new Error(
|
|
913
1995
|
`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`,
|
|
914
1996
|
);
|
|
@@ -919,7 +2001,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
919
2001
|
// `[table.]column` charset. This closes the injection surface regardless of
|
|
920
2002
|
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
921
2003
|
const safe = (name: string): string => {
|
|
922
|
-
const resolved = this.#
|
|
2004
|
+
const resolved = this.#resolveColumnReference(name);
|
|
923
2005
|
if (
|
|
924
2006
|
!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)
|
|
925
2007
|
) {
|
|
@@ -934,11 +2016,48 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
934
2016
|
.map((part) => this.#quote(part))
|
|
935
2017
|
.join(".");
|
|
936
2018
|
};
|
|
937
|
-
const
|
|
2019
|
+
const predicate = `${safe(left)} ${operator} ${safe(right)}`;
|
|
2020
|
+
// Both operands are already validated identifiers and the operator is
|
|
2021
|
+
// allow-listed, so wrapping in NOT(...) adds no new surface.
|
|
2022
|
+
const sql = negated ? `NOT (${predicate})` : predicate;
|
|
938
2023
|
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
939
2024
|
return this;
|
|
940
2025
|
}
|
|
941
2026
|
|
|
2027
|
+
/**
|
|
2028
|
+
* Resolve a column reference that may legitimately point at a table other
|
|
2029
|
+
* than this query's own.
|
|
2030
|
+
*
|
|
2031
|
+
* `#resolveColumn` only knows the entity's own columns, so it rejects
|
|
2032
|
+
* anything qualified. That is right for a value predicate, but wrong for a
|
|
2033
|
+
* column-to-column one: a correlated subquery
|
|
2034
|
+
* (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
|
|
2035
|
+
* and a joined query both have to name another table, and atlas cannot know
|
|
2036
|
+
* that table's columns. So: an unqualified name resolves as usual (typos
|
|
2037
|
+
* still get the helpful error), and a `table.column` naming a different
|
|
2038
|
+
* table passes through — validated against the identifier charset here and
|
|
2039
|
+
* quoted segment by segment by the caller, never interpolated loose. A typo
|
|
2040
|
+
* in that case surfaces as a database error rather than an atlas one, which
|
|
2041
|
+
* is the unavoidable cost of referencing a table we have no metadata for.
|
|
2042
|
+
*/
|
|
2043
|
+
#resolveColumnReference(name: string): string {
|
|
2044
|
+
const qualified =
|
|
2045
|
+
/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
|
|
2046
|
+
if (!qualified) return this.#resolveColumn(name);
|
|
2047
|
+
|
|
2048
|
+
const [, table, column] = qualified;
|
|
2049
|
+
// Our own table: resolve the column half so `@Column({ columnName })` and
|
|
2050
|
+
// the camel→snake convention still apply.
|
|
2051
|
+
if (table === this.#tableName) {
|
|
2052
|
+
return `${table}.${this.#resolveColumn(column as string)}`;
|
|
2053
|
+
}
|
|
2054
|
+
// Another table in scope (outer query or JOIN). Charset-checked by the
|
|
2055
|
+
// regex above and quoted segment by segment by the caller — strict mode
|
|
2056
|
+
// does not apply, since its concern is unvalidated SQL reaching the
|
|
2057
|
+
// compiler and this identifier is validated.
|
|
2058
|
+
return `${table}.${column}`;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
942
2061
|
/**
|
|
943
2062
|
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
944
2063
|
* by the existence of related rows, optionally constrained by a callback.
|
|
@@ -987,6 +2106,22 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
987
2106
|
return this;
|
|
988
2107
|
}
|
|
989
2108
|
|
|
2109
|
+
/** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
|
|
2110
|
+
andWhereHas(
|
|
2111
|
+
relationName: string,
|
|
2112
|
+
callback?: (query: ModelQuery<BaseEntity>) => void,
|
|
2113
|
+
): this {
|
|
2114
|
+
return this.whereHas(relationName, callback);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/** Alias of {@link whereDoesntHave} (Lucid parity). */
|
|
2118
|
+
andWhereDoesntHave(
|
|
2119
|
+
relationName: string,
|
|
2120
|
+
callback?: (query: ModelQuery<BaseEntity>) => void,
|
|
2121
|
+
): this {
|
|
2122
|
+
return this.whereDoesntHave(relationName, callback);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
990
2125
|
/**
|
|
991
2126
|
* Short form of `whereHas`. With an operator + count, emits a count threshold:
|
|
992
2127
|
* has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
|
|
@@ -1026,6 +2161,26 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1026
2161
|
return this;
|
|
1027
2162
|
}
|
|
1028
2163
|
|
|
2164
|
+
/** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
|
|
2165
|
+
orDoesntHave(relationName: string): this {
|
|
2166
|
+
this.#wheres.push(this.#buildExistsClause("or", true, relationName));
|
|
2167
|
+
return this;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** Alias of {@link has} (Lucid parity) — `has` is already AND. */
|
|
2171
|
+
andHas(
|
|
2172
|
+
relationName: string,
|
|
2173
|
+
countOp?: string,
|
|
2174
|
+
countThreshold?: number,
|
|
2175
|
+
): this {
|
|
2176
|
+
return this.has(relationName, countOp, countThreshold);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/** Alias of {@link doesntHave} (Lucid parity). */
|
|
2180
|
+
andDoesntHave(relationName: string): this {
|
|
2181
|
+
return this.doesntHave(relationName);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1029
2184
|
/**
|
|
1030
2185
|
* Set this query's projection alias — only meaningful when this ModelQuery
|
|
1031
2186
|
* is used as the sub-builder callback argument of `withCount` / `withAggregate`.
|
|
@@ -1070,27 +2225,37 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1070
2225
|
/** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
|
|
1071
2226
|
async count(column: string = "*"): Promise<number> {
|
|
1072
2227
|
const expr =
|
|
1073
|
-
column === "*"
|
|
2228
|
+
column === "*"
|
|
2229
|
+
? "COUNT(*)"
|
|
2230
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
|
|
1074
2231
|
return Number((await this.#runScalar(expr)) ?? 0);
|
|
1075
2232
|
}
|
|
1076
2233
|
|
|
1077
2234
|
async sum(column: string): Promise<number | null> {
|
|
1078
|
-
const v = await this.#runScalar(
|
|
2235
|
+
const v = await this.#runScalar(
|
|
2236
|
+
`SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2237
|
+
);
|
|
1079
2238
|
return v === null || v === undefined ? null : Number(v);
|
|
1080
2239
|
}
|
|
1081
2240
|
|
|
1082
2241
|
async avg(column: string): Promise<number | null> {
|
|
1083
|
-
const v = await this.#runScalar(
|
|
2242
|
+
const v = await this.#runScalar(
|
|
2243
|
+
`AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2244
|
+
);
|
|
1084
2245
|
return v === null || v === undefined ? null : Number(v);
|
|
1085
2246
|
}
|
|
1086
2247
|
|
|
1087
2248
|
async min(column: string): Promise<number | null> {
|
|
1088
|
-
const v = await this.#runScalar(
|
|
2249
|
+
const v = await this.#runScalar(
|
|
2250
|
+
`MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2251
|
+
);
|
|
1089
2252
|
return v === null || v === undefined ? null : Number(v);
|
|
1090
2253
|
}
|
|
1091
2254
|
|
|
1092
2255
|
async max(column: string): Promise<number | null> {
|
|
1093
|
-
const v = await this.#runScalar(
|
|
2256
|
+
const v = await this.#runScalar(
|
|
2257
|
+
`MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2258
|
+
);
|
|
1094
2259
|
return v === null || v === undefined ? null : Number(v);
|
|
1095
2260
|
}
|
|
1096
2261
|
|
|
@@ -1140,37 +2305,172 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1140
2305
|
return this;
|
|
1141
2306
|
}
|
|
1142
2307
|
|
|
1143
|
-
|
|
1144
|
-
|
|
2308
|
+
/** ORDER BY a column, a `db.ref()` fragment, or an array of terms (Lucid/Knex `orderBy`). */
|
|
2309
|
+
orderBy(column: string | RawSql, direction?: "asc" | "desc"): this;
|
|
2310
|
+
orderBy(
|
|
2311
|
+
terms: Array<string | { column: string; order?: "asc" | "desc" }>,
|
|
2312
|
+
): this;
|
|
2313
|
+
orderBy(
|
|
2314
|
+
columnOrTerms:
|
|
2315
|
+
| string
|
|
2316
|
+
| RawSql
|
|
2317
|
+
| Array<string | { column: string; order?: "asc" | "desc" }>,
|
|
2318
|
+
direction: "asc" | "desc" = "asc",
|
|
2319
|
+
): this {
|
|
2320
|
+
// A raw fragment (e.g. `db.ref('posts.created_at')`) orders verbatim.
|
|
2321
|
+
if (columnOrTerms instanceof RawSql) {
|
|
2322
|
+
const dir = direction === "desc" ? "DESC" : "ASC";
|
|
2323
|
+
this.#orderBys.push({ raw: `${columnOrTerms.sql} ${dir}` });
|
|
2324
|
+
return this;
|
|
2325
|
+
}
|
|
2326
|
+
if (Array.isArray(columnOrTerms)) {
|
|
2327
|
+
for (const t of columnOrTerms) {
|
|
2328
|
+
const [col, dir] =
|
|
2329
|
+
typeof t === "string"
|
|
2330
|
+
? [t, "asc" as const]
|
|
2331
|
+
: [t.column, t.order ?? "asc"];
|
|
2332
|
+
this.#orderBys.push({
|
|
2333
|
+
column: this.#resolveColumn(col),
|
|
2334
|
+
direction: dir,
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
return this;
|
|
2338
|
+
}
|
|
2339
|
+
this.#orderBys.push({
|
|
2340
|
+
column: this.#resolveColumn(columnOrTerms),
|
|
2341
|
+
direction,
|
|
2342
|
+
});
|
|
2343
|
+
return this;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
|
|
2348
|
+
* no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
|
|
2349
|
+
* alias.
|
|
2350
|
+
*
|
|
2351
|
+
* query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
|
|
2352
|
+
*
|
|
2353
|
+
* The fragment keeps its position among the plain `orderBy` terms.
|
|
2354
|
+
*
|
|
2355
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
2356
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
2357
|
+
*
|
|
2358
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
2359
|
+
*/
|
|
2360
|
+
orderByRaw(sql: string): this {
|
|
2361
|
+
this.#assertRawAllowed("orderByRaw");
|
|
2362
|
+
this.#orderBys.push({ raw: sql });
|
|
1145
2363
|
return this;
|
|
1146
2364
|
}
|
|
1147
2365
|
|
|
1148
2366
|
/**
|
|
1149
2367
|
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
1150
2368
|
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
1151
|
-
* For a
|
|
1152
|
-
* fluent {@link QueryBuilder}.
|
|
2369
|
+
* For a grouping expression with no typed form, see {@link groupByRaw}.
|
|
1153
2370
|
*/
|
|
1154
2371
|
groupBy(...columns: string[]): this {
|
|
1155
2372
|
for (const c of columns) this.#groupBy.push(this.#resolveColumn(c));
|
|
1156
2373
|
return this;
|
|
1157
2374
|
}
|
|
1158
2375
|
|
|
2376
|
+
/**
|
|
2377
|
+
* `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
|
|
2378
|
+
* no typed form, e.g. `DATE_TRUNC('day', created_at)`.
|
|
2379
|
+
*
|
|
2380
|
+
* The fragment keeps its position among the plain `groupBy` terms.
|
|
2381
|
+
*
|
|
2382
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
2383
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
2384
|
+
*
|
|
2385
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
2386
|
+
*/
|
|
2387
|
+
groupByRaw(sql: string): this {
|
|
2388
|
+
this.#assertRawAllowed("groupByRaw");
|
|
2389
|
+
this.#groupBy.push({ raw: sql });
|
|
2390
|
+
return this;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
1159
2393
|
/**
|
|
1160
2394
|
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
* (`COUNT(*)`, `SUM(col)`, …)
|
|
1164
|
-
*
|
|
2395
|
+
* A bare model property is resolved through the entity column map (honouring
|
|
2396
|
+
* `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
|
|
2397
|
+
* expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
|
|
2398
|
+
* `having` can still reference `withCount`/`withAggregate` aliases.
|
|
1165
2399
|
*/
|
|
1166
2400
|
having(column: string, operator: string, value: unknown): this {
|
|
1167
|
-
this.#having.push({
|
|
2401
|
+
this.#having.push({
|
|
2402
|
+
column: this.#resolveHavingCol(column),
|
|
2403
|
+
operator,
|
|
2404
|
+
value: this.#prep(column, value),
|
|
2405
|
+
type: "and",
|
|
2406
|
+
});
|
|
1168
2407
|
return this;
|
|
1169
2408
|
}
|
|
1170
2409
|
|
|
2410
|
+
/** HAVING col IS NULL (Lucid/Knex `havingNull`). */
|
|
2411
|
+
havingNull(column: string): this {
|
|
2412
|
+
return this.#pushHaving(column, "IS NULL", null);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/** HAVING col IS NOT NULL (Lucid/Knex `havingNotNull`). */
|
|
2416
|
+
havingNotNull(column: string): this {
|
|
2417
|
+
return this.#pushHaving(column, "IS NOT NULL", null);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/** HAVING col IN (...) (Lucid/Knex `havingIn`). */
|
|
2421
|
+
havingIn(column: string, values: unknown[]): this {
|
|
2422
|
+
return this.#pushHaving(column, "IN", [...values]);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/** HAVING col NOT IN (...) (Lucid/Knex `havingNotIn`). */
|
|
2426
|
+
havingNotIn(column: string, values: unknown[]): this {
|
|
2427
|
+
return this.#pushHaving(column, "NOT IN", [...values]);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/** HAVING col BETWEEN ? AND ? (Lucid/Knex `havingBetween`). */
|
|
2431
|
+
havingBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
2432
|
+
return this.#pushHaving(column, "BETWEEN", [...range]);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/** HAVING col NOT BETWEEN ? AND ? (Lucid/Knex `havingNotBetween`). */
|
|
2436
|
+
havingNotBetween(column: string, range: readonly [unknown, unknown]): this {
|
|
2437
|
+
return this.#pushHaving(column, "NOT BETWEEN", [...range]);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/** Push a HAVING entry with a raw (non-`#prep`'d) value — for IN/BETWEEN/NULL. */
|
|
2441
|
+
#pushHaving(column: string, operator: string, value: unknown): this {
|
|
2442
|
+
this.#having.push({
|
|
2443
|
+
column: this.#resolveHavingCol(column),
|
|
2444
|
+
operator,
|
|
2445
|
+
value,
|
|
2446
|
+
type: "and",
|
|
2447
|
+
});
|
|
2448
|
+
return this;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/**
|
|
2452
|
+
* Resolve a HAVING column: a bare model property maps to its DB column
|
|
2453
|
+
* (honouring `@Column({ columnName })`), but an aggregate expression
|
|
2454
|
+
* (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
|
|
2455
|
+
* so `having` can still reference `withCount`/`withAggregate` aliases.
|
|
2456
|
+
*/
|
|
2457
|
+
#resolveHavingCol(column: string): string {
|
|
2458
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) return column;
|
|
2459
|
+
try {
|
|
2460
|
+
return this.#resolveColumn(column);
|
|
2461
|
+
} catch {
|
|
2462
|
+
return column;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
|
|
1171
2466
|
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
1172
2467
|
orHaving(column: string, operator: string, value: unknown): this {
|
|
1173
|
-
this.#having.push({
|
|
2468
|
+
this.#having.push({
|
|
2469
|
+
column: this.#resolveHavingCol(column),
|
|
2470
|
+
operator,
|
|
2471
|
+
value: this.#prep(column, value),
|
|
2472
|
+
type: "or",
|
|
2473
|
+
});
|
|
1174
2474
|
return this;
|
|
1175
2475
|
}
|
|
1176
2476
|
|
|
@@ -1183,6 +2483,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1183
2483
|
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1184
2484
|
*/
|
|
1185
2485
|
havingRaw(sql: string, bindings: readonly unknown[] = []): this {
|
|
2486
|
+
// Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
|
|
2487
|
+
// surface, so prod hardening must be able to neutralise it too.
|
|
2488
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
2489
|
+
throw new Error(
|
|
2490
|
+
"havingRaw() is disabled in Atlas strict mode. " +
|
|
2491
|
+
"Use having(column, operator, value) instead.",
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
1186
2494
|
this.#having.push({
|
|
1187
2495
|
kind: "raw",
|
|
1188
2496
|
sql,
|
|
@@ -1197,27 +2505,161 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1197
2505
|
* appended as a parenthesised UNION branch; its bindings are re-indexed into
|
|
1198
2506
|
* the outer parameter list.
|
|
1199
2507
|
*/
|
|
1200
|
-
union(query:
|
|
1201
|
-
this.#unions.push({ query, all: false });
|
|
2508
|
+
union(query: UnionArg): this {
|
|
2509
|
+
this.#unions.push({ query: this.#resolveUnion(query), all: false });
|
|
1202
2510
|
return this;
|
|
1203
2511
|
}
|
|
1204
2512
|
|
|
1205
2513
|
/** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
|
|
1206
|
-
unionAll(query:
|
|
1207
|
-
this.#unions.push({ query, all: true });
|
|
2514
|
+
unionAll(query: UnionArg): this {
|
|
2515
|
+
this.#unions.push({ query: this.#resolveUnion(query), all: true });
|
|
2516
|
+
return this;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
|
|
2520
|
+
intersect(query: UnionArg): this {
|
|
2521
|
+
this.#unions.push({
|
|
2522
|
+
query: this.#resolveUnion(query),
|
|
2523
|
+
all: false,
|
|
2524
|
+
op: "intersect",
|
|
2525
|
+
});
|
|
2526
|
+
return this;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
|
|
2531
|
+
*
|
|
2532
|
+
* Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
|
|
2533
|
+
* INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
|
|
2534
|
+
* `E_UNSUPPORTED` there rather than emitting a syntax error.
|
|
2535
|
+
*/
|
|
2536
|
+
intersectAll(query: UnionArg): this {
|
|
2537
|
+
this.#unions.push({
|
|
2538
|
+
query: this.#resolveUnion(query),
|
|
2539
|
+
all: true,
|
|
2540
|
+
op: "intersect",
|
|
2541
|
+
});
|
|
2542
|
+
return this;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
|
|
2546
|
+
except(query: UnionArg): this {
|
|
2547
|
+
this.#unions.push({
|
|
2548
|
+
query: this.#resolveUnion(query),
|
|
2549
|
+
all: false,
|
|
2550
|
+
op: "except",
|
|
2551
|
+
});
|
|
2552
|
+
return this;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
/** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
|
|
2556
|
+
exceptAll(query: UnionArg): this {
|
|
2557
|
+
this.#unions.push({
|
|
2558
|
+
query: this.#resolveUnion(query),
|
|
2559
|
+
all: true,
|
|
2560
|
+
op: "except",
|
|
2561
|
+
});
|
|
1208
2562
|
return this;
|
|
1209
2563
|
}
|
|
1210
2564
|
|
|
2565
|
+
/** A compound-query arg — an explicit ModelQuery OR a callback building one on this model. */
|
|
2566
|
+
#resolveUnion(query: UnionArg): ModelQuery<BaseEntity> {
|
|
2567
|
+
if (typeof query !== "function") return query;
|
|
2568
|
+
const sub = this.#freshQuery();
|
|
2569
|
+
query(sub);
|
|
2570
|
+
return sub;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
/** A fresh, empty query on the SAME model + connection (for compound callbacks). */
|
|
2574
|
+
#freshQuery(): ModelQuery<BaseEntity> {
|
|
2575
|
+
return new ModelQuery<BaseEntity>(
|
|
2576
|
+
this.#tableName,
|
|
2577
|
+
this.#db,
|
|
2578
|
+
this.#hydrateFn,
|
|
2579
|
+
this.#entityClass,
|
|
2580
|
+
this.#resolveColumn,
|
|
2581
|
+
this.#softDeletes,
|
|
2582
|
+
this.#dialect,
|
|
2583
|
+
this.#prepareValue,
|
|
2584
|
+
this.#onDomainEvents,
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
1211
2588
|
/**
|
|
1212
2589
|
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
1213
2590
|
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
1214
2591
|
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
1215
2592
|
*/
|
|
1216
|
-
with(name: string, query:
|
|
2593
|
+
with(name: string, query: UnionArg): this {
|
|
2594
|
+
return this.#pushCte("with", name, query, {});
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/**
|
|
2598
|
+
* `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
|
|
2599
|
+
* (Lucid/Knex `withRecursive`), for trees and graph walks.
|
|
2600
|
+
*
|
|
2601
|
+
* RECURSIVE is a property of the WITH clause rather than of one CTE, so a
|
|
2602
|
+
* single recursive entry makes the whole clause recursive — which is what
|
|
2603
|
+
* all three dialects require. Mixing `with()` and `withRecursive()` is fine.
|
|
2604
|
+
*
|
|
2605
|
+
* The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
|
|
2606
|
+
* an anchor `SELECT` unioned with a select that references `<name>`.
|
|
2607
|
+
*/
|
|
2608
|
+
withRecursive(name: string, query: UnionArg, columns?: string[]): this {
|
|
2609
|
+
return this.#pushCte("withRecursive", name, query, {
|
|
2610
|
+
recursive: true,
|
|
2611
|
+
columns,
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
/**
|
|
2616
|
+
* `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
|
|
2617
|
+
* once and stashed (Lucid/Knex `withMaterialized`).
|
|
2618
|
+
*
|
|
2619
|
+
* Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
|
|
2620
|
+
* compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
|
|
2621
|
+
*/
|
|
2622
|
+
withMaterialized(name: string, query: UnionArg): this {
|
|
2623
|
+
return this.#pushCte("withMaterialized", name, query, {
|
|
2624
|
+
materialized: true,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
|
|
2629
|
+
withNotMaterialized(name: string, query: UnionArg): this {
|
|
2630
|
+
return this.#pushCte("withNotMaterialized", name, query, {
|
|
2631
|
+
materialized: false,
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
#pushCte(
|
|
2636
|
+
method: string,
|
|
2637
|
+
name: string,
|
|
2638
|
+
query: UnionArg,
|
|
2639
|
+
options: {
|
|
2640
|
+
recursive?: boolean;
|
|
2641
|
+
materialized?: boolean;
|
|
2642
|
+
columns?: string[];
|
|
2643
|
+
},
|
|
2644
|
+
): this {
|
|
1217
2645
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1218
|
-
throw new Error(
|
|
2646
|
+
throw new Error(
|
|
2647
|
+
`${method}(): CTE name '${name}' is not a valid identifier`,
|
|
2648
|
+
);
|
|
1219
2649
|
}
|
|
1220
|
-
this.#ctes.push({ name, query });
|
|
2650
|
+
this.#ctes.push({ name, query: this.#resolveUnion(query), ...options });
|
|
2651
|
+
return this;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* `@ManyToMany` only — project EXTRA pivot columns at query time (AdonisJS/Lucid
|
|
2656
|
+
* `pivotColumns([...])`), on top of any declared in the relation decorator. Read
|
|
2657
|
+
* off each loaded relation as `$extras.pivot_<col>`. Inert on non-m2m relations.
|
|
2658
|
+
*
|
|
2659
|
+
* userRepo.query().preload('skills', q => q.pivotColumns(['notes']))
|
|
2660
|
+
*/
|
|
2661
|
+
pivotColumns(columns: string[]): this {
|
|
2662
|
+
this.#extraPivotColumns.push(...columns);
|
|
1221
2663
|
return this;
|
|
1222
2664
|
}
|
|
1223
2665
|
|
|
@@ -1232,21 +2674,180 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1232
2674
|
wherePivot(column: string, value: unknown): this;
|
|
1233
2675
|
wherePivot(column: string, operator: string, value: unknown): this;
|
|
1234
2676
|
wherePivot(column: string, operatorOrValue: unknown, value?: unknown): this {
|
|
2677
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
/** Alias of {@link wherePivot} (Lucid parity) — pivot filters already AND together. */
|
|
2681
|
+
andWherePivot(column: string, value: unknown): this;
|
|
2682
|
+
andWherePivot(column: string, operator: string, value: unknown): this;
|
|
2683
|
+
andWherePivot(
|
|
2684
|
+
column: string,
|
|
2685
|
+
operatorOrValue: unknown,
|
|
2686
|
+
value?: unknown,
|
|
2687
|
+
): this {
|
|
2688
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/**
|
|
2692
|
+
* `@ManyToMany` only — OR form of {@link wherePivot} (Lucid parity).
|
|
2693
|
+
*
|
|
2694
|
+
* The pivot filters are compiled as a parenthesised group, so an OR joins
|
|
2695
|
+
* the other pivot filters and cannot escape the `pivot_fk IN (parents)`
|
|
2696
|
+
* scoping that makes the preload correct.
|
|
2697
|
+
*/
|
|
2698
|
+
orWherePivot(column: string, value: unknown): this;
|
|
2699
|
+
orWherePivot(column: string, operator: string, value: unknown): this;
|
|
2700
|
+
orWherePivot(
|
|
2701
|
+
column: string,
|
|
2702
|
+
operatorOrValue: unknown,
|
|
2703
|
+
value?: unknown,
|
|
2704
|
+
): this {
|
|
2705
|
+
return this.#pushPivot("or", column, operatorOrValue, value);
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
|
|
2709
|
+
whereInPivot(column: string, values: readonly unknown[]): this {
|
|
2710
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/** Alias of {@link whereInPivot} (Lucid parity). */
|
|
2714
|
+
andWhereInPivot(column: string, values: readonly unknown[]): this {
|
|
2715
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
/** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
|
|
2719
|
+
orWhereInPivot(column: string, values: readonly unknown[]): this {
|
|
2720
|
+
return this.#pushPivotOp("or", column, "IN", [...values]);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/** Alias of {@link whereInPivot} kept for the earlier atlas name. */
|
|
2724
|
+
wherePivotIn(column: string, values: readonly unknown[]): this {
|
|
2725
|
+
return this.whereInPivot(column, values);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/**
|
|
2729
|
+
* `@ManyToMany` only — negated pivot filter (AdonisJS Lucid `whereNotPivot`).
|
|
2730
|
+
* `whereNotPivot(col, value)` → `col != value`; `whereNotPivot(col, op, value)`
|
|
2731
|
+
* → the negated comparison (`whereNotPivot('proficiency', '>=', 4)` → `< 4`).
|
|
2732
|
+
*/
|
|
2733
|
+
whereNotPivot(column: string, value: unknown): this;
|
|
2734
|
+
whereNotPivot(column: string, operator: string, value: unknown): this;
|
|
2735
|
+
whereNotPivot(
|
|
2736
|
+
column: string,
|
|
2737
|
+
operatorOrValue: unknown,
|
|
2738
|
+
value?: unknown,
|
|
2739
|
+
): this {
|
|
1235
2740
|
if (value === undefined) {
|
|
1236
|
-
this.#
|
|
1237
|
-
} else {
|
|
1238
|
-
this.#pivotWheres.push({
|
|
1239
|
-
column,
|
|
1240
|
-
operator: operatorOrValue as string,
|
|
1241
|
-
value,
|
|
1242
|
-
});
|
|
2741
|
+
return this.#pushPivotOp("and", column, "!=", operatorOrValue);
|
|
1243
2742
|
}
|
|
1244
|
-
return this
|
|
2743
|
+
return this.#pushPivotOp(
|
|
2744
|
+
"and",
|
|
2745
|
+
column,
|
|
2746
|
+
negateOperator(String(operatorOrValue)),
|
|
2747
|
+
value,
|
|
2748
|
+
);
|
|
1245
2749
|
}
|
|
1246
2750
|
|
|
1247
|
-
/**
|
|
1248
|
-
|
|
1249
|
-
|
|
2751
|
+
/** Alias of {@link whereNotPivot} (Lucid parity). Operator form supported too. */
|
|
2752
|
+
andWhereNotPivot(column: string, value: unknown): this;
|
|
2753
|
+
andWhereNotPivot(column: string, operator: string, value: unknown): this;
|
|
2754
|
+
andWhereNotPivot(
|
|
2755
|
+
column: string,
|
|
2756
|
+
operatorOrValue: unknown,
|
|
2757
|
+
value?: unknown,
|
|
2758
|
+
): this {
|
|
2759
|
+
return value === undefined
|
|
2760
|
+
? this.#pushPivotOp("and", column, "!=", operatorOrValue)
|
|
2761
|
+
: this.#pushPivotOp(
|
|
2762
|
+
"and",
|
|
2763
|
+
column,
|
|
2764
|
+
negateOperator(String(operatorOrValue)),
|
|
2765
|
+
value,
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
/** `@ManyToMany` only — OR form of {@link whereNotPivot} (Lucid parity). */
|
|
2770
|
+
orWhereNotPivot(column: string, value: unknown): this;
|
|
2771
|
+
orWhereNotPivot(column: string, operator: string, value: unknown): this;
|
|
2772
|
+
orWhereNotPivot(
|
|
2773
|
+
column: string,
|
|
2774
|
+
operatorOrValue: unknown,
|
|
2775
|
+
value?: unknown,
|
|
2776
|
+
): this {
|
|
2777
|
+
return value === undefined
|
|
2778
|
+
? this.#pushPivotOp("or", column, "!=", operatorOrValue)
|
|
2779
|
+
: this.#pushPivotOp(
|
|
2780
|
+
"or",
|
|
2781
|
+
column,
|
|
2782
|
+
negateOperator(String(operatorOrValue)),
|
|
2783
|
+
value,
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
|
|
2788
|
+
whereNotInPivot(column: string, values: readonly unknown[]): this {
|
|
2789
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/** Alias of {@link whereNotInPivot} (Lucid parity). */
|
|
2793
|
+
andWhereNotInPivot(column: string, values: readonly unknown[]): this {
|
|
2794
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
|
|
2798
|
+
orWhereNotInPivot(column: string, values: readonly unknown[]): this {
|
|
2799
|
+
return this.#pushPivotOp("or", column, "NOT IN", [...values]);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
|
|
2803
|
+
whereNullPivot(column: string): this {
|
|
2804
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/** Alias of {@link whereNullPivot} (Lucid parity). */
|
|
2808
|
+
andWhereNullPivot(column: string): this {
|
|
2809
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
/** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
|
|
2813
|
+
orWhereNullPivot(column: string): this {
|
|
2814
|
+
return this.#pushPivotOp("or", column, "IS NULL", null);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
|
|
2818
|
+
whereNotNullPivot(column: string): this {
|
|
2819
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
/** Alias of {@link whereNotNullPivot} (Lucid parity). */
|
|
2823
|
+
andWhereNotNullPivot(column: string): this {
|
|
2824
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
|
|
2828
|
+
orWhereNotNullPivot(column: string): this {
|
|
2829
|
+
return this.#pushPivotOp("or", column, "IS NOT NULL", null);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
/** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
|
|
2833
|
+
#pushPivot(
|
|
2834
|
+
type: "and" | "or",
|
|
2835
|
+
column: string,
|
|
2836
|
+
operatorOrValue: unknown,
|
|
2837
|
+
value?: unknown,
|
|
2838
|
+
): this {
|
|
2839
|
+
return value === undefined
|
|
2840
|
+
? this.#pushPivotOp(type, column, "=", operatorOrValue)
|
|
2841
|
+
: this.#pushPivotOp(type, column, operatorOrValue as string, value);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
#pushPivotOp(
|
|
2845
|
+
type: "and" | "or",
|
|
2846
|
+
column: string,
|
|
2847
|
+
operator: string,
|
|
2848
|
+
value: unknown,
|
|
2849
|
+
): this {
|
|
2850
|
+
this.#pivotWheres.push({ column, operator, value, type });
|
|
1250
2851
|
return this;
|
|
1251
2852
|
}
|
|
1252
2853
|
|
|
@@ -1255,10 +2856,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1255
2856
|
column: string;
|
|
1256
2857
|
operator: string;
|
|
1257
2858
|
value: unknown;
|
|
2859
|
+
type: "and" | "or";
|
|
1258
2860
|
}> {
|
|
1259
2861
|
return this.#pivotWheres;
|
|
1260
2862
|
}
|
|
1261
2863
|
|
|
2864
|
+
/** Read-only accessor for query-time pivot columns — consumed by the m2m preload resolver. */
|
|
2865
|
+
get pivotColumnRequests(): readonly string[] {
|
|
2866
|
+
return this.#extraPivotColumns;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
1262
2869
|
limit(n: number): this {
|
|
1263
2870
|
// Guard here with a clear message — the Rust spec types limit as
|
|
1264
2871
|
// u64, so a negative/non-integer otherwise surfaces as a cryptic
|
|
@@ -1271,6 +2878,26 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1271
2878
|
return this;
|
|
1272
2879
|
}
|
|
1273
2880
|
|
|
2881
|
+
/**
|
|
2882
|
+
* In a has-many preload callback, cap the related rows PER PARENT (Lucid
|
|
2883
|
+
* `groupLimit`) — a plain `.limit()` caps the whole result set across parents.
|
|
2884
|
+
* Compiled with a `ROW_NUMBER() OVER (PARTITION BY <fk> …)` window. Pair with
|
|
2885
|
+
* {@link groupOrderBy} to pick which N per parent.
|
|
2886
|
+
*/
|
|
2887
|
+
groupLimit(n: number): this {
|
|
2888
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
2889
|
+
throw new Error(`groupLimit must be a non-negative integer, got ${n}`);
|
|
2890
|
+
}
|
|
2891
|
+
this.#groupLimit = n;
|
|
2892
|
+
return this;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/** ORDER BY for the {@link groupLimit} per-parent window (Lucid `groupOrderBy`). */
|
|
2896
|
+
groupOrderBy(column: string, direction: "asc" | "desc" = "asc"): this {
|
|
2897
|
+
this.#groupOrderBy = { column, direction };
|
|
2898
|
+
return this;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
1274
2901
|
offset(n: number): this {
|
|
1275
2902
|
if (!Number.isInteger(n) || n < 0) {
|
|
1276
2903
|
throw new Error(`offset must be a non-negative integer, got ${n}`);
|
|
@@ -1341,21 +2968,99 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1341
2968
|
}
|
|
1342
2969
|
|
|
1343
2970
|
/** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
|
|
2971
|
+
/**
|
|
2972
|
+
* DB column backing the soft-delete `deletedAt` property — honours a
|
|
2973
|
+
* `@Column({ columnName })` override, read straight from the entity metadata
|
|
2974
|
+
* (not the resolver callback, which is identity for subqueries/preloads).
|
|
2975
|
+
*/
|
|
2976
|
+
#deletedAtColumn(): string {
|
|
2977
|
+
const col = this.#entityClass
|
|
2978
|
+
? getColumnMetadata(this.#entityClass).find(
|
|
2979
|
+
(c) => c.propertyKey === "deletedAt",
|
|
2980
|
+
)
|
|
2981
|
+
: undefined;
|
|
2982
|
+
return col?.columnName ?? "deleted_at";
|
|
2983
|
+
}
|
|
2984
|
+
|
|
1344
2985
|
#buildSpec(): SelectSpec {
|
|
2986
|
+
// `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
|
|
2987
|
+
// compiler emits the lock clause only when a base mode is set, so a lone
|
|
2988
|
+
// modifier would be a SILENT no-op (dangerous for job-queue polling that
|
|
2989
|
+
// believes it skips locked rows). Fail loud instead. Order-independent: this
|
|
2990
|
+
// fires whether the modifier was chained before or after the base lock.
|
|
2991
|
+
if (this.#lockModifier && !this.#lockMode) {
|
|
2992
|
+
throw new Error(
|
|
2993
|
+
`${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`,
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
// With a JOIN and the default `SELECT *`, scope the projection to the base
|
|
2997
|
+
// table's declared columns so joined columns can't clobber the model's fields
|
|
2998
|
+
// (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
|
|
2999
|
+
// AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
|
|
3000
|
+
let selectCols = this.#select;
|
|
3001
|
+
if (
|
|
3002
|
+
this.#joins.length > 0 &&
|
|
3003
|
+
this.#select.length === 1 &&
|
|
3004
|
+
this.#select[0] === "*"
|
|
3005
|
+
) {
|
|
3006
|
+
const cols = getColumnMetadata(this.#entityClass).map(
|
|
3007
|
+
(c) =>
|
|
3008
|
+
`${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`,
|
|
3009
|
+
);
|
|
3010
|
+
if (cols.length > 0) selectCols = cols;
|
|
3011
|
+
} else if (
|
|
3012
|
+
!(selectCols.length === 1 && selectCols[0] === "*") &&
|
|
3013
|
+
selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))
|
|
3014
|
+
) {
|
|
3015
|
+
// A partial `select()` of PLAIN columns that omits the primary key would
|
|
3016
|
+
// hydrate a persisted entity with no PK — a later save() would then INSERT
|
|
3017
|
+
// instead of UPDATE (double-write / unique violation / spurious
|
|
3018
|
+
// beforeCreate). Auto-include the (base-table-qualified) PK so model
|
|
3019
|
+
// entities stay saveable. Aggregate/alias/expression selects are left
|
|
3020
|
+
// untouched — use `.pojo()` for those.
|
|
3021
|
+
const pkProp = getPrimaryKey(this.#entityClass);
|
|
3022
|
+
if (pkProp) {
|
|
3023
|
+
const pkCol =
|
|
3024
|
+
getColumnMetadata(this.#entityClass).find(
|
|
3025
|
+
(c) => c.propertyKey === pkProp,
|
|
3026
|
+
)?.columnName ?? camelToSnake(pkProp);
|
|
3027
|
+
// The PK counts as present ONLY as the bare column or the BASE-table-
|
|
3028
|
+
// qualified column. A joined `other.id` must NOT satisfy it (its leaf
|
|
3029
|
+
// collides with the PK name but it's a different table's row) — otherwise
|
|
3030
|
+
// we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
|
|
3031
|
+
// save(). Appended last, `base.id` also wins the duplicate result key
|
|
3032
|
+
// (rows collect in column order, last-wins) so the base row's PK hydrates.
|
|
3033
|
+
const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
|
|
3034
|
+
if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
|
|
3035
|
+
selectCols = [...selectCols, baseQualifiedPk];
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
1345
3039
|
const wheres: WhereClause[] = [...this.#wheres];
|
|
1346
|
-
//
|
|
3040
|
+
// Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
|
|
3041
|
+
// `.wherePivot()` recorded since the proxy handed back this query (pushed to
|
|
3042
|
+
// the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
|
|
3043
|
+
if (this.#pivotExists) {
|
|
3044
|
+
const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
|
|
3045
|
+
wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
|
|
3046
|
+
}
|
|
3047
|
+
// Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
|
|
3048
|
+
// Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
|
|
3049
|
+
// override on the soft-delete column is honoured on the read side too — matching
|
|
3050
|
+
// the write side (delete/restore go through #dbColumn).
|
|
1347
3051
|
if (this.#softDeletes) {
|
|
3052
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
1348
3053
|
if (this.#softScope === "default") {
|
|
1349
3054
|
wheres.push({
|
|
1350
3055
|
type: "and",
|
|
1351
|
-
column:
|
|
3056
|
+
column: deletedAtCol,
|
|
1352
3057
|
operator: "IS NULL",
|
|
1353
3058
|
value: null,
|
|
1354
3059
|
});
|
|
1355
3060
|
} else if (this.#softScope === "only-trashed") {
|
|
1356
3061
|
wheres.push({
|
|
1357
3062
|
type: "and",
|
|
1358
|
-
column:
|
|
3063
|
+
column: deletedAtCol,
|
|
1359
3064
|
operator: "IS NOT NULL",
|
|
1360
3065
|
value: null,
|
|
1361
3066
|
});
|
|
@@ -1366,8 +3071,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1366
3071
|
return {
|
|
1367
3072
|
kind: "select",
|
|
1368
3073
|
table: this.#tableName,
|
|
1369
|
-
select:
|
|
3074
|
+
select: selectCols,
|
|
1370
3075
|
selectSubqueries: this.#selectSubqueries,
|
|
3076
|
+
selectRaw: this.#selectRaw,
|
|
1371
3077
|
wheres,
|
|
1372
3078
|
orderBy: this.#orderBys,
|
|
1373
3079
|
groupBy: this.#groupBy,
|
|
@@ -1375,23 +3081,148 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1375
3081
|
limit: this.#limit ?? null,
|
|
1376
3082
|
offset: this.#offset ?? null,
|
|
1377
3083
|
distinct: this.#distinct,
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
return { name: c.name, sql, params };
|
|
1381
|
-
}),
|
|
3084
|
+
distinctOn: this.#distinctOn,
|
|
3085
|
+
ctes: this.#compiledCtes(),
|
|
1382
3086
|
unions: this.#unions.map((u) => {
|
|
1383
|
-
const { sql, params } = u.query
|
|
1384
|
-
return { sql, params, all: u.all };
|
|
3087
|
+
const { sql, params } = u.query.#compiledNative();
|
|
3088
|
+
return { sql, params, all: u.all, op: u.op ?? null };
|
|
1385
3089
|
}),
|
|
1386
3090
|
joins: this.#joins,
|
|
1387
|
-
lockMode: this.#lockMode
|
|
3091
|
+
lockMode: this.#lockMode
|
|
3092
|
+
? this.#lockModifier
|
|
3093
|
+
? `${this.#lockMode} ${this.#lockModifier}`
|
|
3094
|
+
: this.#lockMode
|
|
3095
|
+
: null,
|
|
1388
3096
|
};
|
|
1389
3097
|
}
|
|
1390
3098
|
|
|
1391
|
-
/**
|
|
1392
|
-
|
|
3099
|
+
/**
|
|
3100
|
+
* Native compiled `{ sql, params }` (Postgres `$N`) — the form atlas executes
|
|
3101
|
+
* and embeds inside parent queries. Public `toSQL()` normalizes to Knex `?`.
|
|
3102
|
+
*/
|
|
3103
|
+
#compiledNative(): { sql: string; params: unknown[] } {
|
|
1393
3104
|
const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
|
|
1394
|
-
|
|
3105
|
+
const sql = this.#commentPrefix() + compiled.statements[0];
|
|
3106
|
+
return { sql, params: compiled.params };
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
/**
|
|
3110
|
+
* Build SQL via the Rust query compiler (Lucid `toSQL`). `.sql` uses `?`
|
|
3111
|
+
* placeholders (Knex-normalized, like Lucid); `.toNative()` yields the native
|
|
3112
|
+
* form. Returns `bindings` (Lucid's name) and `params` (atlas's) — same array.
|
|
3113
|
+
*/
|
|
3114
|
+
toSQL(): CompiledStatement {
|
|
3115
|
+
const { sql, params } = this.#compiledNative();
|
|
3116
|
+
return compiledStatement(sql, params);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
/** `{ sql, bindings }` — the compiled native query (Lucid/Knex `toNative`). */
|
|
3120
|
+
toNative(): { sql: string; bindings: unknown[] } {
|
|
3121
|
+
const { sql, params } = this.#compiledNative();
|
|
3122
|
+
return { sql, bindings: params };
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
/** The model class this query targets (Adonis Lucid `query.model`). */
|
|
3126
|
+
get model(): new () => T {
|
|
3127
|
+
return this.#entityClass;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
/** Apply `cb` only on the given dialect(s) (Lucid `ifDialect`; Lucid names accepted). */
|
|
3131
|
+
ifDialect(
|
|
3132
|
+
dialect: DialectName | DialectName[],
|
|
3133
|
+
cb: (query: this) => void,
|
|
3134
|
+
): this {
|
|
3135
|
+
const set = (Array.isArray(dialect) ? dialect : [dialect]).map(
|
|
3136
|
+
normalizeDialect,
|
|
3137
|
+
);
|
|
3138
|
+
if (set.includes(this.#dialect)) cb(this);
|
|
3139
|
+
return this;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/** Apply `cb` on every dialect EXCEPT the given one(s) (Lucid `unlessDialect`). */
|
|
3143
|
+
unlessDialect(
|
|
3144
|
+
dialect: DialectName | DialectName[],
|
|
3145
|
+
cb: (query: this) => void,
|
|
3146
|
+
): this {
|
|
3147
|
+
const set = (Array.isArray(dialect) ? dialect : [dialect]).map(
|
|
3148
|
+
normalizeDialect,
|
|
3149
|
+
);
|
|
3150
|
+
if (!set.includes(this.#dialect)) cb(this);
|
|
3151
|
+
return this;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
/** Prepend a `/* … */` SQL comment to the compiled query (Lucid/Knex `comment`). */
|
|
3155
|
+
comment(text: string): this {
|
|
3156
|
+
if (text.includes("*/")) {
|
|
3157
|
+
throw new Error("comment() text may not contain '*/'");
|
|
3158
|
+
}
|
|
3159
|
+
this.#comments.push(text);
|
|
3160
|
+
return this;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
/**
|
|
3164
|
+
* Set a caller-facing statement timeout in milliseconds (Lucid `timeout(ms)`).
|
|
3165
|
+
* The awaiting promise rejects after `ms` on the primary result fetch. With
|
|
3166
|
+
* `{ cancel: true }` a SERVER-side statement timeout is also applied — Postgres
|
|
3167
|
+
* `statement_timeout`, MySQL `MAX_EXECUTION_TIME` (SELECT) — so the server
|
|
3168
|
+
* aborts the query, not just the client. (SQLite has no server timeout; the
|
|
3169
|
+
* client race applies.) Called with no argument it clears the timeout.
|
|
3170
|
+
*/
|
|
3171
|
+
timeout(ms?: number, options?: { cancel?: boolean }): this {
|
|
3172
|
+
// `{ cancel: true }` also applies a SERVER-side statement timeout (Postgres
|
|
3173
|
+
// statement_timeout / MySQL MAX_EXECUTION_TIME for SELECT); SQLite has none,
|
|
3174
|
+
// so the client race applies there.
|
|
3175
|
+
this.#timeoutMs = ms;
|
|
3176
|
+
this.#cancelTimeout = options?.cancel === true;
|
|
3177
|
+
return this;
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
/**
|
|
3181
|
+
* Race `work` against the configured `.timeout(ms)`. Rejects the awaiter after
|
|
3182
|
+
* `ms`; the losing DB promise is swallowed so a post-timeout driver error never
|
|
3183
|
+
* surfaces as an unhandled rejection. No timeout set → returns `work` as-is.
|
|
3184
|
+
*/
|
|
3185
|
+
#raceTimeout<R>(work: Promise<R>): Promise<R> {
|
|
3186
|
+
const ms = this.#timeoutMs;
|
|
3187
|
+
if (!ms || ms <= 0) return work;
|
|
3188
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3189
|
+
const guard = new Promise<never>((_, reject) => {
|
|
3190
|
+
timer = setTimeout(
|
|
3191
|
+
() => reject(new Error(`Query timed out after ${ms}ms`)),
|
|
3192
|
+
ms,
|
|
3193
|
+
);
|
|
3194
|
+
});
|
|
3195
|
+
work.catch(() => {});
|
|
3196
|
+
return Promise.race([work, guard]).finally(() => clearTimeout(timer));
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
/** The `/* … */` prefix for the compiled SQL, or empty when no comments. */
|
|
3200
|
+
/** Compile the registered CTEs to the wire shape — shared by SELECT and DML. */
|
|
3201
|
+
#compiledCtes(): Array<{
|
|
3202
|
+
name: string;
|
|
3203
|
+
sql: string;
|
|
3204
|
+
params: unknown[];
|
|
3205
|
+
recursive: boolean;
|
|
3206
|
+
materialized: boolean | null;
|
|
3207
|
+
columns: string[];
|
|
3208
|
+
}> {
|
|
3209
|
+
return this.#ctes.map((c) => {
|
|
3210
|
+
const { sql, params } = c.query.#compiledNative();
|
|
3211
|
+
return {
|
|
3212
|
+
name: c.name,
|
|
3213
|
+
sql,
|
|
3214
|
+
params,
|
|
3215
|
+
recursive: c.recursive ?? false,
|
|
3216
|
+
materialized: c.materialized ?? null,
|
|
3217
|
+
columns: c.columns ?? [],
|
|
3218
|
+
};
|
|
3219
|
+
});
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
#commentPrefix(): string {
|
|
3223
|
+
return this.#comments.length > 0
|
|
3224
|
+
? `${this.#comments.map((c) => `/* ${c} */`).join(" ")} `
|
|
3225
|
+
: "";
|
|
1395
3226
|
}
|
|
1396
3227
|
|
|
1397
3228
|
/**
|
|
@@ -1419,8 +3250,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1419
3250
|
}
|
|
1420
3251
|
|
|
1421
3252
|
async #doExec(): Promise<T[]> {
|
|
1422
|
-
const { sql, params } = this
|
|
1423
|
-
const rawRows = await this.#
|
|
3253
|
+
const { sql, params } = this.#compiledNative();
|
|
3254
|
+
const rawRows = await this.#raceTimeout(
|
|
3255
|
+
this.#db.query<Record<string, unknown>>(sql, params, this.#meta("exec")),
|
|
3256
|
+
);
|
|
1424
3257
|
// Peel withCount / withAggregate alias columns off the raw row into $extras
|
|
1425
3258
|
// BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
|
|
1426
3259
|
const extraKeys = this.#selectSubqueries.map((s) => s.alias);
|
|
@@ -1434,6 +3267,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1434
3267
|
}
|
|
1435
3268
|
const entity = this.#hydrateFn(row);
|
|
1436
3269
|
for (const [k, v] of Object.entries(picked)) entity.setExtra(k, v);
|
|
3270
|
+
// Thread query-level sideloaded context onto each hydrated instance.
|
|
3271
|
+
if (this.#sideloaded) entity.$sideloaded = { ...this.#sideloaded };
|
|
1437
3272
|
return entity;
|
|
1438
3273
|
});
|
|
1439
3274
|
|
|
@@ -1442,9 +3277,42 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1442
3277
|
await this.#resolvePreloads(entities);
|
|
1443
3278
|
}
|
|
1444
3279
|
|
|
3280
|
+
// rowTransformer callbacks run per instance AFTER preloads, before resolve
|
|
3281
|
+
// (Lucid), so a transformer can read the eager-loaded relations.
|
|
3282
|
+
if (this.#rowTransformers.length > 0) {
|
|
3283
|
+
for (const entity of entities) {
|
|
3284
|
+
for (const transform of this.#rowTransformers) transform.run(entity);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
1445
3288
|
return entities;
|
|
1446
3289
|
}
|
|
1447
3290
|
|
|
3291
|
+
/**
|
|
3292
|
+
* Execute and return PLAIN row objects (raw snake_case DB columns), skipping
|
|
3293
|
+
* model hydration, `@column({ consume })`, dirty-tracking and preloads —
|
|
3294
|
+
* AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
|
|
3295
|
+
* instances aren't needed.
|
|
3296
|
+
*/
|
|
3297
|
+
async pojo<R = Record<string, unknown>>(): Promise<R[]> {
|
|
3298
|
+
const { sql, params } = this.#compiledNative();
|
|
3299
|
+
return this.#db.query<R>(sql, params);
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
/**
|
|
3303
|
+
* Thread arbitrary context onto every instance this query hydrates, exposed as
|
|
3304
|
+
* `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
|
|
3305
|
+
* user, so hooks/computed can read it. REPLACES the current sideloaded data by
|
|
3306
|
+
* default (Lucid); pass `merge = true` to merge instead. The data also
|
|
3307
|
+
* propagates to preloaded relation queries. Chainable.
|
|
3308
|
+
*/
|
|
3309
|
+
sideload(values: Record<string, unknown>, merge = false): this {
|
|
3310
|
+
this.#sideloaded = merge
|
|
3311
|
+
? { ...this.#sideloaded, ...values }
|
|
3312
|
+
: { ...values };
|
|
3313
|
+
return this;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
1448
3316
|
/** Resolve preloaded relations via batched subqueries (no N+1). */
|
|
1449
3317
|
async #resolvePreloads(entities: T[]): Promise<void> {
|
|
1450
3318
|
if (!this.#entityClass) return;
|
|
@@ -1463,6 +3331,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1463
3331
|
relation.type,
|
|
1464
3332
|
ctx,
|
|
1465
3333
|
);
|
|
3334
|
+
// Propagate the parent query's sideloaded context onto the preloaded
|
|
3335
|
+
// relation instances (Adonis Lucid). The related row's own sideloaded
|
|
3336
|
+
// data (if any) wins over the inherited context.
|
|
3337
|
+
if (this.#sideloaded) {
|
|
3338
|
+
for (const related of allRelated) {
|
|
3339
|
+
related.$sideloaded = { ...this.#sideloaded, ...related.$sideloaded };
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
1466
3342
|
await this.#applyNestedPreloads(allRelated, ctx);
|
|
1467
3343
|
}
|
|
1468
3344
|
}
|
|
@@ -1473,33 +3349,98 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1473
3349
|
relationName: string,
|
|
1474
3350
|
): PreloadContext | null {
|
|
1475
3351
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1476
|
-
|
|
1477
|
-
|
|
3352
|
+
// Boot the related model's metadata on demand (Lucid parity): a preload
|
|
3353
|
+
// must not silently no-op just because the related class hasn't been
|
|
3354
|
+
// touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
|
|
3355
|
+
// static table / naming strategy when the decorator hasn't run.
|
|
3356
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1478
3357
|
|
|
1479
3358
|
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1480
3359
|
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
1481
3360
|
// on a freshly constructed instance, so `key in entity` is always false and
|
|
1482
3361
|
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1483
|
-
|
|
3362
|
+
// For m2m the pivot's otherKey references `relatedKey` (default the related
|
|
3363
|
+
// PK); the load must filter/index rows by THAT column, not always the PK.
|
|
3364
|
+
const relatedPkName =
|
|
3365
|
+
(relation.type === "manyToMany"
|
|
3366
|
+
? relation.pivot?.relatedKey
|
|
3367
|
+
: undefined) ??
|
|
3368
|
+
getPrimaryKey(relatedClass) ??
|
|
3369
|
+
"id";
|
|
1484
3370
|
const validColumns = new Set<string>();
|
|
3371
|
+
// Reverse map (db column → property) so an explicit `@Column({ columnName })`
|
|
3372
|
+
// on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
|
|
3373
|
+
const byDbName = new Map<string, string>();
|
|
3374
|
+
// Capture the related model's `@Column({ consume })` adapters + its date
|
|
3375
|
+
// columns so preloaded rows hydrate identically to a direct query — dates
|
|
3376
|
+
// become Chronos DateTime, decimal/etc adapters run. Without this, a
|
|
3377
|
+
// preloaded relation left column values raw (Lucid parity bug + a runtime
|
|
3378
|
+
// footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
|
|
3379
|
+
const consumes = new Map<
|
|
3380
|
+
string,
|
|
3381
|
+
(v: unknown, attribute?: string, model?: unknown) => unknown
|
|
3382
|
+
>();
|
|
3383
|
+
let relatedPkDb = camelToSnake(relatedPkName);
|
|
1485
3384
|
for (const col of getColumnMetadata(relatedClass)) {
|
|
3385
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
1486
3386
|
validColumns.add(col.propertyKey);
|
|
1487
|
-
validColumns.add(
|
|
3387
|
+
validColumns.add(db);
|
|
3388
|
+
byDbName.set(db, col.propertyKey);
|
|
3389
|
+
if (col.consume) consumes.set(col.propertyKey, col.consume);
|
|
3390
|
+
// The related PK may be multi-word (postId→post_id) or columnName-mapped;
|
|
3391
|
+
// its DB column name is what the WHERE + row indexing must use.
|
|
3392
|
+
if (col.propertyKey === relatedPkName) relatedPkDb = db;
|
|
1488
3393
|
}
|
|
1489
3394
|
validColumns.add(relatedPkName);
|
|
1490
3395
|
validColumns.add(camelToSnake(relatedPkName));
|
|
3396
|
+
const dateCols = getDateColumnConfig(relatedClass);
|
|
3397
|
+
const consumeValue = (
|
|
3398
|
+
prop: string,
|
|
3399
|
+
value: unknown,
|
|
3400
|
+
model?: unknown,
|
|
3401
|
+
): unknown => {
|
|
3402
|
+
const c = consumes.get(prop);
|
|
3403
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
3404
|
+
if (c) return c(value, prop, model);
|
|
3405
|
+
if (dateCols[prop] && value != null)
|
|
3406
|
+
return dateTimeAtlasAdapter.consume(value);
|
|
3407
|
+
return value;
|
|
3408
|
+
};
|
|
1491
3409
|
|
|
3410
|
+
// A repository for the related model so preloaded instances are hydrated with
|
|
3411
|
+
// the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
|
|
3412
|
+
// not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
|
|
3413
|
+
// refresh()/fresh()/load()/related(). Without this a preloaded relation
|
|
3414
|
+
// looked $isNew/$isLocal/$dirty and a later save() over-updated it.
|
|
3415
|
+
const relatedRepo = new BaseRepository(relatedClass, this.#db, {
|
|
3416
|
+
dialect: this.#dialect,
|
|
3417
|
+
});
|
|
3418
|
+
// Propagate the domain-event bus so save()/create() from a preloaded relation
|
|
3419
|
+
// still dispatch events (a fresh repo has none by default).
|
|
3420
|
+
relatedRepo.onDomainEvents = this.#onDomainEvents;
|
|
1492
3421
|
const hydrate = (row: Record<string, unknown>): BaseEntity => {
|
|
1493
3422
|
const entity = new relatedClass();
|
|
1494
3423
|
for (const [key, value] of Object.entries(row)) {
|
|
1495
3424
|
const camelKey = snakeToCamel(key);
|
|
1496
|
-
const targetKey =
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
?
|
|
1500
|
-
:
|
|
1501
|
-
|
|
3425
|
+
const targetKey =
|
|
3426
|
+
byDbName.get(key) ??
|
|
3427
|
+
(validColumns.has(camelKey)
|
|
3428
|
+
? camelKey
|
|
3429
|
+
: validColumns.has(key)
|
|
3430
|
+
? key
|
|
3431
|
+
: null);
|
|
3432
|
+
if (targetKey !== null)
|
|
3433
|
+
entity.setProp(targetKey, consumeValue(targetKey, value, entity));
|
|
1502
3434
|
}
|
|
3435
|
+
// Freeze the clean snapshot + mark persisted/from-DB, and back-reference
|
|
3436
|
+
// the related repo (mirrors BaseRepository.#hydrate).
|
|
3437
|
+
entity.markAsPersisted();
|
|
3438
|
+
entity.markAsFromDatabase();
|
|
3439
|
+
Object.defineProperty(entity, REPO_REF, {
|
|
3440
|
+
value: relatedRepo,
|
|
3441
|
+
enumerable: false,
|
|
3442
|
+
configurable: true,
|
|
3443
|
+
});
|
|
1503
3444
|
return entity;
|
|
1504
3445
|
};
|
|
1505
3446
|
|
|
@@ -1508,7 +3449,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1508
3449
|
relationName,
|
|
1509
3450
|
relatedClass,
|
|
1510
3451
|
relatedTable: relatedMeta.tableName,
|
|
1511
|
-
|
|
3452
|
+
// DB column name (not property) — used as the WHERE column in the related
|
|
3453
|
+
// query AND to index the returned DB rows by their PK value.
|
|
3454
|
+
relatedPk: relatedPkDb,
|
|
1512
3455
|
hydrate,
|
|
1513
3456
|
runInQuery: (table, column, values) =>
|
|
1514
3457
|
this.#runInQuery(table, column, values),
|
|
@@ -1570,11 +3513,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1570
3513
|
);
|
|
1571
3514
|
}
|
|
1572
3515
|
const throughClass = relation.through() as new () => BaseEntity;
|
|
1573
|
-
const throughMeta =
|
|
1574
|
-
if (!throughMeta)
|
|
1575
|
-
throw new Error(
|
|
1576
|
-
`Entity metadata missing on through class ${throughClass.name}`,
|
|
1577
|
-
);
|
|
3516
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1578
3517
|
const throughTable = throughMeta.tableName;
|
|
1579
3518
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1580
3519
|
const parentLocal =
|
|
@@ -1583,7 +3522,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1583
3522
|
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1584
3523
|
const secondKey =
|
|
1585
3524
|
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1586
|
-
|
|
3525
|
+
// secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
|
|
3526
|
+
// DB column — resolve the through model's key (default: its PK), honouring a
|
|
3527
|
+
// multi-word / columnName PK. (parentLocal stays a property: it's read off
|
|
3528
|
+
// the parent ENTITY, not a row.)
|
|
3529
|
+
const secondLocal = buildColumnResolver(throughClass)(
|
|
3530
|
+
relation.secondLocalKey ?? throughPk,
|
|
3531
|
+
);
|
|
1587
3532
|
|
|
1588
3533
|
const parentIds = entities
|
|
1589
3534
|
.map((e) => e[parentLocal])
|
|
@@ -1752,7 +3697,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1752
3697
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1753
3698
|
const otherKey =
|
|
1754
3699
|
pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1755
|
-
|
|
3700
|
+
// The pivot FK stores `parent[localKey]` (default PK) — attach() writes it,
|
|
3701
|
+
// so preload MUST read back with the SAME key, else a custom-localKey m2m
|
|
3702
|
+
// writes `user_code = code` but reads `user_code IN (id)` and never matches.
|
|
3703
|
+
const pk =
|
|
3704
|
+
ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
1756
3705
|
|
|
1757
3706
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
1758
3707
|
if (ids.length === 0) return [];
|
|
@@ -1766,18 +3715,49 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1766
3715
|
operator: string;
|
|
1767
3716
|
value: unknown;
|
|
1768
3717
|
}> = [];
|
|
3718
|
+
// Query-time pivotColumns([...]) requested in the preload callback (replayed
|
|
3719
|
+
// on the scratch builder below) OR directly on this query (related().query()).
|
|
3720
|
+
const extraPivotCols: string[] = [...this.#extraPivotColumns];
|
|
1769
3721
|
if (ctx.nestedCallback) {
|
|
1770
3722
|
const scratch = new ModelQuery<BaseEntity>(
|
|
1771
3723
|
ctx.relatedTable,
|
|
1772
3724
|
this.#db,
|
|
1773
3725
|
(r) => r as BaseEntity,
|
|
1774
3726
|
ctx.relatedClass,
|
|
1775
|
-
(
|
|
3727
|
+
buildColumnResolver(ctx.relatedClass),
|
|
1776
3728
|
false,
|
|
1777
3729
|
this.#dialect,
|
|
3730
|
+
buildValuePreparer(ctx.relatedClass),
|
|
1778
3731
|
);
|
|
1779
3732
|
ctx.nestedCallback(scratch);
|
|
1780
|
-
|
|
3733
|
+
extraPivotCols.push(...scratch.pivotColumnRequests);
|
|
3734
|
+
// Apply the pivot column adapters' `prepare` to wherePivot values, so a
|
|
3735
|
+
// filter like wherePivot('amount', new Money(1)) matches what attach()/
|
|
3736
|
+
// sync() stored (they prepare the same extras on write).
|
|
3737
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
3738
|
+
for (const c of scratch.pivotConstraints) {
|
|
3739
|
+
const prep = pivotAdapters[c.column]?.prepare;
|
|
3740
|
+
// Same guards as the attach()/sync() write path: wrap a throwing
|
|
3741
|
+
// adapter with a column-annotated error and reject async adapters,
|
|
3742
|
+
// so filter and write agree on the adapter contract.
|
|
3743
|
+
const apply = (v: unknown): unknown => {
|
|
3744
|
+
if (!prep) return v;
|
|
3745
|
+
let out: unknown;
|
|
3746
|
+
try {
|
|
3747
|
+
// Adonis Lucid signature: (value, attribute, model). wherePivot is
|
|
3748
|
+
// a query filter — attribute known, no model instance.
|
|
3749
|
+
out = prep(v, c.column, undefined);
|
|
3750
|
+
} catch (err) {
|
|
3751
|
+
throw wrapAdapterError("prepare", c.column, err);
|
|
3752
|
+
}
|
|
3753
|
+
assertNotPromise("prepare", c.column, out);
|
|
3754
|
+
return out;
|
|
3755
|
+
};
|
|
3756
|
+
const value = Array.isArray(c.value)
|
|
3757
|
+
? c.value.map(apply)
|
|
3758
|
+
: apply(c.value);
|
|
3759
|
+
pivotWheres.push({ ...c, value });
|
|
3760
|
+
}
|
|
1781
3761
|
}
|
|
1782
3762
|
|
|
1783
3763
|
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
@@ -1797,7 +3777,11 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1797
3777
|
|
|
1798
3778
|
// Step 2 — load all related entities in one query
|
|
1799
3779
|
const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
|
|
1800
|
-
|
|
3780
|
+
// Declared pivot columns (decorator) + any requested at query time
|
|
3781
|
+
// (`pivotColumns([...])`, from the callback or this query), de-duplicated.
|
|
3782
|
+
const pivotCols = [
|
|
3783
|
+
...new Set([...(pivot.pivotColumns ?? []), ...extraPivotCols]),
|
|
3784
|
+
];
|
|
1801
3785
|
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
1802
3786
|
// When pivot extras are projected, each (parent, related) edge gets its OWN
|
|
1803
3787
|
// hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
|
|
@@ -1830,7 +3814,8 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1830
3814
|
const adapter = pivotAdapters[col];
|
|
1831
3815
|
related.setExtra(
|
|
1832
3816
|
`pivot_${col}`,
|
|
1833
|
-
|
|
3817
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
3818
|
+
adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal,
|
|
1834
3819
|
);
|
|
1835
3820
|
}
|
|
1836
3821
|
allRelated.push(related);
|
|
@@ -1860,6 +3845,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1860
3845
|
this.#db,
|
|
1861
3846
|
(r) => ctx.hydrate(r),
|
|
1862
3847
|
ctx.relatedClass,
|
|
3848
|
+
buildColumnResolver(ctx.relatedClass),
|
|
3849
|
+
hasSoftDeletes(ctx.relatedClass),
|
|
3850
|
+
this.#dialect,
|
|
3851
|
+
buildValuePreparer(ctx.relatedClass),
|
|
1863
3852
|
);
|
|
1864
3853
|
ctx.nestedCallback(sub);
|
|
1865
3854
|
if (sub.#preloads.size > 0) {
|
|
@@ -1876,17 +3865,28 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1876
3865
|
column: string;
|
|
1877
3866
|
operator: string;
|
|
1878
3867
|
value: unknown;
|
|
3868
|
+
type?: "and" | "or";
|
|
1879
3869
|
}> = [],
|
|
1880
3870
|
): Promise<Record<string, unknown>[]> {
|
|
1881
3871
|
const wheres: Array<Record<string, unknown>> = [
|
|
1882
3872
|
{ column, operator: "IN", value: values, type: "and" },
|
|
1883
3873
|
];
|
|
1884
|
-
|
|
3874
|
+
// The caller's filters go in a parenthesised group, never flat beside the
|
|
3875
|
+
// `IN`. Flat, an `orWherePivot` would read as
|
|
3876
|
+
// `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
|
|
3877
|
+
// other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
|
|
3878
|
+
// With every filter ANDed the two forms are equivalent, so this changes
|
|
3879
|
+
// no existing query.
|
|
3880
|
+
if (extraWheres.length > 0) {
|
|
1885
3881
|
wheres.push({
|
|
1886
|
-
|
|
1887
|
-
operator: w.operator,
|
|
1888
|
-
value: w.value,
|
|
3882
|
+
kind: "group",
|
|
1889
3883
|
type: "and",
|
|
3884
|
+
conditions: extraWheres.map((w) => ({
|
|
3885
|
+
column: w.column,
|
|
3886
|
+
operator: w.operator,
|
|
3887
|
+
value: w.value,
|
|
3888
|
+
type: w.type ?? "and",
|
|
3889
|
+
})),
|
|
1890
3890
|
});
|
|
1891
3891
|
}
|
|
1892
3892
|
const spec = {
|
|
@@ -1937,7 +3937,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1937
3937
|
this.#db,
|
|
1938
3938
|
(row) => row as BaseEntity,
|
|
1939
3939
|
relatedClass,
|
|
1940
|
-
|
|
3940
|
+
// Resolve columns + prepare values against the RELATED model so a preload
|
|
3941
|
+
// constraint (onQuery / callback) targeting a columnName-mapped or date
|
|
3942
|
+
// column compiles/binds like a direct query on that model.
|
|
3943
|
+
buildColumnResolver(relatedClass),
|
|
1941
3944
|
// Propagate the RELATED entity's soft-delete flag — hardcoding
|
|
1942
3945
|
// false here meant `preload('posts')` returned soft-deleted
|
|
1943
3946
|
// posts even when Post is @SoftDeletes (a data leak). The
|
|
@@ -1947,14 +3950,63 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1947
3950
|
// preload callback.)
|
|
1948
3951
|
hasSoftDeletes(relatedClass),
|
|
1949
3952
|
this.#dialect,
|
|
3953
|
+
buildValuePreparer(relatedClass),
|
|
1950
3954
|
);
|
|
1951
3955
|
sub.whereIn(column, values);
|
|
1952
3956
|
if (relation.onQuery) relation.onQuery(sub as unknown);
|
|
1953
3957
|
if (userCallback) userCallback(sub);
|
|
1954
|
-
|
|
3958
|
+
// Top-N per parent (Lucid groupLimit) → a ROW_NUMBER() window over the fk.
|
|
3959
|
+
if (sub.#groupLimit != null) {
|
|
3960
|
+
return this.#runGroupLimited(sub, column, sub.#groupLimit);
|
|
3961
|
+
}
|
|
3962
|
+
const { sql, params } = sub.#compiledNative();
|
|
1955
3963
|
return this.#db.query<Record<string, unknown>>(sql, params);
|
|
1956
3964
|
}
|
|
1957
3965
|
|
|
3966
|
+
/**
|
|
3967
|
+
* Run a has-many preload query capped to `n` rows PER PARENT (Lucid
|
|
3968
|
+
* `groupLimit`). Wraps the inner query with a `ROW_NUMBER() OVER (PARTITION BY
|
|
3969
|
+
* <fk> ORDER BY <groupOrderBy | pk>)` window, then keeps `rn <= n`. Supported on
|
|
3970
|
+
* Postgres, MySQL 8+, and SQLite 3.25+ (all have window functions).
|
|
3971
|
+
*/
|
|
3972
|
+
async #runGroupLimited(
|
|
3973
|
+
sub: ModelQuery<BaseEntity>,
|
|
3974
|
+
fkColumn: string,
|
|
3975
|
+
n: number,
|
|
3976
|
+
): Promise<Record<string, unknown>[]> {
|
|
3977
|
+
const quoteSeg = (name: string): string => {
|
|
3978
|
+
const qc = this.#dialect === "mysql" ? "`" : '"';
|
|
3979
|
+
return name
|
|
3980
|
+
.split(".")
|
|
3981
|
+
.map((s) => {
|
|
3982
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
|
|
3983
|
+
throw new Error(`groupLimit: unsafe identifier '${name}'`);
|
|
3984
|
+
}
|
|
3985
|
+
return `${qc}${s}${qc}`;
|
|
3986
|
+
})
|
|
3987
|
+
.join(".");
|
|
3988
|
+
};
|
|
3989
|
+
const orderCol = sub.#groupOrderBy
|
|
3990
|
+
? sub.#resolveColumn(sub.#groupOrderBy.column)
|
|
3991
|
+
: (getPrimaryKey(sub.#entityClass) ?? "id");
|
|
3992
|
+
const dir = sub.#groupOrderBy?.direction === "desc" ? "DESC" : "ASC";
|
|
3993
|
+
sub.#selectRaw.push({
|
|
3994
|
+
sql: `ROW_NUMBER() OVER (PARTITION BY ${quoteSeg(fkColumn)} ORDER BY ${quoteSeg(orderCol)} ${dir}) AS __atlas_rn`,
|
|
3995
|
+
params: [],
|
|
3996
|
+
});
|
|
3997
|
+
const inner = sub.#compiledNative();
|
|
3998
|
+
const ph =
|
|
3999
|
+
this.#dialect === "postgres" ? `$${inner.params.length + 1}` : "?";
|
|
4000
|
+
const outerSql = `SELECT * FROM (${inner.sql}) AS __atlas_grp WHERE __atlas_rn <= ${ph}`;
|
|
4001
|
+
const rows = await this.#db.query<Record<string, unknown>>(outerSql, [
|
|
4002
|
+
...inner.params,
|
|
4003
|
+
n,
|
|
4004
|
+
]);
|
|
4005
|
+
// Strip the window helper column so it doesn't leak into hydration.
|
|
4006
|
+
for (const row of rows) delete row.__atlas_rn;
|
|
4007
|
+
return rows;
|
|
4008
|
+
}
|
|
4009
|
+
|
|
1958
4010
|
/**
|
|
1959
4011
|
* Build a correlated subquery over a relation. Returns `SubqueryProjection`
|
|
1960
4012
|
* used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
|
|
@@ -1995,30 +4047,46 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1995
4047
|
);
|
|
1996
4048
|
}
|
|
1997
4049
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1998
|
-
const relatedMeta =
|
|
1999
|
-
if (!relatedMeta) {
|
|
2000
|
-
throw new Error(
|
|
2001
|
-
`Entity metadata missing on related class ${relatedClass.name}`,
|
|
2002
|
-
);
|
|
2003
|
-
}
|
|
4050
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
2004
4051
|
const relatedTable = relatedMeta.tableName;
|
|
2005
4052
|
const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
2006
4053
|
const parentTable = this.#tableName;
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
4054
|
+
// Strict single-segment identifier quote. This builds a RAW correlated
|
|
4055
|
+
// subquery fragment (no bind params for identifiers), so every segment must
|
|
4056
|
+
// be validated — a table/key from relation metadata carrying a quote/backtick
|
|
4057
|
+
// would otherwise emit invalid or injectable SQL. Same policy as
|
|
4058
|
+
// BaseRepository's lazy m2m path.
|
|
4059
|
+
const q = (name: string): string => {
|
|
4060
|
+
if (!/^[A-Za-z0-9_]+$/.test(name)) {
|
|
4061
|
+
throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
|
|
4062
|
+
}
|
|
4063
|
+
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
4064
|
+
};
|
|
4065
|
+
// Table identifiers may be schema-qualified (`schema.table`) — quote each
|
|
4066
|
+
// dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
|
|
4067
|
+
// `public.users_roles` gets wrapped as ONE identifier and silently targets a
|
|
4068
|
+
// table literally named with a dot. Each segment still passes the strict
|
|
4069
|
+
// guard above. Columns stay single-segment via `q`.
|
|
4070
|
+
const qTable = (name: string): string => name.split(".").map(q).join(".");
|
|
2011
4071
|
|
|
2012
4072
|
const sub = new ModelQuery<BaseEntity>(
|
|
2013
4073
|
relatedTable,
|
|
2014
4074
|
this.#db,
|
|
2015
4075
|
(row) => row as BaseEntity,
|
|
2016
4076
|
relatedClass,
|
|
2017
|
-
|
|
4077
|
+
// whereHas/withCount constraints run against the RELATED model — resolve
|
|
4078
|
+
// its columns (columnName/multi-word) and prepare its values like a direct query.
|
|
4079
|
+
buildColumnResolver(relatedClass),
|
|
2018
4080
|
false,
|
|
2019
4081
|
this.#dialect,
|
|
4082
|
+
buildValuePreparer(relatedClass),
|
|
2020
4083
|
);
|
|
2021
4084
|
|
|
4085
|
+
// `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
|
|
4086
|
+
// PK); resolve each to its DB column via the owning model so a multi-word or
|
|
4087
|
+
// `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
|
|
4088
|
+
// `firstKey`/`secondKey` are DB column names already — left as-is.
|
|
4089
|
+
const resolveParent = buildColumnResolver(this.#entityClass);
|
|
2022
4090
|
switch (relation.type) {
|
|
2023
4091
|
case "hasOne":
|
|
2024
4092
|
case "hasMany": {
|
|
@@ -2026,19 +4094,20 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2026
4094
|
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
2027
4095
|
const fk =
|
|
2028
4096
|
relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2029
|
-
const localKey = relation.localKey ?? parentPk;
|
|
4097
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2030
4098
|
sub.#pushWhereRaw(
|
|
2031
|
-
`${
|
|
4099
|
+
`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`,
|
|
2032
4100
|
);
|
|
2033
4101
|
break;
|
|
2034
4102
|
}
|
|
2035
4103
|
case "belongsTo": {
|
|
2036
4104
|
const fk =
|
|
2037
4105
|
relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
2038
|
-
const ownerKey =
|
|
2039
|
-
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id"
|
|
4106
|
+
const ownerKey = buildColumnResolver(relatedClass)(
|
|
4107
|
+
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id",
|
|
4108
|
+
);
|
|
2040
4109
|
sub.#pushWhereRaw(
|
|
2041
|
-
`${
|
|
4110
|
+
`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`,
|
|
2042
4111
|
);
|
|
2043
4112
|
break;
|
|
2044
4113
|
}
|
|
@@ -2055,12 +4124,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2055
4124
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2056
4125
|
const otherKey =
|
|
2057
4126
|
pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
2058
|
-
const
|
|
2059
|
-
const
|
|
4127
|
+
const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
|
|
4128
|
+
const relatedPk =
|
|
4129
|
+
getColumnMetadata(relatedClass).find(
|
|
4130
|
+
(c) => c.propertyKey === relatedPkProp,
|
|
4131
|
+
)?.columnName ?? camelToSnake(relatedPkProp);
|
|
4132
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2060
4133
|
sub.#pushWhereRaw(
|
|
2061
|
-
`${
|
|
2062
|
-
`(SELECT ${q(otherKey)} FROM ${
|
|
2063
|
-
`WHERE ${
|
|
4134
|
+
`${qTable(relatedTable)}.${q(relatedPk)} IN ` +
|
|
4135
|
+
`(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
|
|
4136
|
+
`WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`,
|
|
2064
4137
|
);
|
|
2065
4138
|
break;
|
|
2066
4139
|
}
|
|
@@ -2075,24 +4148,21 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2075
4148
|
);
|
|
2076
4149
|
}
|
|
2077
4150
|
const throughClass = relation.through() as new () => BaseEntity;
|
|
2078
|
-
const throughMeta =
|
|
2079
|
-
if (!throughMeta) {
|
|
2080
|
-
throw new Error(
|
|
2081
|
-
`Entity metadata missing on through class ${throughClass.name}`,
|
|
2082
|
-
);
|
|
2083
|
-
}
|
|
4151
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
2084
4152
|
const throughTable = throughMeta.tableName;
|
|
2085
4153
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
2086
|
-
const parentLocal = relation.localKey ?? parentPk;
|
|
4154
|
+
const parentLocal = resolveParent(relation.localKey ?? parentPk);
|
|
2087
4155
|
const firstKey =
|
|
2088
4156
|
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2089
4157
|
const secondKey =
|
|
2090
4158
|
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
2091
|
-
const secondLocal =
|
|
4159
|
+
const secondLocal = buildColumnResolver(throughClass)(
|
|
4160
|
+
relation.secondLocalKey ?? throughPk,
|
|
4161
|
+
);
|
|
2092
4162
|
sub.#pushWhereRaw(
|
|
2093
|
-
`${
|
|
2094
|
-
`(SELECT ${q(secondLocal)} FROM ${
|
|
2095
|
-
`WHERE ${
|
|
4163
|
+
`${qTable(relatedTable)}.${q(secondKey)} IN ` +
|
|
4164
|
+
`(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
|
|
4165
|
+
`WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`,
|
|
2096
4166
|
);
|
|
2097
4167
|
break;
|
|
2098
4168
|
}
|
|
@@ -2114,40 +4184,119 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2114
4184
|
*/
|
|
2115
4185
|
// === Story 29.4 — joins ===========================================================================
|
|
2116
4186
|
|
|
2117
|
-
/** `INNER JOIN
|
|
4187
|
+
/** `INNER JOIN` — alias of {@link innerJoin} (Lucid/Knex `join`). */
|
|
4188
|
+
join(table: string, left: string, right: string): this;
|
|
4189
|
+
join(table: string, left: string, operator: string, right: string): this;
|
|
4190
|
+
join(table: string, build: (j: JoinBuilder) => void): this;
|
|
4191
|
+
join(
|
|
4192
|
+
table: string,
|
|
4193
|
+
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4194
|
+
operatorOrRight?: string,
|
|
4195
|
+
right?: string,
|
|
4196
|
+
): this {
|
|
4197
|
+
return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
/** `INNER JOIN <table> ON <left> [op] <right>` or a callback `ON` builder. */
|
|
2118
4201
|
innerJoin(table: string, left: string, right: string): this;
|
|
4202
|
+
innerJoin(table: string, left: string, operator: string, right: string): this;
|
|
2119
4203
|
innerJoin(table: string, build: (j: JoinBuilder) => void): this;
|
|
2120
4204
|
innerJoin(
|
|
2121
4205
|
table: string,
|
|
2122
4206
|
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4207
|
+
operatorOrRight?: string,
|
|
2123
4208
|
right?: string,
|
|
2124
4209
|
): this {
|
|
2125
|
-
return this.#pushJoin("INNER", table, leftOrBuild, right);
|
|
4210
|
+
return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
|
|
2126
4211
|
}
|
|
2127
4212
|
|
|
2128
4213
|
leftJoin(table: string, left: string, right: string): this;
|
|
4214
|
+
leftJoin(table: string, left: string, operator: string, right: string): this;
|
|
2129
4215
|
leftJoin(table: string, build: (j: JoinBuilder) => void): this;
|
|
2130
4216
|
leftJoin(
|
|
2131
4217
|
table: string,
|
|
2132
4218
|
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4219
|
+
operatorOrRight?: string,
|
|
4220
|
+
right?: string,
|
|
4221
|
+
): this {
|
|
4222
|
+
return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
/** `LEFT OUTER JOIN` — alias of {@link leftJoin} (Lucid/Knex `leftOuterJoin`). */
|
|
4226
|
+
leftOuterJoin(table: string, left: string, right: string): this;
|
|
4227
|
+
leftOuterJoin(
|
|
4228
|
+
table: string,
|
|
4229
|
+
left: string,
|
|
4230
|
+
operator: string,
|
|
4231
|
+
right: string,
|
|
4232
|
+
): this;
|
|
4233
|
+
leftOuterJoin(table: string, build: (j: JoinBuilder) => void): this;
|
|
4234
|
+
leftOuterJoin(
|
|
4235
|
+
table: string,
|
|
4236
|
+
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4237
|
+
operatorOrRight?: string,
|
|
2133
4238
|
right?: string,
|
|
2134
4239
|
): this {
|
|
2135
|
-
return this.#pushJoin("LEFT", table, leftOrBuild, right);
|
|
4240
|
+
return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
|
|
2136
4241
|
}
|
|
2137
4242
|
|
|
2138
4243
|
rightJoin(table: string, left: string, right: string): this;
|
|
4244
|
+
rightJoin(table: string, left: string, operator: string, right: string): this;
|
|
2139
4245
|
rightJoin(table: string, build: (j: JoinBuilder) => void): this;
|
|
2140
4246
|
rightJoin(
|
|
2141
4247
|
table: string,
|
|
2142
4248
|
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4249
|
+
operatorOrRight?: string,
|
|
4250
|
+
right?: string,
|
|
4251
|
+
): this {
|
|
4252
|
+
return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
/** `RIGHT OUTER JOIN` — alias of {@link rightJoin} (Lucid/Knex `rightOuterJoin`). */
|
|
4256
|
+
rightOuterJoin(table: string, left: string, right: string): this;
|
|
4257
|
+
rightOuterJoin(
|
|
4258
|
+
table: string,
|
|
4259
|
+
left: string,
|
|
4260
|
+
operator: string,
|
|
4261
|
+
right: string,
|
|
4262
|
+
): this;
|
|
4263
|
+
rightOuterJoin(table: string, build: (j: JoinBuilder) => void): this;
|
|
4264
|
+
rightOuterJoin(
|
|
4265
|
+
table: string,
|
|
4266
|
+
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4267
|
+
operatorOrRight?: string,
|
|
4268
|
+
right?: string,
|
|
4269
|
+
): this {
|
|
4270
|
+
return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
/** `FULL OUTER JOIN` (Lucid/Knex `fullOuterJoin`; Postgres — MySQL/SQLite lack it). */
|
|
4274
|
+
fullOuterJoin(table: string, left: string, right: string): this;
|
|
4275
|
+
fullOuterJoin(
|
|
4276
|
+
table: string,
|
|
4277
|
+
left: string,
|
|
4278
|
+
operator: string,
|
|
4279
|
+
right: string,
|
|
4280
|
+
): this;
|
|
4281
|
+
fullOuterJoin(table: string, build: (j: JoinBuilder) => void): this;
|
|
4282
|
+
fullOuterJoin(
|
|
4283
|
+
table: string,
|
|
4284
|
+
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
4285
|
+
operatorOrRight?: string,
|
|
2143
4286
|
right?: string,
|
|
2144
4287
|
): this {
|
|
2145
|
-
return this.#pushJoin(
|
|
4288
|
+
return this.#pushJoin(
|
|
4289
|
+
"FULL OUTER",
|
|
4290
|
+
table,
|
|
4291
|
+
leftOrBuild,
|
|
4292
|
+
operatorOrRight,
|
|
4293
|
+
right,
|
|
4294
|
+
);
|
|
2146
4295
|
}
|
|
2147
4296
|
|
|
2148
4297
|
crossJoin(table: string): this {
|
|
2149
|
-
const tq = this.#
|
|
2150
|
-
this.#joins.push(`CROSS JOIN ${tq}
|
|
4298
|
+
const tq = this.#quoteCol(table);
|
|
4299
|
+
this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
|
|
2151
4300
|
return this;
|
|
2152
4301
|
}
|
|
2153
4302
|
|
|
@@ -2167,14 +4316,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2167
4316
|
*
|
|
2168
4317
|
* @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
|
|
2169
4318
|
*/
|
|
2170
|
-
joinRaw(fragment: string): this {
|
|
4319
|
+
joinRaw(fragment: string, bindings: readonly unknown[] = []): this {
|
|
2171
4320
|
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
2172
4321
|
throw new Error(
|
|
2173
4322
|
"joinRaw() is disabled in Atlas strict mode. " +
|
|
2174
4323
|
"Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.",
|
|
2175
4324
|
);
|
|
2176
4325
|
}
|
|
2177
|
-
this.#joins.push(fragment);
|
|
4326
|
+
this.#joins.push({ sql: fragment, params: [...bindings] });
|
|
2178
4327
|
return this;
|
|
2179
4328
|
}
|
|
2180
4329
|
|
|
@@ -2195,25 +4344,72 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2195
4344
|
|
|
2196
4345
|
// === Story 29.5 — aggregates / exists / pluck =====================================================
|
|
2197
4346
|
|
|
2198
|
-
|
|
4347
|
+
/**
|
|
4348
|
+
* SELECT DISTINCT (Lucid/Knex `distinct`). With columns, those replace the
|
|
4349
|
+
* projection too — `distinct('a', 'b')` ≈ `SELECT DISTINCT a, b`.
|
|
4350
|
+
*/
|
|
4351
|
+
distinct(...columns: string[]): this {
|
|
2199
4352
|
this.#distinct = true;
|
|
4353
|
+
if (columns.length > 0) {
|
|
4354
|
+
this.#select = columns.map((c) => this.#resolveSelect(c));
|
|
4355
|
+
}
|
|
4356
|
+
return this;
|
|
4357
|
+
}
|
|
4358
|
+
|
|
4359
|
+
/**
|
|
4360
|
+
* `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
|
|
4361
|
+
* `columns` (Lucid/Knex `distinctOn`). Takes precedence over
|
|
4362
|
+
* {@link distinct}.
|
|
4363
|
+
*
|
|
4364
|
+
* Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
|
|
4365
|
+
* would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
|
|
4366
|
+
* return a *different* result set rather than fail — a silent wrong answer
|
|
4367
|
+
* is worse than an error.
|
|
4368
|
+
*
|
|
4369
|
+
* Postgres also requires the leading `ORDER BY` terms to match `columns`;
|
|
4370
|
+
* that is left to the database to enforce.
|
|
4371
|
+
*/
|
|
4372
|
+
distinctOn(...columns: string[]): this {
|
|
4373
|
+
for (const c of columns) this.#distinctOn.push(this.#resolveColumn(c));
|
|
2200
4374
|
return this;
|
|
2201
4375
|
}
|
|
2202
4376
|
|
|
2203
4377
|
/** `SELECT COUNT(DISTINCT col)`. */
|
|
2204
4378
|
async countDistinct(column: string): Promise<number> {
|
|
2205
4379
|
return Number(
|
|
2206
|
-
(await this.#runScalar(
|
|
4380
|
+
(await this.#runScalar(
|
|
4381
|
+
`COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
4382
|
+
)) ?? 0,
|
|
2207
4383
|
);
|
|
2208
4384
|
}
|
|
2209
4385
|
|
|
4386
|
+
/** `SUM(DISTINCT col)` (Lucid parity). */
|
|
4387
|
+
async sumDistinct(column: string): Promise<number | null> {
|
|
4388
|
+
const v = await this.#runScalar(
|
|
4389
|
+
`SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
4390
|
+
);
|
|
4391
|
+
return v === null || v === undefined ? null : Number(v);
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
/** `AVG(DISTINCT col)` (Lucid parity). */
|
|
4395
|
+
async avgDistinct(column: string): Promise<number | null> {
|
|
4396
|
+
const v = await this.#runScalar(
|
|
4397
|
+
`AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
4398
|
+
);
|
|
4399
|
+
return v === null || v === undefined ? null : Number(v);
|
|
4400
|
+
}
|
|
4401
|
+
|
|
2210
4402
|
/** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
|
|
2211
4403
|
async exists(): Promise<boolean> {
|
|
2212
4404
|
const clone = this.clone();
|
|
2213
4405
|
clone.#select = ["1"];
|
|
2214
4406
|
clone.#limit = 1;
|
|
2215
|
-
const { sql, params } = clone
|
|
2216
|
-
const rows = await this.#db.query<Record<string, unknown>>(
|
|
4407
|
+
const { sql, params } = clone.#compiledNative();
|
|
4408
|
+
const rows = await this.#db.query<Record<string, unknown>>(
|
|
4409
|
+
sql,
|
|
4410
|
+
params,
|
|
4411
|
+
this.#meta("exists"),
|
|
4412
|
+
);
|
|
2217
4413
|
return rows.length > 0;
|
|
2218
4414
|
}
|
|
2219
4415
|
|
|
@@ -2226,7 +4422,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2226
4422
|
const col = this.#resolveColumn(column);
|
|
2227
4423
|
const clone = this.clone();
|
|
2228
4424
|
clone.#select = [col];
|
|
2229
|
-
const { sql, params } = clone
|
|
4425
|
+
const { sql, params } = clone.#compiledNative();
|
|
2230
4426
|
const rows = await this.#db.query<Record<string, unknown>>(sql, params);
|
|
2231
4427
|
return rows.map((row) => {
|
|
2232
4428
|
const v = row[col];
|
|
@@ -2297,8 +4493,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2297
4493
|
unless<V>(
|
|
2298
4494
|
condition: V | undefined | null | false,
|
|
2299
4495
|
fn: (q: this) => void,
|
|
4496
|
+
elseFn?: (q: this) => void,
|
|
2300
4497
|
): this {
|
|
2301
4498
|
if (!condition) fn(this);
|
|
4499
|
+
else if (elseFn) elseFn(this);
|
|
2302
4500
|
return this;
|
|
2303
4501
|
}
|
|
2304
4502
|
|
|
@@ -2308,27 +4506,61 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2308
4506
|
async paginate(page: number, perPage: number): Promise<Paginator<T>> {
|
|
2309
4507
|
const p = Math.max(1, Math.floor(page));
|
|
2310
4508
|
const pp = Math.max(1, Math.floor(perPage));
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
2313
|
-
|
|
2314
|
-
//
|
|
4509
|
+
// Adonis Lucid hook order:
|
|
4510
|
+
// beforePaginate([countQuery, query]) → beforeFetch(query)
|
|
4511
|
+
// → (count + data queries) → afterPaginate(paginator) → afterFetch(rows)
|
|
4512
|
+
// The COUNT and data builders are separate clones so a beforePaginate hook
|
|
4513
|
+
// can constrain BOTH (keeping totals in sync), exactly as documented.
|
|
2315
4514
|
const countQ = this.clone();
|
|
2316
|
-
|
|
4515
|
+
const dataQ = this.clone();
|
|
4516
|
+
await fireHooks(this.#entityClass, "beforePaginate", [countQ, dataQ]);
|
|
4517
|
+
// beforeFetch fires on the main (data) query before either query runs.
|
|
4518
|
+
await fireHooks(this.#entityClass, "beforeFetch", dataQ);
|
|
4519
|
+
// COUNT(*) — strip pagination/order noise from the count clone.
|
|
2317
4520
|
countQ.#limit = undefined;
|
|
2318
4521
|
countQ.#offset = undefined;
|
|
2319
4522
|
countQ.#orderBys = [];
|
|
2320
|
-
|
|
2321
|
-
|
|
4523
|
+
let cSql: string;
|
|
4524
|
+
let cParams: unknown[];
|
|
4525
|
+
if (countQ.#groupBy.length > 0) {
|
|
4526
|
+
// A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
|
|
4527
|
+
// group's own size), so `rows[0].count` would be the first group's size, not
|
|
4528
|
+
// the number of pages. Lucid counts via a subquery: wrap the grouped query
|
|
4529
|
+
// (select + groupBy + having preserved) and count its rows = group count.
|
|
4530
|
+
const inner = countQ.#compiledNative();
|
|
4531
|
+
cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
|
|
4532
|
+
cParams = inner.params;
|
|
4533
|
+
} else {
|
|
4534
|
+
countQ.#select = ["COUNT(*) AS count"];
|
|
4535
|
+
const flat = countQ.#compiledNative();
|
|
4536
|
+
cSql = flat.sql;
|
|
4537
|
+
cParams = flat.params;
|
|
4538
|
+
}
|
|
4539
|
+
const cRows = await this.#db.query<Record<string, unknown>>(
|
|
4540
|
+
cSql,
|
|
4541
|
+
cParams,
|
|
4542
|
+
this.#meta("paginate"),
|
|
4543
|
+
);
|
|
2322
4544
|
const total = Number(cRows[0]?.count ?? 0);
|
|
2323
4545
|
|
|
2324
|
-
const dataQ = this.clone();
|
|
2325
4546
|
dataQ.#limit = pp;
|
|
2326
4547
|
dataQ.#offset = (p - 1) * pp;
|
|
2327
|
-
// `#doExec`
|
|
2328
|
-
//
|
|
4548
|
+
// `#doExec` runs the raw fetch + preloads. beforeFetch already fired above;
|
|
4549
|
+
// afterFetch fires AFTER afterPaginate (Lucid order), so #doExec must not
|
|
4550
|
+
// fire either itself — hence #doExec, not exec().
|
|
2329
4551
|
const items = await dataQ.#doExec();
|
|
2330
|
-
|
|
2331
|
-
|
|
4552
|
+
const metaKeys = this.#entityClass
|
|
4553
|
+
? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
|
|
4554
|
+
: undefined;
|
|
4555
|
+
const paginator = new Paginator<T>(
|
|
4556
|
+
items,
|
|
4557
|
+
{ total, perPage: pp, currentPage: p },
|
|
4558
|
+
metaKeys,
|
|
4559
|
+
);
|
|
4560
|
+
// Lucid: afterPaginate(paginator) then afterFetch(rows).
|
|
4561
|
+
await fireHooks(this.#entityClass, "afterPaginate", paginator);
|
|
4562
|
+
await fireHooks(this.#entityClass, "afterFetch", items);
|
|
4563
|
+
return paginator;
|
|
2332
4564
|
}
|
|
2333
4565
|
|
|
2334
4566
|
/**
|
|
@@ -2349,9 +4581,13 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2349
4581
|
limit: number;
|
|
2350
4582
|
orderBy: string | string[];
|
|
2351
4583
|
}): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
4584
|
+
// Keep BOTH forms: `props` (model property names) to read the cursor value
|
|
4585
|
+
// off the hydrated entity, and `cols` (resolved DB columns) for the SQL
|
|
4586
|
+
// ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
|
|
4587
|
+
// encode `undefined` into the cursor (entity exposes the property, not the
|
|
4588
|
+
// DB column) — an unstable / stuck cursor.
|
|
4589
|
+
const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
|
|
4590
|
+
const cols = props.map((c) => this.#resolveColumn(c));
|
|
2355
4591
|
if (cols.length === 0)
|
|
2356
4592
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
2357
4593
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
@@ -2400,9 +4636,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2400
4636
|
const last = items[items.length - 1] as Record<string, unknown> | undefined;
|
|
2401
4637
|
const nextCursor =
|
|
2402
4638
|
hasMore && last
|
|
2403
|
-
? Buffer.from(
|
|
2404
|
-
|
|
2405
|
-
)
|
|
4639
|
+
? Buffer.from(
|
|
4640
|
+
JSON.stringify({ v: props.map((p) => last[p]) }),
|
|
4641
|
+
).toString("base64")
|
|
2406
4642
|
: null;
|
|
2407
4643
|
return { items, nextCursor, hasMore };
|
|
2408
4644
|
}
|
|
@@ -2423,14 +4659,43 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2423
4659
|
return this;
|
|
2424
4660
|
}
|
|
2425
4661
|
|
|
4662
|
+
/**
|
|
4663
|
+
* Attach arbitrary metadata to the `db:query` event this query emits (Adonis
|
|
4664
|
+
* Lucid `reporterData`) — request id, user id, feature flag, … A listener
|
|
4665
|
+
* reads it off `event.reporterData`. Repeated calls merge. Setting it also
|
|
4666
|
+
* forces emission (like {@link debug}), so the data actually reaches a listener.
|
|
4667
|
+
*/
|
|
4668
|
+
reporterData(data: Record<string, unknown>): this {
|
|
4669
|
+
this.#reporterData = { ...this.#reporterData, ...data };
|
|
4670
|
+
this.#debugFlag = true;
|
|
4671
|
+
return this;
|
|
4672
|
+
}
|
|
4673
|
+
|
|
4674
|
+
/**
|
|
4675
|
+
* Context attached to each statement this query runs, so a `db:query`
|
|
4676
|
+
* listener can say which model and which call produced it — and so
|
|
4677
|
+
* {@link debug} can force emission for this query alone.
|
|
4678
|
+
*
|
|
4679
|
+
* Note the connection's own `debug: true` emits every statement regardless;
|
|
4680
|
+
* `meta` only enriches the event and opens the per-query override.
|
|
4681
|
+
*/
|
|
4682
|
+
#meta(method: string): QueryMeta {
|
|
4683
|
+
return {
|
|
4684
|
+
model: this.#entityClass.name,
|
|
4685
|
+
method,
|
|
4686
|
+
debug: this.#debugFlag,
|
|
4687
|
+
reporterData: this.#reporterData,
|
|
4688
|
+
serverTimeoutMs:
|
|
4689
|
+
this.#cancelTimeout && this.#timeoutMs != null
|
|
4690
|
+
? this.#timeoutMs
|
|
4691
|
+
: undefined,
|
|
4692
|
+
};
|
|
4693
|
+
}
|
|
4694
|
+
|
|
2426
4695
|
/** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
|
|
2427
4696
|
toQuery(): string {
|
|
2428
|
-
const { sql, params } = this
|
|
2429
|
-
|
|
2430
|
-
return sql.replace(/\?|\$\d+/g, () => {
|
|
2431
|
-
const v = params[i++];
|
|
2432
|
-
return this.#literalEscape(v);
|
|
2433
|
-
});
|
|
4697
|
+
const { sql, params } = this.#compiledNative();
|
|
4698
|
+
return interpolateQuery(sql, params);
|
|
2434
4699
|
}
|
|
2435
4700
|
|
|
2436
4701
|
/** Deep clone of this query — mutations on the clone never affect the original. */
|
|
@@ -2443,85 +4708,191 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2443
4708
|
this.#resolveColumn,
|
|
2444
4709
|
this.#softDeletes,
|
|
2445
4710
|
this.#dialect,
|
|
4711
|
+
this.#prepareValue,
|
|
4712
|
+
this.#onDomainEvents,
|
|
2446
4713
|
);
|
|
2447
4714
|
c.#softScope = this.#softScope;
|
|
2448
4715
|
c.#wheres = structuredCloneSafe(this.#wheres);
|
|
2449
4716
|
c.#orderBys = [...this.#orderBys];
|
|
2450
4717
|
c.#select = [...this.#select];
|
|
2451
4718
|
c.#limit = this.#limit;
|
|
4719
|
+
c.#groupLimit = this.#groupLimit;
|
|
4720
|
+
c.#groupOrderBy = this.#groupOrderBy
|
|
4721
|
+
? { ...this.#groupOrderBy }
|
|
4722
|
+
: undefined;
|
|
2452
4723
|
c.#offset = this.#offset;
|
|
2453
4724
|
c.#preloads = new Map(this.#preloads);
|
|
4725
|
+
c.#rowTransformers = [...this.#rowTransformers];
|
|
2454
4726
|
c.#selectSubqueries = structuredClone(this.#selectSubqueries);
|
|
2455
|
-
c.#
|
|
4727
|
+
c.#selectRaw = this.#selectRaw.map((s) => ({
|
|
4728
|
+
...s,
|
|
4729
|
+
params: [...s.params],
|
|
4730
|
+
}));
|
|
4731
|
+
c.#timeoutMs = this.#timeoutMs;
|
|
4732
|
+
c.#cancelTimeout = this.#cancelTimeout;
|
|
4733
|
+
c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
|
|
2456
4734
|
c.#lockMode = this.#lockMode;
|
|
4735
|
+
c.#lockModifier = this.#lockModifier;
|
|
4736
|
+
c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
|
|
2457
4737
|
c.#distinct = this.#distinct;
|
|
4738
|
+
c.#distinctOn = [...this.#distinctOn];
|
|
2458
4739
|
c.#groupBy = [...this.#groupBy];
|
|
2459
4740
|
c.#having = structuredCloneSafe(this.#having);
|
|
2460
|
-
c.#ctes = this.#ctes.map((e) => ({
|
|
4741
|
+
c.#ctes = this.#ctes.map((e) => ({
|
|
4742
|
+
name: e.name,
|
|
4743
|
+
query: e.query.clone(),
|
|
4744
|
+
recursive: e.recursive,
|
|
4745
|
+
materialized: e.materialized,
|
|
4746
|
+
columns: e.columns ? [...e.columns] : undefined,
|
|
4747
|
+
}));
|
|
2461
4748
|
c.#unions = this.#unions.map((u) => ({
|
|
2462
4749
|
query: u.query.clone(),
|
|
2463
4750
|
all: u.all,
|
|
4751
|
+
op: u.op,
|
|
2464
4752
|
}));
|
|
2465
4753
|
c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
|
|
4754
|
+
c.#extraPivotColumns = [...this.#extraPivotColumns];
|
|
4755
|
+
// Pure closure over pivot metadata — safe to share by reference; it reads the
|
|
4756
|
+
// clone's own #pivotWheres at build time (passed in), holding no query state.
|
|
4757
|
+
c.#pivotExists = this.#pivotExists;
|
|
2466
4758
|
c.#debugFlag = this.#debugFlag;
|
|
4759
|
+
c.#reporterData = this.#reporterData
|
|
4760
|
+
? { ...this.#reporterData }
|
|
4761
|
+
: undefined;
|
|
4762
|
+
c.#comments = [...this.#comments];
|
|
2467
4763
|
return c;
|
|
2468
4764
|
}
|
|
2469
4765
|
|
|
2470
4766
|
// === Story 30.2 — update / delete fluent ===========================================================
|
|
2471
4767
|
|
|
2472
|
-
/**
|
|
2473
|
-
|
|
4768
|
+
/**
|
|
4769
|
+
* Execute a fluent UPDATE (Lucid/Knex `update`). Accepts a `{ col: value }` map
|
|
4770
|
+
* OR a single `(column, value)` pair; a value may be a `db.raw(...)` expression.
|
|
4771
|
+
* Returns affected rows (or the RETURNING rows when `returning` is set).
|
|
4772
|
+
*/
|
|
4773
|
+
update(
|
|
4774
|
+
column: string,
|
|
4775
|
+
value: unknown,
|
|
4776
|
+
): DmlBuilder<number | Record<string, unknown>[]>;
|
|
4777
|
+
update(
|
|
2474
4778
|
patch: Record<string, unknown>,
|
|
2475
4779
|
returning?: string[],
|
|
2476
|
-
):
|
|
4780
|
+
): DmlBuilder<number | Record<string, unknown>[]>;
|
|
4781
|
+
update(
|
|
4782
|
+
patchOrColumn: Record<string, unknown> | string,
|
|
4783
|
+
valueOrReturning?: unknown,
|
|
4784
|
+
): DmlBuilder<number | Record<string, unknown>[]> {
|
|
4785
|
+
const patch =
|
|
4786
|
+
typeof patchOrColumn === "string"
|
|
4787
|
+
? { [patchOrColumn]: valueOrReturning }
|
|
4788
|
+
: patchOrColumn;
|
|
4789
|
+
const returning =
|
|
4790
|
+
typeof patchOrColumn === "string"
|
|
4791
|
+
? undefined
|
|
4792
|
+
: (valueOrReturning as string[] | undefined);
|
|
2477
4793
|
if (!patch || Object.keys(patch).length === 0) {
|
|
2478
4794
|
throw new Error("update() requires a non-empty payload");
|
|
2479
4795
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
4796
|
+
// Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
|
|
4797
|
+
// like BaseRepository's write paths — the fluent update() must not bypass it.
|
|
4798
|
+
// A `db.raw(...)` value is emitted as a raw SET expression instead.
|
|
4799
|
+
const setPairs = Object.entries(patch).map(([k, v]): [string, unknown] =>
|
|
4800
|
+
v instanceof RawSql
|
|
4801
|
+
? [this.#resolveColumn(k), { raw: v.sql, rawParams: [...v.params] }]
|
|
4802
|
+
: [this.#resolveColumn(k), this.#prepareValue(k, v)],
|
|
2482
4803
|
);
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
4804
|
+
return this.#makeDml(
|
|
4805
|
+
(r) => ({
|
|
4806
|
+
kind: "update",
|
|
4807
|
+
table: this.#tableName,
|
|
4808
|
+
set: setPairs,
|
|
4809
|
+
wheres: this.#wheresForDml(),
|
|
4810
|
+
returning: r,
|
|
4811
|
+
ctes: this.#compiledCtes(),
|
|
4812
|
+
}),
|
|
4813
|
+
returning,
|
|
4814
|
+
);
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
/**
|
|
4818
|
+
* Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
|
|
4819
|
+
* scoped rows (stamps `deleted_at`) — consistent with the entity-level
|
|
4820
|
+
* `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
|
|
4821
|
+
* model it issues a hard `DELETE`. Returns affected rows (or rows when
|
|
4822
|
+
* `returning` is set).
|
|
4823
|
+
*/
|
|
4824
|
+
delete(returning?: string[]): DmlBuilder<number | Record<string, unknown>[]> {
|
|
4825
|
+
if (this.#softDeletes) {
|
|
4826
|
+
const stampedAt = new Date().toISOString();
|
|
4827
|
+
return this.#makeDml(
|
|
4828
|
+
(r) => ({
|
|
4829
|
+
kind: "update",
|
|
4830
|
+
table: this.#tableName,
|
|
4831
|
+
set: [[this.#deletedAtColumn(), stampedAt]],
|
|
4832
|
+
wheres: this.#wheresForDml(),
|
|
4833
|
+
returning: r,
|
|
4834
|
+
ctes: this.#compiledCtes(),
|
|
4835
|
+
}),
|
|
4836
|
+
returning,
|
|
2495
4837
|
);
|
|
2496
4838
|
}
|
|
2497
|
-
|
|
2498
|
-
return r.rowsAffected ?? 0;
|
|
4839
|
+
return this.forceDelete(returning);
|
|
2499
4840
|
}
|
|
2500
4841
|
|
|
2501
|
-
/**
|
|
2502
|
-
|
|
4842
|
+
/** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
|
|
4843
|
+
forceDelete(
|
|
2503
4844
|
returning?: string[],
|
|
2504
|
-
):
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
4845
|
+
): DmlBuilder<number | Record<string, unknown>[]> {
|
|
4846
|
+
return this.#makeDml(
|
|
4847
|
+
(r) => ({
|
|
4848
|
+
kind: "delete",
|
|
4849
|
+
table: this.#tableName,
|
|
4850
|
+
wheres: this.#wheresForDml(),
|
|
4851
|
+
returning: r,
|
|
4852
|
+
ctes: this.#compiledCtes(),
|
|
4853
|
+
}),
|
|
4854
|
+
returning,
|
|
4855
|
+
);
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
/**
|
|
4859
|
+
* Bulk restore: clear `deleted_at` on the trashed rows matching the user's
|
|
4860
|
+
* predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
|
|
4861
|
+
* on a non-soft-delete model. Independent of the current soft-scope — it always
|
|
4862
|
+
* targets trashed rows (`deleted_at IS NOT NULL`).
|
|
4863
|
+
*/
|
|
4864
|
+
restore(
|
|
4865
|
+
returning?: string[],
|
|
4866
|
+
): DmlBuilder<number | Record<string, unknown>[]> {
|
|
4867
|
+
if (!this.#softDeletes) {
|
|
4868
|
+
return new DmlBuilder(
|
|
4869
|
+
() => Promise.resolve<number | Record<string, unknown>[]>(0),
|
|
4870
|
+
() => ({ sql: "", bindings: [], params: [] }),
|
|
4871
|
+
this.#dmlHooks(),
|
|
2516
4872
|
);
|
|
2517
4873
|
}
|
|
2518
|
-
|
|
2519
|
-
|
|
4874
|
+
return this.#makeDml((r) => {
|
|
4875
|
+
const wheres = this.#userWheresForDml();
|
|
4876
|
+
wheres.push({
|
|
4877
|
+
column: this.#deletedAtColumn(),
|
|
4878
|
+
operator: "IS NOT NULL",
|
|
4879
|
+
value: null,
|
|
4880
|
+
type: "and",
|
|
4881
|
+
});
|
|
4882
|
+
return {
|
|
4883
|
+
kind: "update",
|
|
4884
|
+
table: this.#tableName,
|
|
4885
|
+
set: [[this.#deletedAtColumn(), null]],
|
|
4886
|
+
wheres,
|
|
4887
|
+
returning: r,
|
|
4888
|
+
ctes: this.#compiledCtes(),
|
|
4889
|
+
};
|
|
4890
|
+
}, returning);
|
|
2520
4891
|
}
|
|
2521
4892
|
|
|
2522
4893
|
// === Story 30.3 — increment / decrement already implemented? check ================================
|
|
2523
4894
|
|
|
2524
|
-
increment(column: string, amount
|
|
4895
|
+
increment(column: string, amount?: number): Promise<number>;
|
|
2525
4896
|
increment(patch: Record<string, number>): Promise<number>;
|
|
2526
4897
|
increment(
|
|
2527
4898
|
colOrPatch: string | Record<string, number>,
|
|
@@ -2530,7 +4901,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2530
4901
|
return this.#runIncDec("increment", colOrPatch, amount);
|
|
2531
4902
|
}
|
|
2532
4903
|
|
|
2533
|
-
decrement(column: string, amount
|
|
4904
|
+
decrement(column: string, amount?: number): Promise<number>;
|
|
2534
4905
|
decrement(patch: Record<string, number>): Promise<number>;
|
|
2535
4906
|
decrement(
|
|
2536
4907
|
colOrPatch: string | Record<string, number>,
|
|
@@ -2563,6 +4934,56 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2563
4934
|
return this;
|
|
2564
4935
|
}
|
|
2565
4936
|
|
|
4937
|
+
/** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
|
|
4938
|
+
forNoKeyUpdate(): this {
|
|
4939
|
+
if (this.#dialect === "postgres") {
|
|
4940
|
+
this.#lockMode = "FOR NO KEY UPDATE";
|
|
4941
|
+
} else {
|
|
4942
|
+
console.warn(
|
|
4943
|
+
`[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`,
|
|
4944
|
+
);
|
|
4945
|
+
}
|
|
4946
|
+
return this;
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
/** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
|
|
4950
|
+
forKeyShare(): this {
|
|
4951
|
+
if (this.#dialect === "postgres") {
|
|
4952
|
+
this.#lockMode = "FOR KEY SHARE";
|
|
4953
|
+
} else {
|
|
4954
|
+
console.warn(
|
|
4955
|
+
`[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`,
|
|
4956
|
+
);
|
|
4957
|
+
}
|
|
4958
|
+
return this;
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4961
|
+
/**
|
|
4962
|
+
* Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
|
|
4963
|
+
* waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
4964
|
+
*/
|
|
4965
|
+
skipLocked(): this {
|
|
4966
|
+
if (this.#dialect === "sqlite") {
|
|
4967
|
+
console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
|
|
4968
|
+
} else {
|
|
4969
|
+
this.#lockModifier = "SKIP LOCKED";
|
|
4970
|
+
}
|
|
4971
|
+
return this;
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
/**
|
|
4975
|
+
* Append `NOWAIT` to the lock clause — error immediately instead of waiting on
|
|
4976
|
+
* a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
4977
|
+
*/
|
|
4978
|
+
noWait(): this {
|
|
4979
|
+
if (this.#dialect === "sqlite") {
|
|
4980
|
+
console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
|
|
4981
|
+
} else {
|
|
4982
|
+
this.#lockModifier = "NOWAIT";
|
|
4983
|
+
}
|
|
4984
|
+
return this;
|
|
4985
|
+
}
|
|
4986
|
+
|
|
2566
4987
|
// === Private helpers ==============================================================================
|
|
2567
4988
|
|
|
2568
4989
|
#quote(name: string): string {
|
|
@@ -2571,62 +4992,207 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2571
4992
|
|
|
2572
4993
|
/** Quote a `table.column` reference on both sides of the dot. */
|
|
2573
4994
|
#quoteCol(ref: string): string {
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
4995
|
+
// Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
|
|
4996
|
+
// identifier smuggling a `"`/backtick would break out of the quoting on the
|
|
4997
|
+
// join path (which the Rust screen doesn't re-validate). Strict
|
|
4998
|
+
// `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
|
|
4999
|
+
// helpers injection-safe. Use joinRaw() for anything more complex.
|
|
5000
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
|
|
5001
|
+
throw new Error(
|
|
5002
|
+
`Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`,
|
|
5003
|
+
);
|
|
2577
5004
|
}
|
|
2578
|
-
return
|
|
5005
|
+
return ref
|
|
5006
|
+
.split(".")
|
|
5007
|
+
.map((seg) => this.#quote(seg))
|
|
5008
|
+
.join(".");
|
|
2579
5009
|
}
|
|
2580
5010
|
|
|
2581
5011
|
#pushJoin(
|
|
2582
|
-
kind: "INNER" | "LEFT" | "RIGHT",
|
|
5012
|
+
kind: "INNER" | "LEFT" | "RIGHT" | "FULL OUTER",
|
|
2583
5013
|
table: string,
|
|
2584
5014
|
leftOrBuild: string | ((j: JoinBuilder) => void),
|
|
5015
|
+
operatorOrRight?: string,
|
|
2585
5016
|
right?: string,
|
|
2586
5017
|
): this {
|
|
2587
|
-
const tq = this.#
|
|
5018
|
+
const tq = this.#quoteCol(table);
|
|
2588
5019
|
if (typeof leftOrBuild === "function") {
|
|
5020
|
+
const parts: JoinPartMQ[] = [];
|
|
2589
5021
|
const jb: JoinBuilder = {
|
|
2590
|
-
parts
|
|
2591
|
-
on(l: string,
|
|
2592
|
-
|
|
2593
|
-
|
|
5022
|
+
parts,
|
|
5023
|
+
on: (l: string, opOrR: string, r?: string) => {
|
|
5024
|
+
parts.push(
|
|
5025
|
+
r === undefined
|
|
5026
|
+
? { kind: "and", left: l, right: opOrR }
|
|
5027
|
+
: { kind: "and", left: l, operator: opOrR, right: r },
|
|
5028
|
+
);
|
|
5029
|
+
return jb;
|
|
5030
|
+
},
|
|
5031
|
+
andOn: (l: string, opOrR: string, r?: string) => {
|
|
5032
|
+
parts.push(
|
|
5033
|
+
r === undefined
|
|
5034
|
+
? { kind: "and", left: l, right: opOrR }
|
|
5035
|
+
: { kind: "and", left: l, operator: opOrR, right: r },
|
|
5036
|
+
);
|
|
5037
|
+
return jb;
|
|
5038
|
+
},
|
|
5039
|
+
orOn: (l: string, opOrR: string, r?: string) => {
|
|
5040
|
+
parts.push(
|
|
5041
|
+
r === undefined
|
|
5042
|
+
? { kind: "or", left: l, right: opOrR }
|
|
5043
|
+
: { kind: "or", left: l, operator: opOrR, right: r },
|
|
5044
|
+
);
|
|
5045
|
+
return jb;
|
|
5046
|
+
},
|
|
5047
|
+
onVal: (l, v) => {
|
|
5048
|
+
parts.push({ kind: "and", left: l, value: { v } });
|
|
5049
|
+
return jb;
|
|
5050
|
+
},
|
|
5051
|
+
andOnVal: (l, v) => {
|
|
5052
|
+
parts.push({ kind: "and", left: l, value: { v } });
|
|
5053
|
+
return jb;
|
|
5054
|
+
},
|
|
5055
|
+
orOnVal: (l, v) => {
|
|
5056
|
+
parts.push({ kind: "or", left: l, value: { v } });
|
|
5057
|
+
return jb;
|
|
5058
|
+
},
|
|
5059
|
+
onIn: (l, values) => {
|
|
5060
|
+
parts.push({ kind: "and", left: l, values: [...values] });
|
|
5061
|
+
return jb;
|
|
5062
|
+
},
|
|
5063
|
+
onNotIn: (l, values) => {
|
|
5064
|
+
parts.push({
|
|
5065
|
+
kind: "and",
|
|
5066
|
+
left: l,
|
|
5067
|
+
values: [...values],
|
|
5068
|
+
notIn: true,
|
|
5069
|
+
});
|
|
5070
|
+
return jb;
|
|
5071
|
+
},
|
|
5072
|
+
onNull: (l) => {
|
|
5073
|
+
parts.push({ kind: "and", left: l, nullOp: "IS NULL" });
|
|
5074
|
+
return jb;
|
|
5075
|
+
},
|
|
5076
|
+
onNotNull: (l) => {
|
|
5077
|
+
parts.push({ kind: "and", left: l, nullOp: "IS NOT NULL" });
|
|
5078
|
+
return jb;
|
|
5079
|
+
},
|
|
5080
|
+
onBetween: (l, range) => {
|
|
5081
|
+
parts.push({ kind: "and", left: l, between: [range[0], range[1]] });
|
|
5082
|
+
return jb;
|
|
2594
5083
|
},
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
5084
|
+
onNotBetween: (l, range) => {
|
|
5085
|
+
parts.push({
|
|
5086
|
+
kind: "and",
|
|
5087
|
+
left: l,
|
|
5088
|
+
between: [range[0], range[1]],
|
|
5089
|
+
notBetween: true,
|
|
5090
|
+
});
|
|
5091
|
+
return jb;
|
|
5092
|
+
},
|
|
5093
|
+
onExists: (sub) => {
|
|
5094
|
+
const { sql, params } = this.#resolveUnion(sub).#compiledNative();
|
|
5095
|
+
parts.push({ kind: "and", exists: { sql, params, not: false } });
|
|
5096
|
+
return jb;
|
|
2598
5097
|
},
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
5098
|
+
onNotExists: (sub) => {
|
|
5099
|
+
const { sql, params } = this.#resolveUnion(sub).#compiledNative();
|
|
5100
|
+
parts.push({ kind: "and", exists: { sql, params, not: true } });
|
|
5101
|
+
return jb;
|
|
2602
5102
|
},
|
|
2603
5103
|
};
|
|
2604
5104
|
leftOrBuild(jb);
|
|
2605
|
-
|
|
5105
|
+
// A BASE-table column runs the full model prepare (DateTime→ISO + @Column
|
|
5106
|
+
// adapters/casts), keyed by its property. A FOREIGN join column must NOT
|
|
5107
|
+
// borrow the root model's adapter for a same-named column on another
|
|
5108
|
+
// table — apply only universal type-lowering, matching Knex's
|
|
5109
|
+
// model-agnostic join binding.
|
|
5110
|
+
const prep = (col: string, v: unknown): unknown => {
|
|
5111
|
+
const dot = col.lastIndexOf(".");
|
|
5112
|
+
const tablePrefix = dot >= 0 ? col.slice(0, dot) : "";
|
|
5113
|
+
const leaf = dot >= 0 ? col.slice(dot + 1) : col;
|
|
5114
|
+
const isBaseColumn =
|
|
5115
|
+
tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
|
|
5116
|
+
return isBaseColumn ? this.#prepareValue(leaf, v) : lowerJoinValue(v);
|
|
5117
|
+
};
|
|
5118
|
+
const params: unknown[] = [];
|
|
5119
|
+
const on = parts
|
|
2606
5120
|
.map((p, i) => {
|
|
2607
5121
|
const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
|
|
2608
|
-
|
|
5122
|
+
if (p.exists) {
|
|
5123
|
+
params.push(...p.exists.params);
|
|
5124
|
+
return `${prefix} ${p.exists.not ? "NOT EXISTS" : "EXISTS"} (${p.exists.sql})`;
|
|
5125
|
+
}
|
|
5126
|
+
const col = this.#quoteCol(p.left ?? "");
|
|
5127
|
+
if (p.nullOp) {
|
|
5128
|
+
return `${prefix} ${col} ${p.nullOp}`;
|
|
5129
|
+
}
|
|
5130
|
+
if (p.between) {
|
|
5131
|
+
params.push(
|
|
5132
|
+
prep(p.left ?? "", p.between[0]),
|
|
5133
|
+
prep(p.left ?? "", p.between[1]),
|
|
5134
|
+
);
|
|
5135
|
+
return `${prefix} ${col} ${p.notBetween ? "NOT BETWEEN" : "BETWEEN"} ? AND ?`;
|
|
5136
|
+
}
|
|
5137
|
+
if (p.values) {
|
|
5138
|
+
const placeholders = p.values.map(() => "?").join(", ");
|
|
5139
|
+
for (const v of p.values) params.push(prep(p.left ?? "", v));
|
|
5140
|
+
return `${prefix} ${col} ${p.notIn ? "NOT IN" : "IN"} (${placeholders})`;
|
|
5141
|
+
}
|
|
5142
|
+
if (p.value) {
|
|
5143
|
+
params.push(prep(p.left ?? "", p.value.v));
|
|
5144
|
+
return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ?`;
|
|
5145
|
+
}
|
|
5146
|
+
return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ${this.#quoteCol(p.right ?? "")}`;
|
|
2609
5147
|
})
|
|
2610
5148
|
.join(" ");
|
|
2611
|
-
this.#joins.push(`${kind} JOIN ${tq} ${on}
|
|
5149
|
+
this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
|
|
2612
5150
|
return this;
|
|
2613
5151
|
}
|
|
2614
|
-
|
|
5152
|
+
// String form: 3-arg `(left, right)` or 4-arg `(left, operator, right)`.
|
|
5153
|
+
const left = leftOrBuild;
|
|
5154
|
+
const operator = right === undefined ? "=" : (operatorOrRight ?? "=");
|
|
5155
|
+
const rightCol = right === undefined ? operatorOrRight : right;
|
|
5156
|
+
if (rightCol === undefined)
|
|
2615
5157
|
throw new Error(
|
|
2616
5158
|
"join() with string form requires both left and right operands",
|
|
2617
5159
|
);
|
|
2618
|
-
this.#joins.push(
|
|
2619
|
-
`${kind} JOIN ${tq} ON ${this.#quoteCol(
|
|
2620
|
-
|
|
5160
|
+
this.#joins.push({
|
|
5161
|
+
sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(left)} ${this.#validateJoinOp(operator)} ${this.#quoteCol(rightCol)}`,
|
|
5162
|
+
params: [],
|
|
5163
|
+
});
|
|
2621
5164
|
return this;
|
|
2622
5165
|
}
|
|
2623
5166
|
|
|
5167
|
+
/** Allowlist the comparison operator embedded verbatim into a JOIN's ON SQL. */
|
|
5168
|
+
#validateJoinOp(op: string): string {
|
|
5169
|
+
const t = op.trim();
|
|
5170
|
+
const up = t.toUpperCase();
|
|
5171
|
+
const allowed = new Set([
|
|
5172
|
+
"=",
|
|
5173
|
+
"<>",
|
|
5174
|
+
"!=",
|
|
5175
|
+
"<",
|
|
5176
|
+
"<=",
|
|
5177
|
+
">",
|
|
5178
|
+
">=",
|
|
5179
|
+
"LIKE",
|
|
5180
|
+
"NOT LIKE",
|
|
5181
|
+
"ILIKE",
|
|
5182
|
+
]);
|
|
5183
|
+
if (allowed.has(t)) return t;
|
|
5184
|
+
if (allowed.has(up)) return up;
|
|
5185
|
+
throw new Error(`Unsupported join operator '${op}'.`);
|
|
5186
|
+
}
|
|
5187
|
+
|
|
2624
5188
|
async #runScalar(expr: string): Promise<unknown> {
|
|
2625
5189
|
const clone = this.clone();
|
|
2626
5190
|
clone.#select = [`${expr} AS __scalar__`];
|
|
2627
5191
|
clone.#orderBys = [];
|
|
2628
|
-
const { sql, params } = clone
|
|
2629
|
-
const rows = await this.#
|
|
5192
|
+
const { sql, params } = clone.#compiledNative();
|
|
5193
|
+
const rows = await this.#raceTimeout(
|
|
5194
|
+
this.#db.query<Record<string, unknown>>(sql, params),
|
|
5195
|
+
);
|
|
2630
5196
|
const row = rows[0];
|
|
2631
5197
|
return row ? row.__scalar__ : null;
|
|
2632
5198
|
}
|
|
@@ -2651,46 +5217,140 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2651
5217
|
set: setPairs,
|
|
2652
5218
|
wheres: this.#wheresForDml(),
|
|
2653
5219
|
returning: [],
|
|
5220
|
+
ctes: this.#compiledCtes(),
|
|
2654
5221
|
};
|
|
2655
5222
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
2656
|
-
const r = await this.#
|
|
5223
|
+
const r = await this.#raceTimeout(
|
|
5224
|
+
this.#db.execute(
|
|
5225
|
+
this.#commentPrefix() + compiled.statements[0],
|
|
5226
|
+
compiled.params,
|
|
5227
|
+
this.#meta("dml"),
|
|
5228
|
+
),
|
|
5229
|
+
);
|
|
2657
5230
|
return r.rowsAffected ?? 0;
|
|
2658
5231
|
}
|
|
2659
5232
|
|
|
2660
5233
|
/**
|
|
2661
|
-
*
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
2664
|
-
*
|
|
5234
|
+
* The user's own WHERE predicates for DML (no soft-delete scope). The DML
|
|
5235
|
+
* compiler now REUSES the SELECT compiler's WHERE lowering, so every predicate
|
|
5236
|
+
* the read builder accepts — standard, `whereRaw`, `whereExists`, sub-queries,
|
|
5237
|
+
* and nested groups — is valid for update/delete too. Passed through unchanged
|
|
5238
|
+
* (same as `#buildSpec` does for reads).
|
|
2665
5239
|
*/
|
|
2666
|
-
#
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
5240
|
+
#userWheresForDml(): WhereClause[] {
|
|
5241
|
+
return [...this.#wheres];
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
#wheresForDml(): WhereClause[] {
|
|
5245
|
+
const out = this.#userWheresForDml();
|
|
5246
|
+
// Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
|
|
5247
|
+
// update/delete/increment/decrement must NOT touch trashed rows under the
|
|
5248
|
+
// default scope — otherwise `query().where(x)` would denote a different row
|
|
5249
|
+
// set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
|
|
5250
|
+
// `.onlyTrashed()` restricts to trashed (mirrors reads).
|
|
5251
|
+
if (this.#softDeletes) {
|
|
5252
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
5253
|
+
if (this.#softScope === "default") {
|
|
5254
|
+
out.push({
|
|
5255
|
+
column: deletedAtCol,
|
|
5256
|
+
operator: "IS NULL",
|
|
5257
|
+
value: null,
|
|
5258
|
+
type: "and",
|
|
5259
|
+
});
|
|
5260
|
+
} else if (this.#softScope === "only-trashed") {
|
|
5261
|
+
out.push({
|
|
5262
|
+
column: deletedAtCol,
|
|
5263
|
+
operator: "IS NOT NULL",
|
|
5264
|
+
value: null,
|
|
5265
|
+
type: "and",
|
|
5266
|
+
});
|
|
2683
5267
|
}
|
|
2684
|
-
out.push({
|
|
2685
|
-
column: w.column,
|
|
2686
|
-
operator: w.operator,
|
|
2687
|
-
value: w.value,
|
|
2688
|
-
type: w.type,
|
|
2689
|
-
});
|
|
2690
5268
|
}
|
|
2691
5269
|
return out;
|
|
2692
5270
|
}
|
|
2693
5271
|
|
|
5272
|
+
/** Compile a DML spec with the comment prefix — for a lazy builder's `.toSQL()`. */
|
|
5273
|
+
#compileDmlSpec(spec: Record<string, unknown>): {
|
|
5274
|
+
sql: string;
|
|
5275
|
+
bindings: unknown[];
|
|
5276
|
+
params: unknown[];
|
|
5277
|
+
} {
|
|
5278
|
+
const compiled = compileStatementNative(spec, this.#dialect);
|
|
5279
|
+
const sql = this.#commentPrefix() + compiled.statements[0];
|
|
5280
|
+
return { sql, bindings: compiled.params, params: compiled.params };
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5283
|
+
/** Hooks a lazy DML builder delegates back to (upsert clauses are N/A on a model query). */
|
|
5284
|
+
#dmlHooks(): DmlChainHooks {
|
|
5285
|
+
return {
|
|
5286
|
+
onConflict: () => {},
|
|
5287
|
+
merge: () => {},
|
|
5288
|
+
ignore: () => {},
|
|
5289
|
+
returning: (...cols) => {
|
|
5290
|
+
for (const c of cols) {
|
|
5291
|
+
if (Array.isArray(c)) this.#dmlReturning.push(...c);
|
|
5292
|
+
else this.#dmlReturning.push(c);
|
|
5293
|
+
}
|
|
5294
|
+
},
|
|
5295
|
+
timeout: (ms) => {
|
|
5296
|
+
this.#timeoutMs = ms;
|
|
5297
|
+
},
|
|
5298
|
+
comment: (t) => {
|
|
5299
|
+
this.comment(t);
|
|
5300
|
+
},
|
|
5301
|
+
debug: (enabled) => {
|
|
5302
|
+
this.debug(enabled);
|
|
5303
|
+
},
|
|
5304
|
+
reporterData: (data) => {
|
|
5305
|
+
this.reporterData(data);
|
|
5306
|
+
},
|
|
5307
|
+
};
|
|
5308
|
+
}
|
|
5309
|
+
|
|
5310
|
+
/** Wrap a DML spec-builder as a lazy, chainable, inspectable {@link DmlBuilder}. */
|
|
5311
|
+
#makeDml(
|
|
5312
|
+
build: (returning: string[]) => Record<string, unknown>,
|
|
5313
|
+
paramReturning?: string[],
|
|
5314
|
+
): DmlBuilder<number | Record<string, unknown>[]> {
|
|
5315
|
+
const resolved = () =>
|
|
5316
|
+
[...(paramReturning ?? []), ...this.#dmlReturning].map((c) =>
|
|
5317
|
+
this.#resolveSelect(c),
|
|
5318
|
+
);
|
|
5319
|
+
return new DmlBuilder(
|
|
5320
|
+
() => {
|
|
5321
|
+
const r = resolved();
|
|
5322
|
+
return this.#runDml(build(r), r);
|
|
5323
|
+
},
|
|
5324
|
+
() => this.#compileDmlSpec(build(resolved())),
|
|
5325
|
+
this.#dmlHooks(),
|
|
5326
|
+
);
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
/** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
|
|
5330
|
+
async #runDml(
|
|
5331
|
+
spec: Record<string, unknown>,
|
|
5332
|
+
returning?: string[],
|
|
5333
|
+
): Promise<number | Record<string, unknown>[]> {
|
|
5334
|
+
const compiled = compileStatementNative(spec, this.#dialect);
|
|
5335
|
+
if (returning && returning.length > 0) {
|
|
5336
|
+
return this.#raceTimeout(
|
|
5337
|
+
this.#db.query<Record<string, unknown>>(
|
|
5338
|
+
this.#commentPrefix() + compiled.statements[0],
|
|
5339
|
+
compiled.params,
|
|
5340
|
+
this.#meta("dml"),
|
|
5341
|
+
),
|
|
5342
|
+
);
|
|
5343
|
+
}
|
|
5344
|
+
const r = await this.#raceTimeout(
|
|
5345
|
+
this.#db.execute(
|
|
5346
|
+
this.#commentPrefix() + compiled.statements[0],
|
|
5347
|
+
compiled.params,
|
|
5348
|
+
this.#meta("dml"),
|
|
5349
|
+
),
|
|
5350
|
+
);
|
|
5351
|
+
return r.rowsAffected ?? 0;
|
|
5352
|
+
}
|
|
5353
|
+
|
|
2694
5354
|
/**
|
|
2695
5355
|
* !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
|
|
2696
5356
|
*
|
|
@@ -2704,15 +5364,6 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2704
5364
|
* parameters via the Rust compiler — this escaper is never on the hot path.
|
|
2705
5365
|
* If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
|
|
2706
5366
|
*/
|
|
2707
|
-
#literalEscape(v: unknown): string {
|
|
2708
|
-
if (v === null || v === undefined) return "NULL";
|
|
2709
|
-
if (typeof v === "number") return String(v);
|
|
2710
|
-
if (typeof v === "boolean") return v ? "1" : "0";
|
|
2711
|
-
if (v instanceof Date) return `'${v.toISOString()}'`;
|
|
2712
|
-
// Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
|
|
2713
|
-
return `'${String(v).replace(/'/g, "''")}'`;
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
5367
|
/**
|
|
2717
5368
|
* Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
|
|
2718
5369
|
* on the SAME table is used as the scratch builder so the callback can call
|
|
@@ -2728,6 +5379,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2728
5379
|
this.#resolveColumn,
|
|
2729
5380
|
false,
|
|
2730
5381
|
this.#dialect,
|
|
5382
|
+
this.#prepareValue,
|
|
2731
5383
|
);
|
|
2732
5384
|
callback(scratch);
|
|
2733
5385
|
return { type, kind: "group", conditions: scratch.#wheres };
|
|
@@ -2781,19 +5433,30 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2781
5433
|
type,
|
|
2782
5434
|
column: resolved,
|
|
2783
5435
|
operator: "=",
|
|
2784
|
-
value: operatorOrValue,
|
|
5436
|
+
value: this.#prep(column, operatorOrValue),
|
|
2785
5437
|
});
|
|
2786
5438
|
} else {
|
|
2787
5439
|
this.#wheres.push({
|
|
2788
5440
|
type,
|
|
2789
5441
|
column: resolved,
|
|
2790
5442
|
operator: operatorOrValue as string,
|
|
2791
|
-
value,
|
|
5443
|
+
value: this.#prep(column, value),
|
|
2792
5444
|
});
|
|
2793
5445
|
}
|
|
2794
5446
|
return this;
|
|
2795
5447
|
}
|
|
2796
5448
|
|
|
5449
|
+
/**
|
|
5450
|
+
* Lower a WHERE/search value (or each element of an array) to its DB form via
|
|
5451
|
+
* the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
|
|
5452
|
+
* adapter column used as a predicate binds the same shape the write path stores.
|
|
5453
|
+
*/
|
|
5454
|
+
#prep(column: string, value: unknown): unknown {
|
|
5455
|
+
return Array.isArray(value)
|
|
5456
|
+
? value.map((v) => this.#prepareValue(column, v))
|
|
5457
|
+
: this.#prepareValue(column, value);
|
|
5458
|
+
}
|
|
5459
|
+
|
|
2797
5460
|
/**
|
|
2798
5461
|
* Resolve this ModelQuery's preloads against a pre-loaded set of entities.
|
|
2799
5462
|
* Used by the nested-preload machinery to recurse without re-running the root select.
|