@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/dist/ModelQuery.js
CHANGED
|
@@ -7,9 +7,19 @@
|
|
|
7
7
|
* Builds SQL fluently and executes against the database connection.
|
|
8
8
|
*/
|
|
9
9
|
var _a;
|
|
10
|
-
import {
|
|
10
|
+
import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
|
|
11
|
+
import { REPO_REF } from "./BaseEntity.js";
|
|
12
|
+
// Value import used only inside method bodies (preload hydration) — the
|
|
13
|
+
// BaseRepository ↔ ModelQuery cycle resolves at runtime, after both are defined.
|
|
14
|
+
import { assertNotPromise, BaseRepository, wrapAdapterError, } from "./BaseRepository.js";
|
|
15
|
+
import { ensureEntityMetadata, getColumnMetadata, getDateColumnConfig, getPrimaryKey, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
|
|
11
16
|
import { fireHooks } from "./decorators/hooks.js";
|
|
12
|
-
import {
|
|
17
|
+
import { getNamingStrategy } from "./naming/NamingStrategy.js";
|
|
18
|
+
import { DmlBuilder } from "./query/DmlBuilder.js";
|
|
19
|
+
import { compiledStatement, interpolateQuery, } from "./query/interpolate.js";
|
|
20
|
+
import { compileStatementNative, getAtlasDialect, normalizeDialect, } from "./query/native.js";
|
|
21
|
+
import { negateOperator } from "./query/operators.js";
|
|
22
|
+
import { RawSql } from "./query/QueryBuilder.js";
|
|
13
23
|
import { camelToSnake, snakeToCamel } from "./utils/casing.js";
|
|
14
24
|
/**
|
|
15
25
|
* Comparison operators allowed in `whereExpr`'s raw 4-arg form (where
|
|
@@ -28,6 +38,49 @@ const WHEREEXPR_OPERATORS = new Set([
|
|
|
28
38
|
"LIKE",
|
|
29
39
|
"NOT LIKE",
|
|
30
40
|
]);
|
|
41
|
+
/**
|
|
42
|
+
* SQL keyword tokens forbidden inside `whereExpr`'s arithmetic extra-expression.
|
|
43
|
+
* They are just letters (pass the charset guard) but would let the fragment alter
|
|
44
|
+
* the predicate's logical structure — whereExpr stays an arithmetic-only, SAFE
|
|
45
|
+
* alternative to whereRaw. A column genuinely named after a keyword must use whereRaw.
|
|
46
|
+
*/
|
|
47
|
+
const WHEREEXPR_FORBIDDEN_WORDS = new Set([
|
|
48
|
+
"OR",
|
|
49
|
+
"AND",
|
|
50
|
+
"NOT",
|
|
51
|
+
"IS",
|
|
52
|
+
"NULL",
|
|
53
|
+
"IN",
|
|
54
|
+
"LIKE",
|
|
55
|
+
"ILIKE",
|
|
56
|
+
"BETWEEN",
|
|
57
|
+
"EXISTS",
|
|
58
|
+
"ANY",
|
|
59
|
+
"ALL",
|
|
60
|
+
"SOME",
|
|
61
|
+
"CASE",
|
|
62
|
+
"WHEN",
|
|
63
|
+
"THEN",
|
|
64
|
+
"ELSE",
|
|
65
|
+
"END",
|
|
66
|
+
"SELECT",
|
|
67
|
+
"FROM",
|
|
68
|
+
"WHERE",
|
|
69
|
+
"JOIN",
|
|
70
|
+
"UNION",
|
|
71
|
+
"INTERSECT",
|
|
72
|
+
"EXCEPT",
|
|
73
|
+
"HAVING",
|
|
74
|
+
"GROUP",
|
|
75
|
+
"ORDER",
|
|
76
|
+
"BY",
|
|
77
|
+
"LIMIT",
|
|
78
|
+
"OFFSET",
|
|
79
|
+
"AS",
|
|
80
|
+
"DISTINCT",
|
|
81
|
+
"TRUE",
|
|
82
|
+
"FALSE",
|
|
83
|
+
]);
|
|
31
84
|
/** True when every `(` in `s` has a matching `)` and none closes early. */
|
|
32
85
|
function hasBalancedParens(s) {
|
|
33
86
|
let depth = 0;
|
|
@@ -42,6 +95,97 @@ function hasBalancedParens(s) {
|
|
|
42
95
|
}
|
|
43
96
|
return depth === 0;
|
|
44
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Column resolver for an ARBITRARY entity class, honouring `@Column({ columnName })`
|
|
100
|
+
* and the snake_case convention. Used to build correlated/preload subqueries on a
|
|
101
|
+
* RELATED model so their WHERE/join columns resolve like a direct query would.
|
|
102
|
+
*/
|
|
103
|
+
function buildColumnResolver(entityClass) {
|
|
104
|
+
const map = new Map();
|
|
105
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
106
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
107
|
+
map.set(col.propertyKey, db);
|
|
108
|
+
map.set(db, db);
|
|
109
|
+
}
|
|
110
|
+
return (col) => map.get(col) ?? camelToSnake(col);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Value preparer for an ARBITRARY entity class — mirrors `BaseRepository.#applyPrepare`
|
|
114
|
+
* (a `@column.dateTime` DateTime → ISO, a `@Column({ prepare })` adapter runs). So a
|
|
115
|
+
* preload/whereHas constraint on a RELATED model prepares its values like a direct query.
|
|
116
|
+
*/
|
|
117
|
+
function buildValuePreparer(entityClass) {
|
|
118
|
+
const prepares = new Map();
|
|
119
|
+
// Reverse map (db column → property) so a caller passing a DB name or an
|
|
120
|
+
// explicit `columnName` (e.g. preload/whereHas constraint on `published_at`)
|
|
121
|
+
// still routes through the property-keyed prepare/date maps — mirrors
|
|
122
|
+
// BaseRepository.#applyPrepare.
|
|
123
|
+
const byDbName = new Map();
|
|
124
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
125
|
+
if (col.prepare)
|
|
126
|
+
prepares.set(col.propertyKey, col.prepare);
|
|
127
|
+
byDbName.set(col.columnName ?? camelToSnake(col.propertyKey), col.propertyKey);
|
|
128
|
+
}
|
|
129
|
+
const dateCols = getDateColumnConfig(entityClass);
|
|
130
|
+
return (key, value) => {
|
|
131
|
+
const prop = byDbName.get(key) ?? key;
|
|
132
|
+
const p = prepares.get(prop);
|
|
133
|
+
// Query-builder value transform — no model instance, but the attribute is
|
|
134
|
+
// known (Adonis Lucid signature: value, attribute, model).
|
|
135
|
+
if (p)
|
|
136
|
+
return p(value, prop, undefined);
|
|
137
|
+
if (dateCols[prop] && value != null) {
|
|
138
|
+
if (value instanceof Date)
|
|
139
|
+
return value.toISOString();
|
|
140
|
+
return dateTimeAtlasAdapter.prepare(value);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Structural (cross-realm-safe) check for a value exposing `toISO()` — a Chronos/Luxon DateTime. */
|
|
146
|
+
function joinValueHasToISO(v) {
|
|
147
|
+
return (typeof v === "object" &&
|
|
148
|
+
v !== null &&
|
|
149
|
+
"toISO" in v &&
|
|
150
|
+
typeof v.toISO === "function");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Universal type-lowering for a JOIN `onVal`/`andOnVal`/`orOnVal` bound value:
|
|
154
|
+
* `Date`/`DateTime` → ISO string. Unlike the model value-preparer this applies NO
|
|
155
|
+
* column-specific `@Column({ prepare })` adapter, so a FOREIGN join column can't
|
|
156
|
+
* borrow the root model's adapter for a same-named column on a different table
|
|
157
|
+
* (Knex binds join values model-agnostically; we add only safe universal
|
|
158
|
+
* serialization so a DateTime still lowers to ISO like `where()`).
|
|
159
|
+
*/
|
|
160
|
+
function lowerJoinValue(value) {
|
|
161
|
+
if (value instanceof Date)
|
|
162
|
+
return value.toISOString();
|
|
163
|
+
if (joinValueHasToISO(value))
|
|
164
|
+
return value.toISO();
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Does a join column's table reference (`ref`) denote the root model's own table
|
|
169
|
+
* (`modelTable`)? The match is ASYMMETRIC: a reference may OMIT the schema the
|
|
170
|
+
* model declares (default schema) — `orders` matches a `public.orders` model — but
|
|
171
|
+
* it may NOT ADD qualification the model doesn't claim. So a `public.orders` model
|
|
172
|
+
* accepts `orders.col`, while an unqualified `orders` model rejects
|
|
173
|
+
* `archive.orders.col` (a different schema the model never named) — keeping it
|
|
174
|
+
* foreign so the root model's `@Column` adapters aren't misapplied to it.
|
|
175
|
+
*/
|
|
176
|
+
function sameTableRef(ref, modelTable) {
|
|
177
|
+
const rs = ref.split(".");
|
|
178
|
+
const ms = modelTable.split(".");
|
|
179
|
+
// The reference cannot be MORE qualified than the model (it can only drop the
|
|
180
|
+
// schema, never assert a new one) — otherwise treat it as a foreign table.
|
|
181
|
+
if (rs.length > ms.length)
|
|
182
|
+
return false;
|
|
183
|
+
for (let i = 1; i <= rs.length; i++) {
|
|
184
|
+
if (rs[rs.length - i] !== ms[ms.length - i])
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
45
189
|
/** Set an empty relation value on every parent and return no related rows. */
|
|
46
190
|
function assignEmptyRelation(entities, relationName, single) {
|
|
47
191
|
for (const e of entities)
|
|
@@ -68,10 +212,12 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
|
|
|
68
212
|
return throughToParent;
|
|
69
213
|
}
|
|
70
214
|
/**
|
|
71
|
-
* Process-wide strict mode flag. When enabled, `whereRaw()
|
|
72
|
-
* throw unconditionally — forcing every
|
|
73
|
-
* `whereExpr()` / `joinOn()` /
|
|
74
|
-
*
|
|
215
|
+
* Process-wide strict mode flag. When enabled, `whereRaw()`, `joinRaw()`,
|
|
216
|
+
* `havingRaw()` and the repository's `raw()` throw unconditionally — forcing every
|
|
217
|
+
* call site to use the typed `whereExpr()` / `joinOn()` / `having()` / structured
|
|
218
|
+
* builder paths. The connection-level `db.query()` / `db.execute()` stay available
|
|
219
|
+
* as the explicit, parameterised break-glass. Intended for prod hardening on apps
|
|
220
|
+
* that can't audit every call site manually.
|
|
75
221
|
*
|
|
76
222
|
* Enable via:
|
|
77
223
|
* - `setAtlasStrictMode(true)` at app bootstrap
|
|
@@ -82,7 +228,7 @@ function buildThroughToParent(throughRows, secondLocal, firstKey, err) {
|
|
|
82
228
|
* `__internal: true` flag on the call — not exposed in the public types.
|
|
83
229
|
*/
|
|
84
230
|
let atlasStrictMode;
|
|
85
|
-
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw throw in user code. */
|
|
231
|
+
/** Enable or disable Atlas strict mode. When enabled, whereRaw/joinRaw/havingRaw throw in user code. */
|
|
86
232
|
export function setAtlasStrictMode(enabled) {
|
|
87
233
|
atlasStrictMode = enabled;
|
|
88
234
|
}
|
|
@@ -120,14 +266,38 @@ export class Paginator {
|
|
|
120
266
|
meta;
|
|
121
267
|
#baseUrl;
|
|
122
268
|
#queryString = {};
|
|
123
|
-
|
|
269
|
+
#metaKeys;
|
|
270
|
+
constructor(items, base, metaKeys) {
|
|
124
271
|
this.items = items;
|
|
125
272
|
const lastPage = Math.max(1, Math.ceil(base.total / base.perPage));
|
|
126
273
|
this.meta = { ...base, lastPage, firstPage: 1 };
|
|
274
|
+
this.#metaKeys = metaKeys;
|
|
127
275
|
}
|
|
128
276
|
all() {
|
|
129
277
|
return this.items;
|
|
130
278
|
}
|
|
279
|
+
// Top-level numeric accessors (AdonisJS Lucid paginator) — the same values
|
|
280
|
+
// carried in `.meta`, exposed directly on the instance for convenience.
|
|
281
|
+
/** Total row count across all pages. */
|
|
282
|
+
get total() {
|
|
283
|
+
return this.meta.total;
|
|
284
|
+
}
|
|
285
|
+
/** Rows per page. */
|
|
286
|
+
get perPage() {
|
|
287
|
+
return this.meta.perPage;
|
|
288
|
+
}
|
|
289
|
+
/** The current page number. */
|
|
290
|
+
get currentPage() {
|
|
291
|
+
return this.meta.currentPage;
|
|
292
|
+
}
|
|
293
|
+
/** The last page number. */
|
|
294
|
+
get lastPage() {
|
|
295
|
+
return this.meta.lastPage;
|
|
296
|
+
}
|
|
297
|
+
/** The first page number (always 1). */
|
|
298
|
+
get firstPage() {
|
|
299
|
+
return this.meta.firstPage;
|
|
300
|
+
}
|
|
131
301
|
/** True when there is more than one page of results (AdonisJS `hasPages`). */
|
|
132
302
|
get hasPages() {
|
|
133
303
|
return this.meta.lastPage > 1;
|
|
@@ -145,7 +315,9 @@ export class Paginator {
|
|
|
145
315
|
picked[f] = item[f];
|
|
146
316
|
return picked;
|
|
147
317
|
});
|
|
148
|
-
|
|
318
|
+
// Same meta shape as toJSON — snake_case keys via the naming strategy's
|
|
319
|
+
// paginationMetaKeys, plus page URLs when a baseUrl is set.
|
|
320
|
+
return { data, meta: this.#buildMeta() };
|
|
149
321
|
}
|
|
150
322
|
baseUrl(url) {
|
|
151
323
|
this.#baseUrl = url;
|
|
@@ -155,26 +327,70 @@ export class Paginator {
|
|
|
155
327
|
this.#queryString = qs;
|
|
156
328
|
return this;
|
|
157
329
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Build the URL for a page number, honouring `baseUrl` + `queryString`.
|
|
332
|
+
* Returns `''` when no `baseUrl` was set (AdonisJS `getUrl`).
|
|
333
|
+
*/
|
|
334
|
+
getUrl(page) {
|
|
335
|
+
if (!this.#baseUrl)
|
|
336
|
+
return "";
|
|
337
|
+
const params = new URLSearchParams();
|
|
338
|
+
for (const [k, v] of Object.entries(this.#queryString))
|
|
339
|
+
params.set(k, String(v));
|
|
340
|
+
params.set("page", String(page));
|
|
341
|
+
return `${this.#baseUrl}?${params.toString()}`;
|
|
342
|
+
}
|
|
343
|
+
/** URL of the next page, or `null` when on the last page (AdonisJS `getNextPageUrl`). */
|
|
344
|
+
getNextPageUrl() {
|
|
345
|
+
return this.hasMorePages ? this.getUrl(this.meta.currentPage + 1) : null;
|
|
346
|
+
}
|
|
347
|
+
/** URL of the previous page, or `null` when on the first page (AdonisJS `getPreviousPageUrl`). */
|
|
348
|
+
getPreviousPageUrl() {
|
|
349
|
+
return this.meta.currentPage > 1
|
|
350
|
+
? this.getUrl(this.meta.currentPage - 1)
|
|
351
|
+
: null;
|
|
352
|
+
}
|
|
353
|
+
/** URLs for an inclusive page range, clamped to `[1, lastPage]` (AdonisJS `getUrlsForRange`). */
|
|
354
|
+
getUrlsForRange(start, end) {
|
|
355
|
+
const lo = Math.max(1, start);
|
|
356
|
+
const hi = Math.min(this.meta.lastPage, end);
|
|
357
|
+
const range = [];
|
|
358
|
+
for (let page = lo; page <= hi; page++)
|
|
359
|
+
range.push({
|
|
360
|
+
page,
|
|
361
|
+
url: this.getUrl(page),
|
|
362
|
+
isActive: page === this.meta.currentPage,
|
|
363
|
+
});
|
|
364
|
+
return range;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Build the serialized `meta` object: the raw camelCase fields plus page URLs
|
|
368
|
+
* (when a baseUrl is set), remapped through the naming strategy's
|
|
369
|
+
* `paginationMetaKeys` — snake_case by default (AdonisJS Lucid parity).
|
|
370
|
+
* Shared by {@link toJSON} and {@link serialize} so they never diverge.
|
|
371
|
+
*/
|
|
372
|
+
#buildMeta() {
|
|
373
|
+
const raw = { ...this.meta };
|
|
162
374
|
if (this.#baseUrl) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
375
|
+
raw.firstPageUrl = this.getUrl(1);
|
|
376
|
+
raw.lastPageUrl = this.getUrl(this.meta.lastPage);
|
|
377
|
+
const next = this.getNextPageUrl();
|
|
378
|
+
const prev = this.getPreviousPageUrl();
|
|
379
|
+
if (next)
|
|
380
|
+
raw.nextPageUrl = next;
|
|
381
|
+
if (prev)
|
|
382
|
+
raw.previousPageUrl = prev;
|
|
383
|
+
}
|
|
384
|
+
const keys = this.#metaKeys;
|
|
385
|
+
if (!keys)
|
|
386
|
+
return raw;
|
|
387
|
+
const meta = {};
|
|
388
|
+
for (const [k, v] of Object.entries(raw))
|
|
389
|
+
meta[keys[k] ?? k] = v;
|
|
390
|
+
return meta;
|
|
391
|
+
}
|
|
392
|
+
toJSON() {
|
|
393
|
+
return { data: this.items, meta: this.#buildMeta() };
|
|
178
394
|
}
|
|
179
395
|
}
|
|
180
396
|
/** Safe deep-clone for clause containers. `structuredClone` handles the shapes we use. */
|
|
@@ -193,20 +409,46 @@ export class ModelQuery {
|
|
|
193
409
|
#orderBys = [];
|
|
194
410
|
#select = ["*"];
|
|
195
411
|
#limit;
|
|
412
|
+
/** Top-N-per-parent limit for a has-many preload (Lucid `groupLimit`). */
|
|
413
|
+
#groupLimit;
|
|
414
|
+
/** ORDER BY for the per-parent window (Lucid `groupOrderBy`). */
|
|
415
|
+
#groupOrderBy;
|
|
196
416
|
#offset;
|
|
197
417
|
#preloads = new Map();
|
|
418
|
+
// Per-query row transformers (Adonis Lucid `rowTransformer`). Stored via a
|
|
419
|
+
// method-signature wrapper (not a bare `(row: T) => void` property) so T stays
|
|
420
|
+
// in a bivariant position — a function-typed property would force ModelQuery<T>
|
|
421
|
+
// invariant and break the `this: ModelClass<T>` bound on every static finder.
|
|
422
|
+
#rowTransformers = [];
|
|
198
423
|
/** Correlated subquery projections (withCount / withAggregate). */
|
|
199
424
|
#selectSubqueries = [];
|
|
425
|
+
/** Raw / subquery SELECT fragments carrying their own params (Lucid `select(raw)`, `select(subquery.as())`). */
|
|
426
|
+
#selectRaw = [];
|
|
427
|
+
/** Caller-facing statement timeout in ms (Lucid `timeout(ms)`), applied via a race in exec. */
|
|
428
|
+
#timeoutMs;
|
|
429
|
+
/** `timeout(ms, { cancel: true })` — also apply a SERVER-side statement timeout. */
|
|
430
|
+
#cancelTimeout = false;
|
|
431
|
+
/** Columns from a chainable `.returning(...)` on a lazy DML builder. */
|
|
432
|
+
#dmlReturning = [];
|
|
200
433
|
/** Alias stored by `.as()` — consumed when this query is used as a withCount/withAggregate sub-builder. */
|
|
201
434
|
#subqueryAlias;
|
|
202
435
|
/** Raw JOIN fragments — Story 29.4. */
|
|
203
436
|
#joins = [];
|
|
204
|
-
/** Row lock mode — Story 30.8. */
|
|
437
|
+
/** Row lock base mode — Story 30.8. */
|
|
205
438
|
#lockMode = null;
|
|
439
|
+
/** Optional lock modifier (SKIP LOCKED / NOWAIT), composed onto {@link #lockMode}. */
|
|
440
|
+
#lockModifier = null;
|
|
441
|
+
/** Context threaded onto every hydrated instance's `$sideloaded` — AdonisJS `sideload`. */
|
|
442
|
+
#sideloaded = null;
|
|
206
443
|
/** Per-query debug flag — Story 29.11. */
|
|
207
444
|
#debugFlag = false;
|
|
445
|
+
/** Metadata attached to the db:query event (Adonis Lucid `reporterData`). */
|
|
446
|
+
#reporterData;
|
|
447
|
+
/** SQL comments prepended to the compiled query (Lucid/Knex `comment`). */
|
|
448
|
+
#comments = [];
|
|
208
449
|
/** Distinct flag — Story 29.5. */
|
|
209
450
|
#distinct = false;
|
|
451
|
+
#distinctOn = [];
|
|
210
452
|
/** GROUP BY columns (Lucid parity). */
|
|
211
453
|
#groupBy = [];
|
|
212
454
|
/** HAVING clauses — structured + raw (Lucid parity). */
|
|
@@ -217,9 +459,19 @@ export class ModelQuery {
|
|
|
217
459
|
#unions = [];
|
|
218
460
|
/** m2m pivot-table WHERE constraints — applied to the pivot lookup, not the related query. */
|
|
219
461
|
#pivotWheres = [];
|
|
462
|
+
/** Extra pivot columns requested at query time (Lucid `pivotColumns([...])`). */
|
|
463
|
+
#extraPivotColumns = [];
|
|
464
|
+
/**
|
|
465
|
+
* Deferred builder for a lazy m2m `related().query()` EXISTS predicate. Set by
|
|
466
|
+
* the relation proxy's scoped query; invoked at `#buildSpec()` time with the
|
|
467
|
+
* CURRENT `#pivotWheres` so `.wherePivot()` calls added AFTER the proxy handed
|
|
468
|
+
* back the query still fold into the pivot EXISTS (a flat `whereRaw` at proxy
|
|
469
|
+
* time would freeze the predicate before those calls and silently drop them).
|
|
470
|
+
*/
|
|
471
|
+
#pivotExists;
|
|
220
472
|
/** SQL dialect for compilation — inherited from the owning BaseRepository. */
|
|
221
473
|
#dialect;
|
|
222
|
-
constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect()) {
|
|
474
|
+
constructor(tableName, db, hydrateFn, entityClass, resolveColumn = (c) => c, softDeletes = false, dialect = getAtlasDialect(), prepareValue = (_c, v) => v, onDomainEvents) {
|
|
223
475
|
this.#tableName = tableName;
|
|
224
476
|
this.#db = db;
|
|
225
477
|
this.#hydrateFn = hydrateFn;
|
|
@@ -227,7 +479,13 @@ export class ModelQuery {
|
|
|
227
479
|
this.#resolveColumn = resolveColumn;
|
|
228
480
|
this.#softDeletes = softDeletes;
|
|
229
481
|
this.#dialect = dialect;
|
|
482
|
+
this.#prepareValue = prepareValue;
|
|
483
|
+
this.#onDomainEvents = onDomainEvents;
|
|
230
484
|
}
|
|
485
|
+
/** @see ValuePreparer — identity unless the owning repository wires prepare in. */
|
|
486
|
+
#prepareValue;
|
|
487
|
+
/** Domain-event bus threaded from the owning repository — propagated to preload repos. */
|
|
488
|
+
#onDomainEvents;
|
|
231
489
|
/** Include soft-deleted rows in the result (default behavior excludes them). */
|
|
232
490
|
withTrashed() {
|
|
233
491
|
this.#softScope = "with-trashed";
|
|
@@ -238,6 +496,24 @@ export class ModelQuery {
|
|
|
238
496
|
this.#softScope = "only-trashed";
|
|
239
497
|
return this;
|
|
240
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Apply the first `[guard, callback]` whose guard is truthy (Adonis Lucid
|
|
501
|
+
* `match`); a trailing bare callback is the default, run when none matched.
|
|
502
|
+
*/
|
|
503
|
+
match(...blocks) {
|
|
504
|
+
for (const block of blocks) {
|
|
505
|
+
if (typeof block === "function") {
|
|
506
|
+
block(this);
|
|
507
|
+
return this;
|
|
508
|
+
}
|
|
509
|
+
const [guard, callback] = block;
|
|
510
|
+
if (guard) {
|
|
511
|
+
callback(this);
|
|
512
|
+
return this;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return this;
|
|
516
|
+
}
|
|
241
517
|
/**
|
|
242
518
|
* Eager-load a relation (AdonisJS-style).
|
|
243
519
|
* Relations are never loaded automatically — you must call .preload() explicitly.
|
|
@@ -250,13 +526,97 @@ export class ModelQuery {
|
|
|
250
526
|
this.#preloads.set(relationName, callback);
|
|
251
527
|
return this;
|
|
252
528
|
}
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Eager-load a relation only if it hasn't been registered yet (Adonis Lucid
|
|
531
|
+
* `preloadOnce`) — a later `preload`/`preloadOnce` for the same relation is
|
|
532
|
+
* ignored, so a shared scope can safely add a preload without clobbering one
|
|
533
|
+
* the caller already set.
|
|
534
|
+
*/
|
|
535
|
+
preloadOnce(relationName, callback) {
|
|
536
|
+
if (!this.#preloads.has(relationName)) {
|
|
537
|
+
this.#preloads.set(relationName, callback);
|
|
538
|
+
}
|
|
539
|
+
return this;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Register a callback run for every hydrated instance after loading (preloads
|
|
543
|
+
* included) but before the query resolves (Adonis Lucid `rowTransformer`). The
|
|
544
|
+
* callback mutates the instance in place — decorate rows with per-query
|
|
545
|
+
* computed values without a model hook/accessor. Multiple transformers run in
|
|
546
|
+
* registration order.
|
|
547
|
+
*/
|
|
548
|
+
rowTransformer(callback) {
|
|
549
|
+
this.#rowTransformers.push({ run: callback });
|
|
258
550
|
return this;
|
|
259
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Select columns (default: `*`). The model query builder extends the database
|
|
554
|
+
* query builder's `select` surface (Lucid parity): bare names, multiple args,
|
|
555
|
+
* a comma-separated string, an array, an `{ alias: 'column' }` object map, a
|
|
556
|
+
* `db.raw(...)` fragment, and a named subquery — `select(subquery.as('x'))`.
|
|
557
|
+
* Plain columns are resolved to their DB column (honouring `@Column`).
|
|
558
|
+
*/
|
|
559
|
+
select(...columns) {
|
|
560
|
+
const plain = [];
|
|
561
|
+
for (const col of columns) {
|
|
562
|
+
if (typeof col === "string") {
|
|
563
|
+
plain.push(...col.split(",").map((c) => c.trim()));
|
|
564
|
+
}
|
|
565
|
+
else if (Array.isArray(col)) {
|
|
566
|
+
plain.push(...col);
|
|
567
|
+
}
|
|
568
|
+
else if (col instanceof RawSql) {
|
|
569
|
+
// Lucid `select(db.raw(sql, bindings))` — verbatim fragment + params.
|
|
570
|
+
this.#selectRaw.push({ sql: col.sql, params: [...col.params] });
|
|
571
|
+
}
|
|
572
|
+
else if (col instanceof _a) {
|
|
573
|
+
// Lucid `select(subquery.as('alias'))` — a correlated subquery column.
|
|
574
|
+
const alias = col.#subqueryAlias;
|
|
575
|
+
if (!alias) {
|
|
576
|
+
throw new Error("select(subquery) requires the subquery to be named with .as('alias')");
|
|
577
|
+
}
|
|
578
|
+
const { sql, params } = col.#compiledNative();
|
|
579
|
+
this.#selectRaw.push({
|
|
580
|
+
sql: `(${sql}) AS ${this.#quoteAliasName(alias)}`,
|
|
581
|
+
params,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
for (const [alias, expr] of Object.entries(col)) {
|
|
586
|
+
plain.push(`${expr} AS ${alias}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (plain.length > 0) {
|
|
591
|
+
this.#select = plain.map((c) => this.#resolveSelect(c));
|
|
592
|
+
}
|
|
593
|
+
return this;
|
|
594
|
+
}
|
|
595
|
+
/** Validate + dialect-quote a bare alias identifier. */
|
|
596
|
+
#quoteAliasName(alias) {
|
|
597
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
|
|
598
|
+
throw new Error(`Invalid alias '${alias}' — expected a bare identifier.`);
|
|
599
|
+
}
|
|
600
|
+
const q = this.#dialect === "mysql" ? "`" : '"';
|
|
601
|
+
return `${q}${alias}${q}`;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Resolve a bare model-property select/returning target to its DB column
|
|
605
|
+
* (honouring `@Column({ columnName })`), leaving expressions / aliases /
|
|
606
|
+
* qualified names / `*` untouched. A bare identifier IS validated through the
|
|
607
|
+
* column resolver — so a typo like `select('lable')` raises the same Atlas
|
|
608
|
+
* error as `where`/`orderBy`, rather than reaching the DB.
|
|
609
|
+
*/
|
|
610
|
+
#resolveSelect(col) {
|
|
611
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(col))
|
|
612
|
+
return this.#resolveColumn(col);
|
|
613
|
+
// `col as alias` — resolve the (bare) column part to its DB name, keep the
|
|
614
|
+
// alias verbatim, so `select('label as name')` honours a columnName override.
|
|
615
|
+
const aliased = col.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
|
616
|
+
if (aliased)
|
|
617
|
+
return `${this.#resolveColumn(aliased[1])} AS ${aliased[2]}`;
|
|
618
|
+
return col;
|
|
619
|
+
}
|
|
260
620
|
where(columnOrCb, operatorOrValue, value) {
|
|
261
621
|
if (typeof columnOrCb === "function") {
|
|
262
622
|
this.#wheres.push(this.#buildGroup("and", columnOrCb));
|
|
@@ -289,33 +649,123 @@ export class ModelQuery {
|
|
|
289
649
|
});
|
|
290
650
|
return this;
|
|
291
651
|
}
|
|
292
|
-
|
|
293
|
-
|
|
652
|
+
andWhere(columnOrCb, operatorOrValue, value) {
|
|
653
|
+
// The 2-arg overload must not forward a phantom third argument: `where`
|
|
654
|
+
// switches on `value === undefined` to tell `(col, value)` from
|
|
655
|
+
// `(col, operator, value)`.
|
|
656
|
+
return typeof columnOrCb === "function"
|
|
657
|
+
? this.where(columnOrCb)
|
|
658
|
+
: value === undefined
|
|
659
|
+
? this.where(columnOrCb, operatorOrValue)
|
|
660
|
+
: this.where(columnOrCb, operatorOrValue, value);
|
|
661
|
+
}
|
|
662
|
+
/** Alias of {@link whereNot} (Lucid parity). */
|
|
663
|
+
andWhereNot(column, value) {
|
|
664
|
+
return this.whereNot(column, value);
|
|
665
|
+
}
|
|
666
|
+
/** Alias of {@link whereIn} (Lucid parity). */
|
|
667
|
+
andWhereIn(column, values) {
|
|
668
|
+
return this.whereIn(column, values);
|
|
669
|
+
}
|
|
670
|
+
/** Alias of {@link whereNotIn} (Lucid parity). */
|
|
671
|
+
andWhereNotIn(column, values) {
|
|
672
|
+
return this.whereNotIn(column, values);
|
|
673
|
+
}
|
|
674
|
+
/** Alias of {@link whereNull} (Lucid parity). */
|
|
675
|
+
andWhereNull(column) {
|
|
676
|
+
return this.whereNull(column);
|
|
677
|
+
}
|
|
678
|
+
/** Alias of {@link whereNotNull} (Lucid parity). */
|
|
679
|
+
andWhereNotNull(column) {
|
|
680
|
+
return this.whereNotNull(column);
|
|
681
|
+
}
|
|
682
|
+
/** Alias of {@link whereBetween} (Lucid parity). */
|
|
683
|
+
andWhereBetween(column, range) {
|
|
684
|
+
return this.whereBetween(column, range);
|
|
685
|
+
}
|
|
686
|
+
/** Alias of {@link whereNotBetween} (Lucid parity). */
|
|
687
|
+
andWhereNotBetween(column, range) {
|
|
688
|
+
return this.whereNotBetween(column, range);
|
|
689
|
+
}
|
|
690
|
+
/** Alias of {@link whereLike} (Lucid parity). */
|
|
691
|
+
andWhereLike(column, pattern) {
|
|
692
|
+
return this.whereLike(column, pattern);
|
|
693
|
+
}
|
|
694
|
+
/** Alias of {@link whereILike} (Lucid parity). */
|
|
695
|
+
andWhereILike(column, pattern) {
|
|
696
|
+
return this.whereILike(column, pattern);
|
|
697
|
+
}
|
|
698
|
+
/** Alias of {@link whereColumn} (Lucid parity). */
|
|
699
|
+
andWhereColumn(left, operator, right) {
|
|
700
|
+
return this.whereColumn(left, operator, right);
|
|
701
|
+
}
|
|
702
|
+
whereNot(columnOrCbOrObj, operatorOrValue, value) {
|
|
703
|
+
if (typeof columnOrCbOrObj === "function") {
|
|
704
|
+
this.#wheres.push({
|
|
705
|
+
...this.#buildGroup("and", columnOrCbOrObj),
|
|
706
|
+
negated: true,
|
|
707
|
+
});
|
|
708
|
+
return this;
|
|
709
|
+
}
|
|
710
|
+
if (typeof columnOrCbOrObj === "object") {
|
|
711
|
+
for (const [col, val] of Object.entries(columnOrCbOrObj)) {
|
|
712
|
+
this.#wheres.push({
|
|
713
|
+
type: "and",
|
|
714
|
+
column: this.#resolveColumn(col),
|
|
715
|
+
operator: "!=",
|
|
716
|
+
value: this.#prep(col, val),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return this;
|
|
720
|
+
}
|
|
721
|
+
if (value === undefined) {
|
|
722
|
+
this.#wheres.push({
|
|
723
|
+
type: "and",
|
|
724
|
+
column: this.#resolveColumn(columnOrCbOrObj),
|
|
725
|
+
operator: "!=",
|
|
726
|
+
value: this.#prep(columnOrCbOrObj, operatorOrValue),
|
|
727
|
+
});
|
|
728
|
+
return this;
|
|
729
|
+
}
|
|
730
|
+
// (column, operator, value): NOT (col <op> value) via a negated group so any
|
|
731
|
+
// operator negates correctly without an operator-inversion table.
|
|
732
|
+
const col = columnOrCbOrObj;
|
|
733
|
+
const op = operatorOrValue;
|
|
294
734
|
this.#wheres.push({
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
operator: "!=",
|
|
298
|
-
value,
|
|
735
|
+
...this.#buildGroup("and", (q) => q.where(col, op, value)),
|
|
736
|
+
negated: true,
|
|
299
737
|
});
|
|
300
738
|
return this;
|
|
301
739
|
}
|
|
302
|
-
|
|
303
|
-
|
|
740
|
+
whereIn(columnOrColumns, source) {
|
|
741
|
+
// Tuple form (Lucid `whereIn(['id','email'], [[1,'a@b.com']])`).
|
|
742
|
+
if (Array.isArray(columnOrColumns)) {
|
|
743
|
+
const cols = columnOrColumns.map((c) => this.#resolveColumn(c));
|
|
744
|
+
const rows = (Array.isArray(source) ? source : []).map((r) => (Array.isArray(r) ? r : [r]).map((v, j) => this.#prep(cols[j] ?? "", v)));
|
|
745
|
+
this.#wheres.push({
|
|
746
|
+
type: "and",
|
|
747
|
+
kind: "inTuple",
|
|
748
|
+
columns: cols,
|
|
749
|
+
rows,
|
|
750
|
+
negated: false,
|
|
751
|
+
});
|
|
752
|
+
return this;
|
|
753
|
+
}
|
|
304
754
|
if (source instanceof _a) {
|
|
305
755
|
this.#wheres.push({
|
|
306
756
|
type: "and",
|
|
307
757
|
kind: "inSub",
|
|
308
758
|
negated: false,
|
|
309
|
-
column: this.#resolveColumn(
|
|
759
|
+
column: this.#resolveColumn(columnOrColumns),
|
|
310
760
|
subquery: source.#buildSpec(),
|
|
311
761
|
});
|
|
312
762
|
return this;
|
|
313
763
|
}
|
|
314
764
|
this.#wheres.push({
|
|
315
765
|
type: "and",
|
|
316
|
-
column: this.#resolveColumn(
|
|
766
|
+
column: this.#resolveColumn(columnOrColumns),
|
|
317
767
|
operator: "IN",
|
|
318
|
-
value: [...source],
|
|
768
|
+
value: this.#prep(columnOrColumns, [...source]),
|
|
319
769
|
});
|
|
320
770
|
return this;
|
|
321
771
|
}
|
|
@@ -335,7 +785,7 @@ export class ModelQuery {
|
|
|
335
785
|
type: "and",
|
|
336
786
|
column: this.#resolveColumn(column),
|
|
337
787
|
operator: "NOT IN",
|
|
338
|
-
value: [...source],
|
|
788
|
+
value: this.#prep(column, [...source]),
|
|
339
789
|
});
|
|
340
790
|
return this;
|
|
341
791
|
}
|
|
@@ -345,7 +795,7 @@ export class ModelQuery {
|
|
|
345
795
|
type: "and",
|
|
346
796
|
column: this.#resolveColumn(column),
|
|
347
797
|
operator: "BETWEEN",
|
|
348
|
-
value: [...range],
|
|
798
|
+
value: this.#prep(column, [...range]),
|
|
349
799
|
});
|
|
350
800
|
return this;
|
|
351
801
|
}
|
|
@@ -355,7 +805,7 @@ export class ModelQuery {
|
|
|
355
805
|
type: "and",
|
|
356
806
|
column: this.#resolveColumn(column),
|
|
357
807
|
operator: "NOT BETWEEN",
|
|
358
|
-
value: [...range],
|
|
808
|
+
value: this.#prep(column, [...range]),
|
|
359
809
|
});
|
|
360
810
|
return this;
|
|
361
811
|
}
|
|
@@ -412,7 +862,7 @@ export class ModelQuery {
|
|
|
412
862
|
type: "or",
|
|
413
863
|
column: this.#resolveColumn(column),
|
|
414
864
|
operator: "!=",
|
|
415
|
-
value,
|
|
865
|
+
value: this.#prep(column, value),
|
|
416
866
|
});
|
|
417
867
|
return this;
|
|
418
868
|
}
|
|
@@ -432,7 +882,7 @@ export class ModelQuery {
|
|
|
432
882
|
type: "or",
|
|
433
883
|
column: this.#resolveColumn(column),
|
|
434
884
|
operator: "IN",
|
|
435
|
-
value: [...source],
|
|
885
|
+
value: this.#prep(column, [...source]),
|
|
436
886
|
});
|
|
437
887
|
return this;
|
|
438
888
|
}
|
|
@@ -452,7 +902,7 @@ export class ModelQuery {
|
|
|
452
902
|
type: "or",
|
|
453
903
|
column: this.#resolveColumn(column),
|
|
454
904
|
operator: "NOT IN",
|
|
455
|
-
value: [...source],
|
|
905
|
+
value: this.#prep(column, [...source]),
|
|
456
906
|
});
|
|
457
907
|
return this;
|
|
458
908
|
}
|
|
@@ -462,7 +912,7 @@ export class ModelQuery {
|
|
|
462
912
|
type: "or",
|
|
463
913
|
column: this.#resolveColumn(column),
|
|
464
914
|
operator: "BETWEEN",
|
|
465
|
-
value: [...range],
|
|
915
|
+
value: this.#prep(column, [...range]),
|
|
466
916
|
});
|
|
467
917
|
return this;
|
|
468
918
|
}
|
|
@@ -472,7 +922,7 @@ export class ModelQuery {
|
|
|
472
922
|
type: "or",
|
|
473
923
|
column: this.#resolveColumn(column),
|
|
474
924
|
operator: "NOT BETWEEN",
|
|
475
|
-
value: [...range],
|
|
925
|
+
value: this.#prep(column, [...range]),
|
|
476
926
|
});
|
|
477
927
|
return this;
|
|
478
928
|
}
|
|
@@ -535,15 +985,47 @@ export class ModelQuery {
|
|
|
535
985
|
* Not exported from the package barrel — only accessible inside the Atlas
|
|
536
986
|
* codebase via direct ModelQuery instance access.
|
|
537
987
|
*/
|
|
538
|
-
#pushWhereRaw(sql, bindings = []) {
|
|
988
|
+
#pushWhereRaw(sql, bindings = [], type = "and") {
|
|
539
989
|
this.#wheres.push({
|
|
540
|
-
type
|
|
990
|
+
type,
|
|
541
991
|
kind: "raw",
|
|
542
992
|
sql,
|
|
543
993
|
bindings: [...bindings],
|
|
544
994
|
});
|
|
545
995
|
return this;
|
|
546
996
|
}
|
|
997
|
+
/** Alias of {@link whereRaw} (Lucid parity). Subject to the same strict-mode gate. */
|
|
998
|
+
andWhereRaw(sql, bindings = []) {
|
|
999
|
+
return this.whereRaw(sql, bindings);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* `OR <raw fragment>` (Lucid parity).
|
|
1003
|
+
*
|
|
1004
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1005
|
+
* Subject to the same strict-mode gate as {@link whereRaw}.
|
|
1006
|
+
*/
|
|
1007
|
+
orWhereRaw(sql, bindings = []) {
|
|
1008
|
+
this.#assertRawAllowed("orWhereRaw");
|
|
1009
|
+
return this.#pushWhereRaw(sql, bindings, "or");
|
|
1010
|
+
}
|
|
1011
|
+
/** Shared strict-mode gate for the raw WHERE entry points. */
|
|
1012
|
+
#assertRawAllowed(method) {
|
|
1013
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1014
|
+
throw new Error(`${method}() is disabled in Atlas strict mode. ` +
|
|
1015
|
+
"Use whereExpr() or a structured builder method instead. " +
|
|
1016
|
+
"Call setAtlasStrictMode(false) at bootstrap if you truly need raw SQL.");
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Framework-internal: register the deferred m2m EXISTS predicate for a lazy
|
|
1021
|
+
* `related().query()`. The builder is re-invoked on every `#buildSpec()` with
|
|
1022
|
+
* the pivot constraints known at that moment, so `.wherePivot()` added after
|
|
1023
|
+
* the proxy returned still applies. Not exported from the barrel.
|
|
1024
|
+
*/
|
|
1025
|
+
setPivotExistsBuilder(builder) {
|
|
1026
|
+
this.#pivotExists = builder;
|
|
1027
|
+
return this;
|
|
1028
|
+
}
|
|
547
1029
|
whereExpr(column, operatorOrExtra, operatorOrValue, maybeValue) {
|
|
548
1030
|
// 3-arg form: whereExpr(col, op, value)
|
|
549
1031
|
// 4-arg form: whereExpr(col, extraExpr, op, value)
|
|
@@ -562,6 +1044,18 @@ export class ModelQuery {
|
|
|
562
1044
|
if (!hasBalancedParens(extra)) {
|
|
563
1045
|
throw new Error(`whereExpr: extraExpression '${extra}' has unbalanced parentheses. Use whereRaw() if you need more.`);
|
|
564
1046
|
}
|
|
1047
|
+
// The charset blocks comparison/quote symbols, but bare SQL keywords
|
|
1048
|
+
// (OR / AND / IS / NOT / SELECT …) are just letters and would slip
|
|
1049
|
+
// through, letting `extra` alter the predicate's logical structure
|
|
1050
|
+
// (e.g. `whereExpr('total', 'OR active', '>', 0)`). whereExpr is the
|
|
1051
|
+
// SAFE arithmetic alternative to whereRaw, so reject any SQL keyword
|
|
1052
|
+
// token — arithmetic on columns/numbers/functions only.
|
|
1053
|
+
for (const word of extra.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
|
1054
|
+
if (WHEREEXPR_FORBIDDEN_WORDS.has(word.toUpperCase())) {
|
|
1055
|
+
throw new Error(`whereExpr: extraExpression '${extra}' contains the SQL keyword '${word}'. ` +
|
|
1056
|
+
"whereExpr allows arithmetic expressions only (columns, numbers, + - * / , functions). Use whereRaw() for logical/SQL constructs.");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
565
1059
|
// `op` is interpolated raw into the fragment below, so it MUST be
|
|
566
1060
|
// allow-listed — the 3-arg path gets this from the Rust operator
|
|
567
1061
|
// validation, but the raw 4-arg path bypasses Rust and would
|
|
@@ -578,9 +1072,16 @@ export class ModelQuery {
|
|
|
578
1072
|
// the operator against the allow-list above.
|
|
579
1073
|
if (hasExtra) {
|
|
580
1074
|
const q = this.#quote(resolved);
|
|
581
|
-
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1075
|
+
return this.#pushWhereRaw(`${q} ${extra} ${op} ?`, [
|
|
1076
|
+
this.#prep(column, value),
|
|
1077
|
+
]);
|
|
582
1078
|
}
|
|
583
|
-
this.#wheres.push({
|
|
1079
|
+
this.#wheres.push({
|
|
1080
|
+
type: "and",
|
|
1081
|
+
column: resolved,
|
|
1082
|
+
operator: op,
|
|
1083
|
+
value: this.#prep(column, value),
|
|
1084
|
+
});
|
|
584
1085
|
return this;
|
|
585
1086
|
}
|
|
586
1087
|
/**
|
|
@@ -589,6 +1090,214 @@ export class ModelQuery {
|
|
|
589
1090
|
* operator is allow-listed; nothing is bound (it's a column reference, not a
|
|
590
1091
|
* value), which the standard `where`/`whereExpr` value-binding path can't do.
|
|
591
1092
|
*/
|
|
1093
|
+
// ─── EXISTS ───────────────────────────────────────────────
|
|
1094
|
+
//
|
|
1095
|
+
// `whereExists` lived only on the low-level `query/QueryBuilder`, not on the
|
|
1096
|
+
// builder `repo.query()` actually hands back, so it was unreachable from
|
|
1097
|
+
// normal use. The subquery is another `ModelQuery`; correlate it to the
|
|
1098
|
+
// outer table with `whereColumn`:
|
|
1099
|
+
//
|
|
1100
|
+
// userRepo.query().whereExists(
|
|
1101
|
+
// postRepo.query().whereColumn('posts.user_id', '=', 'users.id')
|
|
1102
|
+
// )
|
|
1103
|
+
//
|
|
1104
|
+
// For relation-shaped EXISTS, prefer `whereHas`/`has`, which derive the
|
|
1105
|
+
// join predicate from the relation metadata.
|
|
1106
|
+
/** `WHERE EXISTS (subquery)` (Lucid parity). */
|
|
1107
|
+
whereExists(subquery) {
|
|
1108
|
+
return this.#pushExists("and", false, subquery);
|
|
1109
|
+
}
|
|
1110
|
+
/** Alias of {@link whereExists} (Lucid parity). */
|
|
1111
|
+
andWhereExists(subquery) {
|
|
1112
|
+
return this.#pushExists("and", false, subquery);
|
|
1113
|
+
}
|
|
1114
|
+
/** `OR EXISTS (subquery)` (Lucid parity). */
|
|
1115
|
+
orWhereExists(subquery) {
|
|
1116
|
+
return this.#pushExists("or", false, subquery);
|
|
1117
|
+
}
|
|
1118
|
+
/** `WHERE NOT EXISTS (subquery)` (Lucid parity). */
|
|
1119
|
+
whereNotExists(subquery) {
|
|
1120
|
+
return this.#pushExists("and", true, subquery);
|
|
1121
|
+
}
|
|
1122
|
+
/** Alias of {@link whereNotExists} (Lucid parity). */
|
|
1123
|
+
andWhereNotExists(subquery) {
|
|
1124
|
+
return this.#pushExists("and", true, subquery);
|
|
1125
|
+
}
|
|
1126
|
+
/** `OR NOT EXISTS (subquery)` (Lucid parity). */
|
|
1127
|
+
orWhereNotExists(subquery) {
|
|
1128
|
+
return this.#pushExists("or", true, subquery);
|
|
1129
|
+
}
|
|
1130
|
+
whereJsonPath(column, path, operatorOrValue, value) {
|
|
1131
|
+
return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
|
|
1132
|
+
}
|
|
1133
|
+
andWhereJsonPath(column, path, operatorOrValue, value) {
|
|
1134
|
+
return this.#pushJsonPathArgs("and", column, path, operatorOrValue, value);
|
|
1135
|
+
}
|
|
1136
|
+
orWhereJsonPath(column, path, operatorOrValue, value) {
|
|
1137
|
+
return this.#pushJsonPathArgs("or", column, path, operatorOrValue, value);
|
|
1138
|
+
}
|
|
1139
|
+
/** Resolve the optional-operator JSONPath form, then push (default op `=`). */
|
|
1140
|
+
#pushJsonPathArgs(type, column, path, operatorOrValue, value) {
|
|
1141
|
+
const [operator, val] = value === undefined
|
|
1142
|
+
? ["=", operatorOrValue]
|
|
1143
|
+
: [String(operatorOrValue), value];
|
|
1144
|
+
return this.#pushJson(type, false, "path", column, val, path, String(operator));
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Structural JSON match (Lucid `whereJson`) — the column's JSON must equal
|
|
1148
|
+
* `value` (canonical comparison on Postgres/MySQL). AND is the default.
|
|
1149
|
+
*/
|
|
1150
|
+
whereJson(column, value) {
|
|
1151
|
+
return this.#pushJson("and", false, "equals", column, value);
|
|
1152
|
+
}
|
|
1153
|
+
/** Alias of {@link whereJson} (Lucid `andWhereJson`). */
|
|
1154
|
+
andWhereJson(column, value) {
|
|
1155
|
+
return this.#pushJson("and", false, "equals", column, value);
|
|
1156
|
+
}
|
|
1157
|
+
/** OR form of {@link whereJson} (Lucid `orWhereJson`). */
|
|
1158
|
+
orWhereJson(column, value) {
|
|
1159
|
+
return this.#pushJson("or", false, "equals", column, value);
|
|
1160
|
+
}
|
|
1161
|
+
/** WHERE NOT structural JSON match (Lucid `whereNotJson`). */
|
|
1162
|
+
whereNotJson(column, value) {
|
|
1163
|
+
return this.#pushJson("and", true, "equals", column, value);
|
|
1164
|
+
}
|
|
1165
|
+
/** Alias of {@link whereNotJson} (Lucid `andWhereNotJson`). */
|
|
1166
|
+
andWhereNotJson(column, value) {
|
|
1167
|
+
return this.#pushJson("and", true, "equals", column, value);
|
|
1168
|
+
}
|
|
1169
|
+
/** OR NOT structural JSON match (Lucid `orWhereNotJson`). */
|
|
1170
|
+
orWhereNotJson(column, value) {
|
|
1171
|
+
return this.#pushJson("or", true, "equals", column, value);
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* `WHERE <col> @> ?` — the JSON column contains `value` (Lucid/Knex
|
|
1175
|
+
* `whereJsonSupersetOf`). `value` is any JSON-serialisable value.
|
|
1176
|
+
*
|
|
1177
|
+
* Postgres and MySQL only — SQLite has no JSON containment operator and the
|
|
1178
|
+
* compiler raises `E_UNSUPPORTED` there.
|
|
1179
|
+
*/
|
|
1180
|
+
whereJsonSupersetOf(column, value) {
|
|
1181
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1182
|
+
}
|
|
1183
|
+
/** Alias of {@link whereJsonSupersetOf} (Lucid parity). */
|
|
1184
|
+
andWhereJsonSupersetOf(column, value) {
|
|
1185
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1186
|
+
}
|
|
1187
|
+
/** `OR <col> @> ?` (Lucid parity). See {@link whereJsonSupersetOf}. */
|
|
1188
|
+
orWhereJsonSupersetOf(column, value) {
|
|
1189
|
+
return this.#pushJson("or", false, "superset", column, value);
|
|
1190
|
+
}
|
|
1191
|
+
/** `WHERE NOT (<col> @> ?)` (Lucid parity). */
|
|
1192
|
+
whereNotJsonSupersetOf(column, value) {
|
|
1193
|
+
return this.#pushJson("and", true, "superset", column, value);
|
|
1194
|
+
}
|
|
1195
|
+
/** `OR NOT (<col> @> ?)` (Lucid parity). */
|
|
1196
|
+
orWhereNotJsonSupersetOf(column, value) {
|
|
1197
|
+
return this.#pushJson("or", true, "superset", column, value);
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* `WHERE <col> <@ ?` — the JSON column is contained in `value` (Lucid/Knex
|
|
1201
|
+
* `whereJsonSubsetOf`). Postgres/MySQL only; see {@link whereJsonSupersetOf}.
|
|
1202
|
+
*/
|
|
1203
|
+
whereJsonSubsetOf(column, value) {
|
|
1204
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1205
|
+
}
|
|
1206
|
+
/** Alias of {@link whereJsonSubsetOf} (Lucid parity). */
|
|
1207
|
+
andWhereJsonSubsetOf(column, value) {
|
|
1208
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1209
|
+
}
|
|
1210
|
+
/** `OR <col> <@ ?` (Lucid parity). See {@link whereJsonSubsetOf}. */
|
|
1211
|
+
orWhereJsonSubsetOf(column, value) {
|
|
1212
|
+
return this.#pushJson("or", false, "subset", column, value);
|
|
1213
|
+
}
|
|
1214
|
+
/** `WHERE NOT (<col> <@ ?)` (Lucid parity). */
|
|
1215
|
+
whereNotJsonSubsetOf(column, value) {
|
|
1216
|
+
return this.#pushJson("and", true, "subset", column, value);
|
|
1217
|
+
}
|
|
1218
|
+
/** `OR NOT (<col> <@ ?)` (Lucid parity). */
|
|
1219
|
+
orWhereNotJsonSubsetOf(column, value) {
|
|
1220
|
+
return this.#pushJson("or", true, "subset", column, value);
|
|
1221
|
+
}
|
|
1222
|
+
// Lucid's canonical containment names drop the `Of` suffix — kept as aliases.
|
|
1223
|
+
/** Lucid `whereJsonSuperset` (alias of {@link whereJsonSupersetOf}). */
|
|
1224
|
+
whereJsonSuperset(column, value) {
|
|
1225
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1226
|
+
}
|
|
1227
|
+
/** Lucid `andWhereJsonSuperset`. */
|
|
1228
|
+
andWhereJsonSuperset(column, value) {
|
|
1229
|
+
return this.#pushJson("and", false, "superset", column, value);
|
|
1230
|
+
}
|
|
1231
|
+
/** Lucid `orWhereJsonSuperset`. */
|
|
1232
|
+
orWhereJsonSuperset(column, value) {
|
|
1233
|
+
return this.#pushJson("or", false, "superset", column, value);
|
|
1234
|
+
}
|
|
1235
|
+
/** Lucid `whereNotJsonSuperset`. */
|
|
1236
|
+
whereNotJsonSuperset(column, value) {
|
|
1237
|
+
return this.#pushJson("and", true, "superset", column, value);
|
|
1238
|
+
}
|
|
1239
|
+
/** Lucid `orWhereNotJsonSuperset`. */
|
|
1240
|
+
orWhereNotJsonSuperset(column, value) {
|
|
1241
|
+
return this.#pushJson("or", true, "superset", column, value);
|
|
1242
|
+
}
|
|
1243
|
+
/** Lucid `whereJsonSubset` (alias of {@link whereJsonSubsetOf}). */
|
|
1244
|
+
whereJsonSubset(column, value) {
|
|
1245
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1246
|
+
}
|
|
1247
|
+
/** Lucid `andWhereJsonSubset`. */
|
|
1248
|
+
andWhereJsonSubset(column, value) {
|
|
1249
|
+
return this.#pushJson("and", false, "subset", column, value);
|
|
1250
|
+
}
|
|
1251
|
+
/** Lucid `orWhereJsonSubset`. */
|
|
1252
|
+
orWhereJsonSubset(column, value) {
|
|
1253
|
+
return this.#pushJson("or", false, "subset", column, value);
|
|
1254
|
+
}
|
|
1255
|
+
/** Lucid `whereNotJsonSubset`. */
|
|
1256
|
+
whereNotJsonSubset(column, value) {
|
|
1257
|
+
return this.#pushJson("and", true, "subset", column, value);
|
|
1258
|
+
}
|
|
1259
|
+
/** Lucid `orWhereNotJsonSubset`. */
|
|
1260
|
+
orWhereNotJsonSubset(column, value) {
|
|
1261
|
+
return this.#pushJson("or", true, "subset", column, value);
|
|
1262
|
+
}
|
|
1263
|
+
#pushJson(type, negated, jsonOp, column, value, path, operator) {
|
|
1264
|
+
// A JSONPath is bound, not interpolated, so injection is not the concern
|
|
1265
|
+
// here — a clear early error for a malformed path is. Lucid/Knex paths
|
|
1266
|
+
// start at the document root.
|
|
1267
|
+
if (path !== undefined && !path.startsWith("$")) {
|
|
1268
|
+
throw new Error(`whereJsonPath: path '${path}' must start with '$' (e.g. '$.a.b' or '$.items[0]')`);
|
|
1269
|
+
}
|
|
1270
|
+
// Containment binds the value as JSON TEXT: `$1::jsonb` parses a string,
|
|
1271
|
+
// and MySQL's JSON_CONTAINS takes a JSON document — a raw JS array bound
|
|
1272
|
+
// as-is would not cast. A path comparison keeps its scalar value.
|
|
1273
|
+
const bound = jsonOp === "path"
|
|
1274
|
+
? value
|
|
1275
|
+
: typeof value === "string"
|
|
1276
|
+
? value
|
|
1277
|
+
: JSON.stringify(value);
|
|
1278
|
+
this.#wheres.push({
|
|
1279
|
+
type,
|
|
1280
|
+
kind: "json",
|
|
1281
|
+
jsonOp,
|
|
1282
|
+
column: this.#resolveColumn(column),
|
|
1283
|
+
negated,
|
|
1284
|
+
path,
|
|
1285
|
+
operator,
|
|
1286
|
+
value: bound,
|
|
1287
|
+
});
|
|
1288
|
+
return this;
|
|
1289
|
+
}
|
|
1290
|
+
#pushExists(type, negated, subquery) {
|
|
1291
|
+
// `#buildSpec` is private, but private access is per-class, not per
|
|
1292
|
+
// instance: another ModelQuery's spec is reachable from here.
|
|
1293
|
+
this.#wheres.push({
|
|
1294
|
+
type,
|
|
1295
|
+
kind: "exists",
|
|
1296
|
+
negated,
|
|
1297
|
+
subquery: subquery.#buildSpec(),
|
|
1298
|
+
});
|
|
1299
|
+
return this;
|
|
1300
|
+
}
|
|
592
1301
|
whereColumn(left, operator, right) {
|
|
593
1302
|
return this.#whereColumn("and", left, operator, right);
|
|
594
1303
|
}
|
|
@@ -596,7 +1305,19 @@ export class ModelQuery {
|
|
|
596
1305
|
orWhereColumn(left, operator, right) {
|
|
597
1306
|
return this.#whereColumn("or", left, operator, right);
|
|
598
1307
|
}
|
|
599
|
-
|
|
1308
|
+
/** `WHERE NOT (left <op> right)` — negation of {@link whereColumn} (Lucid parity). */
|
|
1309
|
+
whereNotColumn(left, operator, right) {
|
|
1310
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1311
|
+
}
|
|
1312
|
+
/** Alias of {@link whereNotColumn} (Lucid parity). */
|
|
1313
|
+
andWhereNotColumn(left, operator, right) {
|
|
1314
|
+
return this.#whereColumn("and", left, operator, right, true);
|
|
1315
|
+
}
|
|
1316
|
+
/** `OR NOT (left <op> right)` (Lucid parity). */
|
|
1317
|
+
orWhereNotColumn(left, operator, right) {
|
|
1318
|
+
return this.#whereColumn("or", left, operator, right, true);
|
|
1319
|
+
}
|
|
1320
|
+
#whereColumn(type, left, operator, right, negated = false) {
|
|
600
1321
|
if (!WHEREEXPR_OPERATORS.has(operator)) {
|
|
601
1322
|
throw new Error(`whereColumn: operator '${operator}' is not allowed. Use one of ${[...WHEREEXPR_OPERATORS].join(" ")}.`);
|
|
602
1323
|
}
|
|
@@ -606,7 +1327,7 @@ export class ModelQuery {
|
|
|
606
1327
|
// `[table.]column` charset. This closes the injection surface regardless of
|
|
607
1328
|
// what #resolveColumn returns (it can be an identity resolver on sub-queries).
|
|
608
1329
|
const safe = (name) => {
|
|
609
|
-
const resolved = this.#
|
|
1330
|
+
const resolved = this.#resolveColumnReference(name);
|
|
610
1331
|
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(resolved)) {
|
|
611
1332
|
throw new Error(`whereColumn: '${name}' is not a valid column identifier ([table.]column, alphanumeric + underscore).`);
|
|
612
1333
|
}
|
|
@@ -617,10 +1338,45 @@ export class ModelQuery {
|
|
|
617
1338
|
.map((part) => this.#quote(part))
|
|
618
1339
|
.join(".");
|
|
619
1340
|
};
|
|
620
|
-
const
|
|
1341
|
+
const predicate = `${safe(left)} ${operator} ${safe(right)}`;
|
|
1342
|
+
// Both operands are already validated identifiers and the operator is
|
|
1343
|
+
// allow-listed, so wrapping in NOT(...) adds no new surface.
|
|
1344
|
+
const sql = negated ? `NOT (${predicate})` : predicate;
|
|
621
1345
|
this.#wheres.push({ type, kind: "raw", sql, bindings: [] });
|
|
622
1346
|
return this;
|
|
623
1347
|
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Resolve a column reference that may legitimately point at a table other
|
|
1350
|
+
* than this query's own.
|
|
1351
|
+
*
|
|
1352
|
+
* `#resolveColumn` only knows the entity's own columns, so it rejects
|
|
1353
|
+
* anything qualified. That is right for a value predicate, but wrong for a
|
|
1354
|
+
* column-to-column one: a correlated subquery
|
|
1355
|
+
* (`whereExists(post.query().whereColumn('posts.user_id', '=', 'users.id'))`)
|
|
1356
|
+
* and a joined query both have to name another table, and atlas cannot know
|
|
1357
|
+
* that table's columns. So: an unqualified name resolves as usual (typos
|
|
1358
|
+
* still get the helpful error), and a `table.column` naming a different
|
|
1359
|
+
* table passes through — validated against the identifier charset here and
|
|
1360
|
+
* quoted segment by segment by the caller, never interpolated loose. A typo
|
|
1361
|
+
* in that case surfaces as a database error rather than an atlas one, which
|
|
1362
|
+
* is the unavoidable cost of referencing a table we have no metadata for.
|
|
1363
|
+
*/
|
|
1364
|
+
#resolveColumnReference(name) {
|
|
1365
|
+
const qualified = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
|
|
1366
|
+
if (!qualified)
|
|
1367
|
+
return this.#resolveColumn(name);
|
|
1368
|
+
const [, table, column] = qualified;
|
|
1369
|
+
// Our own table: resolve the column half so `@Column({ columnName })` and
|
|
1370
|
+
// the camel→snake convention still apply.
|
|
1371
|
+
if (table === this.#tableName) {
|
|
1372
|
+
return `${table}.${this.#resolveColumn(column)}`;
|
|
1373
|
+
}
|
|
1374
|
+
// Another table in scope (outer query or JOIN). Charset-checked by the
|
|
1375
|
+
// regex above and quoted segment by segment by the caller — strict mode
|
|
1376
|
+
// does not apply, since its concern is unvalidated SQL reaching the
|
|
1377
|
+
// compiler and this identifier is validated.
|
|
1378
|
+
return `${table}.${column}`;
|
|
1379
|
+
}
|
|
624
1380
|
/**
|
|
625
1381
|
* `WHERE EXISTS (SELECT * FROM related WHERE <join> AND <cb>)` — filter parent rows
|
|
626
1382
|
* by the existence of related rows, optionally constrained by a callback.
|
|
@@ -645,6 +1401,14 @@ export class ModelQuery {
|
|
|
645
1401
|
this.#wheres.push(this.#buildExistsClause("or", true, relationName, callback));
|
|
646
1402
|
return this;
|
|
647
1403
|
}
|
|
1404
|
+
/** Alias of {@link whereHas} (Lucid parity) — `whereHas` is already AND. */
|
|
1405
|
+
andWhereHas(relationName, callback) {
|
|
1406
|
+
return this.whereHas(relationName, callback);
|
|
1407
|
+
}
|
|
1408
|
+
/** Alias of {@link whereDoesntHave} (Lucid parity). */
|
|
1409
|
+
andWhereDoesntHave(relationName, callback) {
|
|
1410
|
+
return this.whereDoesntHave(relationName, callback);
|
|
1411
|
+
}
|
|
648
1412
|
/**
|
|
649
1413
|
* Short form of `whereHas`. With an operator + count, emits a count threshold:
|
|
650
1414
|
* has('comments') → EXISTS (SELECT * FROM comments WHERE <join>)
|
|
@@ -663,6 +1427,19 @@ export class ModelQuery {
|
|
|
663
1427
|
this.#wheres.push(this.#buildExistsClause("and", true, relationName));
|
|
664
1428
|
return this;
|
|
665
1429
|
}
|
|
1430
|
+
/** `OR NOT EXISTS (...)` — the OR form of {@link doesntHave} (Lucid parity). */
|
|
1431
|
+
orDoesntHave(relationName) {
|
|
1432
|
+
this.#wheres.push(this.#buildExistsClause("or", true, relationName));
|
|
1433
|
+
return this;
|
|
1434
|
+
}
|
|
1435
|
+
/** Alias of {@link has} (Lucid parity) — `has` is already AND. */
|
|
1436
|
+
andHas(relationName, countOp, countThreshold) {
|
|
1437
|
+
return this.has(relationName, countOp, countThreshold);
|
|
1438
|
+
}
|
|
1439
|
+
/** Alias of {@link doesntHave} (Lucid parity). */
|
|
1440
|
+
andDoesntHave(relationName) {
|
|
1441
|
+
return this.doesntHave(relationName);
|
|
1442
|
+
}
|
|
666
1443
|
/**
|
|
667
1444
|
* Set this query's projection alias — only meaningful when this ModelQuery
|
|
668
1445
|
* is used as the sub-builder callback argument of `withCount` / `withAggregate`.
|
|
@@ -698,23 +1475,25 @@ export class ModelQuery {
|
|
|
698
1475
|
// --- Top-level scalar executors (Story 29.5) ---
|
|
699
1476
|
/** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
|
|
700
1477
|
async count(column = "*") {
|
|
701
|
-
const expr = column === "*"
|
|
1478
|
+
const expr = column === "*"
|
|
1479
|
+
? "COUNT(*)"
|
|
1480
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
|
|
702
1481
|
return Number((await this.#runScalar(expr)) ?? 0);
|
|
703
1482
|
}
|
|
704
1483
|
async sum(column) {
|
|
705
|
-
const v = await this.#runScalar(`SUM(${this.#quoteCol(column)})`);
|
|
1484
|
+
const v = await this.#runScalar(`SUM(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
706
1485
|
return v === null || v === undefined ? null : Number(v);
|
|
707
1486
|
}
|
|
708
1487
|
async avg(column) {
|
|
709
|
-
const v = await this.#runScalar(`AVG(${this.#quoteCol(column)})`);
|
|
1488
|
+
const v = await this.#runScalar(`AVG(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
710
1489
|
return v === null || v === undefined ? null : Number(v);
|
|
711
1490
|
}
|
|
712
1491
|
async min(column) {
|
|
713
|
-
const v = await this.#runScalar(`MIN(${this.#quoteCol(column)})`);
|
|
1492
|
+
const v = await this.#runScalar(`MIN(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
714
1493
|
return v === null || v === undefined ? null : Number(v);
|
|
715
1494
|
}
|
|
716
1495
|
async max(column) {
|
|
717
|
-
const v = await this.#runScalar(`MAX(${this.#quoteCol(column)})`);
|
|
1496
|
+
const v = await this.#runScalar(`MAX(${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
718
1497
|
return v === null || v === undefined ? null : Number(v);
|
|
719
1498
|
}
|
|
720
1499
|
/**
|
|
@@ -741,35 +1520,150 @@ export class ModelQuery {
|
|
|
741
1520
|
this.#selectSubqueries.push(this.#buildRelationSubquery(relationName, callback, "aggregate", relationName));
|
|
742
1521
|
return this;
|
|
743
1522
|
}
|
|
744
|
-
orderBy(
|
|
745
|
-
|
|
1523
|
+
orderBy(columnOrTerms, direction = "asc") {
|
|
1524
|
+
// A raw fragment (e.g. `db.ref('posts.created_at')`) orders verbatim.
|
|
1525
|
+
if (columnOrTerms instanceof RawSql) {
|
|
1526
|
+
const dir = direction === "desc" ? "DESC" : "ASC";
|
|
1527
|
+
this.#orderBys.push({ raw: `${columnOrTerms.sql} ${dir}` });
|
|
1528
|
+
return this;
|
|
1529
|
+
}
|
|
1530
|
+
if (Array.isArray(columnOrTerms)) {
|
|
1531
|
+
for (const t of columnOrTerms) {
|
|
1532
|
+
const [col, dir] = typeof t === "string"
|
|
1533
|
+
? [t, "asc"]
|
|
1534
|
+
: [t.column, t.order ?? "asc"];
|
|
1535
|
+
this.#orderBys.push({
|
|
1536
|
+
column: this.#resolveColumn(col),
|
|
1537
|
+
direction: dir,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
return this;
|
|
1541
|
+
}
|
|
1542
|
+
this.#orderBys.push({
|
|
1543
|
+
column: this.#resolveColumn(columnOrTerms),
|
|
1544
|
+
direction,
|
|
1545
|
+
});
|
|
1546
|
+
return this;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* `ORDER BY <raw fragment>` (Lucid/Knex `orderByRaw`) — for orderings with
|
|
1550
|
+
* no typed form: `NULLS LAST`, `RANDOM()`, a CASE expression, a computed
|
|
1551
|
+
* alias.
|
|
1552
|
+
*
|
|
1553
|
+
* query.orderBy('rank').orderByRaw('created_at DESC NULLS LAST')
|
|
1554
|
+
*
|
|
1555
|
+
* The fragment keeps its position among the plain `orderBy` terms.
|
|
1556
|
+
*
|
|
1557
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
1558
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
1559
|
+
*
|
|
1560
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1561
|
+
*/
|
|
1562
|
+
orderByRaw(sql) {
|
|
1563
|
+
this.#assertRawAllowed("orderByRaw");
|
|
1564
|
+
this.#orderBys.push({ raw: sql });
|
|
746
1565
|
return this;
|
|
747
1566
|
}
|
|
748
1567
|
/**
|
|
749
1568
|
* `GROUP BY col1, col2, …` (AdonisJS/Lucid `groupBy`). Columns are resolved
|
|
750
1569
|
* through the entity's column map (camelCase → snake_case) like `orderBy`.
|
|
751
|
-
* For a
|
|
752
|
-
* fluent {@link QueryBuilder}.
|
|
1570
|
+
* For a grouping expression with no typed form, see {@link groupByRaw}.
|
|
753
1571
|
*/
|
|
754
1572
|
groupBy(...columns) {
|
|
755
1573
|
for (const c of columns)
|
|
756
1574
|
this.#groupBy.push(this.#resolveColumn(c));
|
|
757
1575
|
return this;
|
|
758
1576
|
}
|
|
1577
|
+
/**
|
|
1578
|
+
* `GROUP BY <raw fragment>` (Lucid/Knex `groupByRaw`) — for groupings with
|
|
1579
|
+
* no typed form, e.g. `DATE_TRUNC('day', created_at)`.
|
|
1580
|
+
*
|
|
1581
|
+
* The fragment keeps its position among the plain `groupBy` terms.
|
|
1582
|
+
*
|
|
1583
|
+
* **Strict mode**: like {@link whereRaw}, this throws when
|
|
1584
|
+
* `setAtlasStrictMode(true)` (or `ATLAS_STRICT`) is on.
|
|
1585
|
+
*
|
|
1586
|
+
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
1587
|
+
*/
|
|
1588
|
+
groupByRaw(sql) {
|
|
1589
|
+
this.#assertRawAllowed("groupByRaw");
|
|
1590
|
+
this.#groupBy.push({ raw: sql });
|
|
1591
|
+
return this;
|
|
1592
|
+
}
|
|
759
1593
|
/**
|
|
760
1594
|
* `HAVING <col> <op> ?` — applied after `groupBy` (AdonisJS/Lucid `having`).
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
* (`COUNT(*)`, `SUM(col)`, …)
|
|
764
|
-
*
|
|
1595
|
+
* A bare model property is resolved through the entity column map (honouring
|
|
1596
|
+
* `@Column({ columnName })`) via {@link #resolveHavingCol}; an aggregate
|
|
1597
|
+
* expression (`COUNT(*)`, `SUM(col)`, …) or a result alias is left verbatim so
|
|
1598
|
+
* `having` can still reference `withCount`/`withAggregate` aliases.
|
|
765
1599
|
*/
|
|
766
1600
|
having(column, operator, value) {
|
|
767
|
-
this.#having.push({
|
|
1601
|
+
this.#having.push({
|
|
1602
|
+
column: this.#resolveHavingCol(column),
|
|
1603
|
+
operator,
|
|
1604
|
+
value: this.#prep(column, value),
|
|
1605
|
+
type: "and",
|
|
1606
|
+
});
|
|
768
1607
|
return this;
|
|
769
1608
|
}
|
|
1609
|
+
/** HAVING col IS NULL (Lucid/Knex `havingNull`). */
|
|
1610
|
+
havingNull(column) {
|
|
1611
|
+
return this.#pushHaving(column, "IS NULL", null);
|
|
1612
|
+
}
|
|
1613
|
+
/** HAVING col IS NOT NULL (Lucid/Knex `havingNotNull`). */
|
|
1614
|
+
havingNotNull(column) {
|
|
1615
|
+
return this.#pushHaving(column, "IS NOT NULL", null);
|
|
1616
|
+
}
|
|
1617
|
+
/** HAVING col IN (...) (Lucid/Knex `havingIn`). */
|
|
1618
|
+
havingIn(column, values) {
|
|
1619
|
+
return this.#pushHaving(column, "IN", [...values]);
|
|
1620
|
+
}
|
|
1621
|
+
/** HAVING col NOT IN (...) (Lucid/Knex `havingNotIn`). */
|
|
1622
|
+
havingNotIn(column, values) {
|
|
1623
|
+
return this.#pushHaving(column, "NOT IN", [...values]);
|
|
1624
|
+
}
|
|
1625
|
+
/** HAVING col BETWEEN ? AND ? (Lucid/Knex `havingBetween`). */
|
|
1626
|
+
havingBetween(column, range) {
|
|
1627
|
+
return this.#pushHaving(column, "BETWEEN", [...range]);
|
|
1628
|
+
}
|
|
1629
|
+
/** HAVING col NOT BETWEEN ? AND ? (Lucid/Knex `havingNotBetween`). */
|
|
1630
|
+
havingNotBetween(column, range) {
|
|
1631
|
+
return this.#pushHaving(column, "NOT BETWEEN", [...range]);
|
|
1632
|
+
}
|
|
1633
|
+
/** Push a HAVING entry with a raw (non-`#prep`'d) value — for IN/BETWEEN/NULL. */
|
|
1634
|
+
#pushHaving(column, operator, value) {
|
|
1635
|
+
this.#having.push({
|
|
1636
|
+
column: this.#resolveHavingCol(column),
|
|
1637
|
+
operator,
|
|
1638
|
+
value,
|
|
1639
|
+
type: "and",
|
|
1640
|
+
});
|
|
1641
|
+
return this;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Resolve a HAVING column: a bare model property maps to its DB column
|
|
1645
|
+
* (honouring `@Column({ columnName })`), but an aggregate expression
|
|
1646
|
+
* (`COUNT(*)`), a result alias, or any unknown bare identifier is left verbatim
|
|
1647
|
+
* so `having` can still reference `withCount`/`withAggregate` aliases.
|
|
1648
|
+
*/
|
|
1649
|
+
#resolveHavingCol(column) {
|
|
1650
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column))
|
|
1651
|
+
return column;
|
|
1652
|
+
try {
|
|
1653
|
+
return this.#resolveColumn(column);
|
|
1654
|
+
}
|
|
1655
|
+
catch {
|
|
1656
|
+
return column;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
770
1659
|
/** `OR HAVING <col> <op> ?` — OR-combined {@link having}. */
|
|
771
1660
|
orHaving(column, operator, value) {
|
|
772
|
-
this.#having.push({
|
|
1661
|
+
this.#having.push({
|
|
1662
|
+
column: this.#resolveHavingCol(column),
|
|
1663
|
+
operator,
|
|
1664
|
+
value: this.#prep(column, value),
|
|
1665
|
+
type: "or",
|
|
1666
|
+
});
|
|
773
1667
|
return this;
|
|
774
1668
|
}
|
|
775
1669
|
/**
|
|
@@ -781,6 +1675,12 @@ export class ModelQuery {
|
|
|
781
1675
|
* @unsafe Raw SQL fragment — never concatenate user input into `sql`.
|
|
782
1676
|
*/
|
|
783
1677
|
havingRaw(sql, bindings = []) {
|
|
1678
|
+
// Same strict-mode gate as whereRaw()/joinRaw() — havingRaw is a raw-SQL
|
|
1679
|
+
// surface, so prod hardening must be able to neutralise it too.
|
|
1680
|
+
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1681
|
+
throw new Error("havingRaw() is disabled in Atlas strict mode. " +
|
|
1682
|
+
"Use having(column, operator, value) instead.");
|
|
1683
|
+
}
|
|
784
1684
|
this.#having.push({
|
|
785
1685
|
kind: "raw",
|
|
786
1686
|
sql,
|
|
@@ -795,48 +1695,224 @@ export class ModelQuery {
|
|
|
795
1695
|
* the outer parameter list.
|
|
796
1696
|
*/
|
|
797
1697
|
union(query) {
|
|
798
|
-
this.#unions.push({ query, all: false });
|
|
1698
|
+
this.#unions.push({ query: this.#resolveUnion(query), all: false });
|
|
799
1699
|
return this;
|
|
800
1700
|
}
|
|
801
1701
|
/** `UNION ALL (<query>)` — duplicate-preserving {@link union}. */
|
|
802
1702
|
unionAll(query) {
|
|
803
|
-
this.#unions.push({ query, all: true });
|
|
1703
|
+
this.#unions.push({ query: this.#resolveUnion(query), all: true });
|
|
1704
|
+
return this;
|
|
1705
|
+
}
|
|
1706
|
+
/** `INTERSECT (<query>)` — rows present in both (Lucid/Knex `intersect`). */
|
|
1707
|
+
intersect(query) {
|
|
1708
|
+
this.#unions.push({
|
|
1709
|
+
query: this.#resolveUnion(query),
|
|
1710
|
+
all: false,
|
|
1711
|
+
op: "intersect",
|
|
1712
|
+
});
|
|
804
1713
|
return this;
|
|
805
1714
|
}
|
|
1715
|
+
/**
|
|
1716
|
+
* `INTERSECT ALL (<query>)` — duplicate-preserving {@link intersect}.
|
|
1717
|
+
*
|
|
1718
|
+
* Postgres and MySQL only: SQLite's compound operators are UNION, UNION ALL,
|
|
1719
|
+
* INTERSECT and EXCEPT — there is no INTERSECT ALL — so the compiler raises
|
|
1720
|
+
* `E_UNSUPPORTED` there rather than emitting a syntax error.
|
|
1721
|
+
*/
|
|
1722
|
+
intersectAll(query) {
|
|
1723
|
+
this.#unions.push({
|
|
1724
|
+
query: this.#resolveUnion(query),
|
|
1725
|
+
all: true,
|
|
1726
|
+
op: "intersect",
|
|
1727
|
+
});
|
|
1728
|
+
return this;
|
|
1729
|
+
}
|
|
1730
|
+
/** `EXCEPT (<query>)` — rows in this query but not the other (Lucid/Knex `except`). */
|
|
1731
|
+
except(query) {
|
|
1732
|
+
this.#unions.push({
|
|
1733
|
+
query: this.#resolveUnion(query),
|
|
1734
|
+
all: false,
|
|
1735
|
+
op: "except",
|
|
1736
|
+
});
|
|
1737
|
+
return this;
|
|
1738
|
+
}
|
|
1739
|
+
/** `EXCEPT ALL (<query>)` — duplicate-preserving {@link except}. Not on SQLite; see {@link intersectAll}. */
|
|
1740
|
+
exceptAll(query) {
|
|
1741
|
+
this.#unions.push({
|
|
1742
|
+
query: this.#resolveUnion(query),
|
|
1743
|
+
all: true,
|
|
1744
|
+
op: "except",
|
|
1745
|
+
});
|
|
1746
|
+
return this;
|
|
1747
|
+
}
|
|
1748
|
+
/** A compound-query arg — an explicit ModelQuery OR a callback building one on this model. */
|
|
1749
|
+
#resolveUnion(query) {
|
|
1750
|
+
if (typeof query !== "function")
|
|
1751
|
+
return query;
|
|
1752
|
+
const sub = this.#freshQuery();
|
|
1753
|
+
query(sub);
|
|
1754
|
+
return sub;
|
|
1755
|
+
}
|
|
1756
|
+
/** A fresh, empty query on the SAME model + connection (for compound callbacks). */
|
|
1757
|
+
#freshQuery() {
|
|
1758
|
+
return new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
|
|
1759
|
+
}
|
|
806
1760
|
/**
|
|
807
1761
|
* `WITH <name> AS (<query>)` — register a Common Table Expression
|
|
808
1762
|
* (AdonisJS/Lucid `with`). The CTE name is validated as an identifier; the
|
|
809
1763
|
* sub-query is compiled and its bindings are re-indexed into the outer list.
|
|
810
1764
|
*/
|
|
811
1765
|
with(name, query) {
|
|
1766
|
+
return this.#pushCte("with", name, query, {});
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* `WITH RECURSIVE <name> AS (<query>)` — a self-referencing CTE
|
|
1770
|
+
* (Lucid/Knex `withRecursive`), for trees and graph walks.
|
|
1771
|
+
*
|
|
1772
|
+
* RECURSIVE is a property of the WITH clause rather than of one CTE, so a
|
|
1773
|
+
* single recursive entry makes the whole clause recursive — which is what
|
|
1774
|
+
* all three dialects require. Mixing `with()` and `withRecursive()` is fine.
|
|
1775
|
+
*
|
|
1776
|
+
* The recursive term itself is a `UNION`/`UNION ALL` inside `query`, e.g.
|
|
1777
|
+
* an anchor `SELECT` unioned with a select that references `<name>`.
|
|
1778
|
+
*/
|
|
1779
|
+
withRecursive(name, query, columns) {
|
|
1780
|
+
return this.#pushCte("withRecursive", name, query, {
|
|
1781
|
+
recursive: true,
|
|
1782
|
+
columns,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* `WITH <name> AS MATERIALIZED (<query>)` — force the CTE to be evaluated
|
|
1787
|
+
* once and stashed (Lucid/Knex `withMaterialized`).
|
|
1788
|
+
*
|
|
1789
|
+
* Postgres 12+ and SQLite 3.35+ only; MySQL has no such hint and the
|
|
1790
|
+
* compiler raises `E_UNSUPPORTED` rather than emitting a syntax error.
|
|
1791
|
+
*/
|
|
1792
|
+
withMaterialized(name, query) {
|
|
1793
|
+
return this.#pushCte("withMaterialized", name, query, {
|
|
1794
|
+
materialized: true,
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
/** `WITH <name> AS NOT MATERIALIZED (<query>)` — let it be inlined (Lucid/Knex `withNotMaterialized`). See {@link withMaterialized}. */
|
|
1798
|
+
withNotMaterialized(name, query) {
|
|
1799
|
+
return this.#pushCte("withNotMaterialized", name, query, {
|
|
1800
|
+
materialized: false,
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
#pushCte(method, name, query, options) {
|
|
812
1804
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
813
|
-
throw new Error(
|
|
1805
|
+
throw new Error(`${method}(): CTE name '${name}' is not a valid identifier`);
|
|
814
1806
|
}
|
|
815
|
-
this.#ctes.push({ name, query });
|
|
1807
|
+
this.#ctes.push({ name, query: this.#resolveUnion(query), ...options });
|
|
1808
|
+
return this;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* `@ManyToMany` only — project EXTRA pivot columns at query time (AdonisJS/Lucid
|
|
1812
|
+
* `pivotColumns([...])`), on top of any declared in the relation decorator. Read
|
|
1813
|
+
* off each loaded relation as `$extras.pivot_<col>`. Inert on non-m2m relations.
|
|
1814
|
+
*
|
|
1815
|
+
* userRepo.query().preload('skills', q => q.pivotColumns(['notes']))
|
|
1816
|
+
*/
|
|
1817
|
+
pivotColumns(columns) {
|
|
1818
|
+
this.#extraPivotColumns.push(...columns);
|
|
816
1819
|
return this;
|
|
817
1820
|
}
|
|
818
1821
|
wherePivot(column, operatorOrValue, value) {
|
|
1822
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
1823
|
+
}
|
|
1824
|
+
andWherePivot(column, operatorOrValue, value) {
|
|
1825
|
+
return this.#pushPivot("and", column, operatorOrValue, value);
|
|
1826
|
+
}
|
|
1827
|
+
orWherePivot(column, operatorOrValue, value) {
|
|
1828
|
+
return this.#pushPivot("or", column, operatorOrValue, value);
|
|
1829
|
+
}
|
|
1830
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IN (...)` on the pivot table (AdonisJS Lucid `whereInPivot`). */
|
|
1831
|
+
whereInPivot(column, values) {
|
|
1832
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
1833
|
+
}
|
|
1834
|
+
/** Alias of {@link whereInPivot} (Lucid parity). */
|
|
1835
|
+
andWhereInPivot(column, values) {
|
|
1836
|
+
return this.#pushPivotOp("and", column, "IN", [...values]);
|
|
1837
|
+
}
|
|
1838
|
+
/** `@ManyToMany` only — OR form of {@link whereInPivot} (Lucid parity). */
|
|
1839
|
+
orWhereInPivot(column, values) {
|
|
1840
|
+
return this.#pushPivotOp("or", column, "IN", [...values]);
|
|
1841
|
+
}
|
|
1842
|
+
/** Alias of {@link whereInPivot} kept for the earlier atlas name. */
|
|
1843
|
+
wherePivotIn(column, values) {
|
|
1844
|
+
return this.whereInPivot(column, values);
|
|
1845
|
+
}
|
|
1846
|
+
whereNotPivot(column, operatorOrValue, value) {
|
|
819
1847
|
if (value === undefined) {
|
|
820
|
-
this.#
|
|
821
|
-
}
|
|
822
|
-
else {
|
|
823
|
-
this.#pivotWheres.push({
|
|
824
|
-
column,
|
|
825
|
-
operator: operatorOrValue,
|
|
826
|
-
value,
|
|
827
|
-
});
|
|
1848
|
+
return this.#pushPivotOp("and", column, "!=", operatorOrValue);
|
|
828
1849
|
}
|
|
829
|
-
return this;
|
|
1850
|
+
return this.#pushPivotOp("and", column, negateOperator(String(operatorOrValue)), value);
|
|
830
1851
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1852
|
+
andWhereNotPivot(column, operatorOrValue, value) {
|
|
1853
|
+
return value === undefined
|
|
1854
|
+
? this.#pushPivotOp("and", column, "!=", operatorOrValue)
|
|
1855
|
+
: this.#pushPivotOp("and", column, negateOperator(String(operatorOrValue)), value);
|
|
1856
|
+
}
|
|
1857
|
+
orWhereNotPivot(column, operatorOrValue, value) {
|
|
1858
|
+
return value === undefined
|
|
1859
|
+
? this.#pushPivotOp("or", column, "!=", operatorOrValue)
|
|
1860
|
+
: this.#pushPivotOp("or", column, negateOperator(String(operatorOrValue)), value);
|
|
1861
|
+
}
|
|
1862
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> NOT IN (...)` on the pivot table (AdonisJS Lucid `whereNotInPivot`). */
|
|
1863
|
+
whereNotInPivot(column, values) {
|
|
1864
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
1865
|
+
}
|
|
1866
|
+
/** Alias of {@link whereNotInPivot} (Lucid parity). */
|
|
1867
|
+
andWhereNotInPivot(column, values) {
|
|
1868
|
+
return this.#pushPivotOp("and", column, "NOT IN", [...values]);
|
|
1869
|
+
}
|
|
1870
|
+
/** `@ManyToMany` only — OR form of {@link whereNotInPivot} (Lucid parity). */
|
|
1871
|
+
orWhereNotInPivot(column, values) {
|
|
1872
|
+
return this.#pushPivotOp("or", column, "NOT IN", [...values]);
|
|
1873
|
+
}
|
|
1874
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NULL` on the pivot table (Lucid `whereNullPivot`). */
|
|
1875
|
+
whereNullPivot(column) {
|
|
1876
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
1877
|
+
}
|
|
1878
|
+
/** Alias of {@link whereNullPivot} (Lucid parity). */
|
|
1879
|
+
andWhereNullPivot(column) {
|
|
1880
|
+
return this.#pushPivotOp("and", column, "IS NULL", null);
|
|
1881
|
+
}
|
|
1882
|
+
/** `@ManyToMany` only — OR form of {@link whereNullPivot} (Lucid parity). */
|
|
1883
|
+
orWhereNullPivot(column) {
|
|
1884
|
+
return this.#pushPivotOp("or", column, "IS NULL", null);
|
|
1885
|
+
}
|
|
1886
|
+
/** `@ManyToMany` only — `WHERE <pivotCol> IS NOT NULL` on the pivot table (Lucid `whereNotNullPivot`). */
|
|
1887
|
+
whereNotNullPivot(column) {
|
|
1888
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
1889
|
+
}
|
|
1890
|
+
/** Alias of {@link whereNotNullPivot} (Lucid parity). */
|
|
1891
|
+
andWhereNotNullPivot(column) {
|
|
1892
|
+
return this.#pushPivotOp("and", column, "IS NOT NULL", null);
|
|
1893
|
+
}
|
|
1894
|
+
/** `@ManyToMany` only — OR form of {@link whereNotNullPivot} (Lucid parity). */
|
|
1895
|
+
orWhereNotNullPivot(column) {
|
|
1896
|
+
return this.#pushPivotOp("or", column, "IS NOT NULL", null);
|
|
1897
|
+
}
|
|
1898
|
+
/** Shared `(column, value)` / `(column, operator, value)` overload split for the pivot filters. */
|
|
1899
|
+
#pushPivot(type, column, operatorOrValue, value) {
|
|
1900
|
+
return value === undefined
|
|
1901
|
+
? this.#pushPivotOp(type, column, "=", operatorOrValue)
|
|
1902
|
+
: this.#pushPivotOp(type, column, operatorOrValue, value);
|
|
1903
|
+
}
|
|
1904
|
+
#pushPivotOp(type, column, operator, value) {
|
|
1905
|
+
this.#pivotWheres.push({ column, operator, value, type });
|
|
834
1906
|
return this;
|
|
835
1907
|
}
|
|
836
1908
|
/** Read-only accessor for pivot constraints — consumed by the m2m preload resolver. */
|
|
837
1909
|
get pivotConstraints() {
|
|
838
1910
|
return this.#pivotWheres;
|
|
839
1911
|
}
|
|
1912
|
+
/** Read-only accessor for query-time pivot columns — consumed by the m2m preload resolver. */
|
|
1913
|
+
get pivotColumnRequests() {
|
|
1914
|
+
return this.#extraPivotColumns;
|
|
1915
|
+
}
|
|
840
1916
|
limit(n) {
|
|
841
1917
|
// Guard here with a clear message — the Rust spec types limit as
|
|
842
1918
|
// u64, so a negative/non-integer otherwise surfaces as a cryptic
|
|
@@ -848,6 +1924,24 @@ export class ModelQuery {
|
|
|
848
1924
|
this.#limit = n;
|
|
849
1925
|
return this;
|
|
850
1926
|
}
|
|
1927
|
+
/**
|
|
1928
|
+
* In a has-many preload callback, cap the related rows PER PARENT (Lucid
|
|
1929
|
+
* `groupLimit`) — a plain `.limit()` caps the whole result set across parents.
|
|
1930
|
+
* Compiled with a `ROW_NUMBER() OVER (PARTITION BY <fk> …)` window. Pair with
|
|
1931
|
+
* {@link groupOrderBy} to pick which N per parent.
|
|
1932
|
+
*/
|
|
1933
|
+
groupLimit(n) {
|
|
1934
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1935
|
+
throw new Error(`groupLimit must be a non-negative integer, got ${n}`);
|
|
1936
|
+
}
|
|
1937
|
+
this.#groupLimit = n;
|
|
1938
|
+
return this;
|
|
1939
|
+
}
|
|
1940
|
+
/** ORDER BY for the {@link groupLimit} per-parent window (Lucid `groupOrderBy`). */
|
|
1941
|
+
groupOrderBy(column, direction = "asc") {
|
|
1942
|
+
this.#groupOrderBy = { column, direction };
|
|
1943
|
+
return this;
|
|
1944
|
+
}
|
|
851
1945
|
offset(n) {
|
|
852
1946
|
if (!Number.isInteger(n) || n < 0) {
|
|
853
1947
|
throw new Error(`offset must be a non-negative integer, got ${n}`);
|
|
@@ -903,14 +1997,79 @@ export class ModelQuery {
|
|
|
903
1997
|
return this.exec().then(onfulfilled, onrejected);
|
|
904
1998
|
}
|
|
905
1999
|
/** Build the spec object that gets sent to the Rust compiler. Extracted so whereHas can reuse it for sub-queries. */
|
|
2000
|
+
/**
|
|
2001
|
+
* DB column backing the soft-delete `deletedAt` property — honours a
|
|
2002
|
+
* `@Column({ columnName })` override, read straight from the entity metadata
|
|
2003
|
+
* (not the resolver callback, which is identity for subqueries/preloads).
|
|
2004
|
+
*/
|
|
2005
|
+
#deletedAtColumn() {
|
|
2006
|
+
const col = this.#entityClass
|
|
2007
|
+
? getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === "deletedAt")
|
|
2008
|
+
: undefined;
|
|
2009
|
+
return col?.columnName ?? "deleted_at";
|
|
2010
|
+
}
|
|
906
2011
|
#buildSpec() {
|
|
2012
|
+
// `SKIP LOCKED` / `NOWAIT` are meaningless without a base row lock — and the
|
|
2013
|
+
// compiler emits the lock clause only when a base mode is set, so a lone
|
|
2014
|
+
// modifier would be a SILENT no-op (dangerous for job-queue polling that
|
|
2015
|
+
// believes it skips locked rows). Fail loud instead. Order-independent: this
|
|
2016
|
+
// fires whether the modifier was chained before or after the base lock.
|
|
2017
|
+
if (this.#lockModifier && !this.#lockMode) {
|
|
2018
|
+
throw new Error(`${this.#lockModifier} requires a base row lock — call forUpdate()/forShare()/forNoKeyUpdate()/forKeyShare() as well (a modifier alone emits no lock at all).`);
|
|
2019
|
+
}
|
|
2020
|
+
// With a JOIN and the default `SELECT *`, scope the projection to the base
|
|
2021
|
+
// table's declared columns so joined columns can't clobber the model's fields
|
|
2022
|
+
// (e.g. `users.id` overwriting `orders.id`) and corrupt the hydrated entity —
|
|
2023
|
+
// AdonisJS/Lucid selects the model's own columns. Explicit `select()` wins.
|
|
2024
|
+
let selectCols = this.#select;
|
|
2025
|
+
if (this.#joins.length > 0 &&
|
|
2026
|
+
this.#select.length === 1 &&
|
|
2027
|
+
this.#select[0] === "*") {
|
|
2028
|
+
const cols = getColumnMetadata(this.#entityClass).map((c) => `${this.#tableName}.${c.columnName ?? camelToSnake(c.propertyKey)}`);
|
|
2029
|
+
if (cols.length > 0)
|
|
2030
|
+
selectCols = cols;
|
|
2031
|
+
}
|
|
2032
|
+
else if (!(selectCols.length === 1 && selectCols[0] === "*") &&
|
|
2033
|
+
selectCols.every((c) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(c))) {
|
|
2034
|
+
// A partial `select()` of PLAIN columns that omits the primary key would
|
|
2035
|
+
// hydrate a persisted entity with no PK — a later save() would then INSERT
|
|
2036
|
+
// instead of UPDATE (double-write / unique violation / spurious
|
|
2037
|
+
// beforeCreate). Auto-include the (base-table-qualified) PK so model
|
|
2038
|
+
// entities stay saveable. Aggregate/alias/expression selects are left
|
|
2039
|
+
// untouched — use `.pojo()` for those.
|
|
2040
|
+
const pkProp = getPrimaryKey(this.#entityClass);
|
|
2041
|
+
if (pkProp) {
|
|
2042
|
+
const pkCol = getColumnMetadata(this.#entityClass).find((c) => c.propertyKey === pkProp)?.columnName ?? camelToSnake(pkProp);
|
|
2043
|
+
// The PK counts as present ONLY as the bare column or the BASE-table-
|
|
2044
|
+
// qualified column. A joined `other.id` must NOT satisfy it (its leaf
|
|
2045
|
+
// collides with the PK name but it's a different table's row) — otherwise
|
|
2046
|
+
// we'd skip adding `base.id` and hydrate the wrong PK, corrupting a later
|
|
2047
|
+
// save(). Appended last, `base.id` also wins the duplicate result key
|
|
2048
|
+
// (rows collect in column order, last-wins) so the base row's PK hydrates.
|
|
2049
|
+
const baseQualifiedPk = `${this.#tableName}.${pkCol}`;
|
|
2050
|
+
if (!selectCols.some((c) => c === pkCol || c === baseQualifiedPk)) {
|
|
2051
|
+
selectCols = [...selectCols, baseQualifiedPk];
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
907
2055
|
const wheres = [...this.#wheres];
|
|
908
|
-
//
|
|
2056
|
+
// Lazy m2m `related().query()`: emit the pivot EXISTS now, folding in any
|
|
2057
|
+
// `.wherePivot()` recorded since the proxy handed back this query (pushed to
|
|
2058
|
+
// the LOCAL copy so repeated #buildSpec calls — count, subquery — don't stack).
|
|
2059
|
+
if (this.#pivotExists) {
|
|
2060
|
+
const { sql, bindings } = this.#pivotExists(this.#pivotWheres);
|
|
2061
|
+
wheres.push({ type: "and", kind: "raw", sql, bindings: [...bindings] });
|
|
2062
|
+
}
|
|
2063
|
+
// Auto-apply soft-delete scope when the entity opts in via @SoftDeletes.
|
|
2064
|
+
// Resolve `deletedAt` through the column resolver so a `@Column({ columnName })`
|
|
2065
|
+
// override on the soft-delete column is honoured on the read side too — matching
|
|
2066
|
+
// the write side (delete/restore go through #dbColumn).
|
|
909
2067
|
if (this.#softDeletes) {
|
|
2068
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
910
2069
|
if (this.#softScope === "default") {
|
|
911
2070
|
wheres.push({
|
|
912
2071
|
type: "and",
|
|
913
|
-
column:
|
|
2072
|
+
column: deletedAtCol,
|
|
914
2073
|
operator: "IS NULL",
|
|
915
2074
|
value: null,
|
|
916
2075
|
});
|
|
@@ -918,7 +2077,7 @@ export class ModelQuery {
|
|
|
918
2077
|
else if (this.#softScope === "only-trashed") {
|
|
919
2078
|
wheres.push({
|
|
920
2079
|
type: "and",
|
|
921
|
-
column:
|
|
2080
|
+
column: deletedAtCol,
|
|
922
2081
|
operator: "IS NOT NULL",
|
|
923
2082
|
value: null,
|
|
924
2083
|
});
|
|
@@ -928,8 +2087,9 @@ export class ModelQuery {
|
|
|
928
2087
|
return {
|
|
929
2088
|
kind: "select",
|
|
930
2089
|
table: this.#tableName,
|
|
931
|
-
select:
|
|
2090
|
+
select: selectCols,
|
|
932
2091
|
selectSubqueries: this.#selectSubqueries,
|
|
2092
|
+
selectRaw: this.#selectRaw,
|
|
933
2093
|
wheres,
|
|
934
2094
|
orderBy: this.#orderBys,
|
|
935
2095
|
groupBy: this.#groupBy,
|
|
@@ -937,22 +2097,120 @@ export class ModelQuery {
|
|
|
937
2097
|
limit: this.#limit ?? null,
|
|
938
2098
|
offset: this.#offset ?? null,
|
|
939
2099
|
distinct: this.#distinct,
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
return { name: c.name, sql, params };
|
|
943
|
-
}),
|
|
2100
|
+
distinctOn: this.#distinctOn,
|
|
2101
|
+
ctes: this.#compiledCtes(),
|
|
944
2102
|
unions: this.#unions.map((u) => {
|
|
945
|
-
const { sql, params } = u.query
|
|
946
|
-
return { sql, params, all: u.all };
|
|
2103
|
+
const { sql, params } = u.query.#compiledNative();
|
|
2104
|
+
return { sql, params, all: u.all, op: u.op ?? null };
|
|
947
2105
|
}),
|
|
948
2106
|
joins: this.#joins,
|
|
949
|
-
lockMode: this.#lockMode
|
|
2107
|
+
lockMode: this.#lockMode
|
|
2108
|
+
? this.#lockModifier
|
|
2109
|
+
? `${this.#lockMode} ${this.#lockModifier}`
|
|
2110
|
+
: this.#lockMode
|
|
2111
|
+
: null,
|
|
950
2112
|
};
|
|
951
2113
|
}
|
|
952
|
-
/**
|
|
953
|
-
|
|
2114
|
+
/**
|
|
2115
|
+
* Native compiled `{ sql, params }` (Postgres `$N`) — the form atlas executes
|
|
2116
|
+
* and embeds inside parent queries. Public `toSQL()` normalizes to Knex `?`.
|
|
2117
|
+
*/
|
|
2118
|
+
#compiledNative() {
|
|
954
2119
|
const compiled = compileStatementNative(this.#buildSpec(), this.#dialect);
|
|
955
|
-
|
|
2120
|
+
const sql = this.#commentPrefix() + compiled.statements[0];
|
|
2121
|
+
return { sql, params: compiled.params };
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Build SQL via the Rust query compiler (Lucid `toSQL`). `.sql` uses `?`
|
|
2125
|
+
* placeholders (Knex-normalized, like Lucid); `.toNative()` yields the native
|
|
2126
|
+
* form. Returns `bindings` (Lucid's name) and `params` (atlas's) — same array.
|
|
2127
|
+
*/
|
|
2128
|
+
toSQL() {
|
|
2129
|
+
const { sql, params } = this.#compiledNative();
|
|
2130
|
+
return compiledStatement(sql, params);
|
|
2131
|
+
}
|
|
2132
|
+
/** `{ sql, bindings }` — the compiled native query (Lucid/Knex `toNative`). */
|
|
2133
|
+
toNative() {
|
|
2134
|
+
const { sql, params } = this.#compiledNative();
|
|
2135
|
+
return { sql, bindings: params };
|
|
2136
|
+
}
|
|
2137
|
+
/** The model class this query targets (Adonis Lucid `query.model`). */
|
|
2138
|
+
get model() {
|
|
2139
|
+
return this.#entityClass;
|
|
2140
|
+
}
|
|
2141
|
+
/** Apply `cb` only on the given dialect(s) (Lucid `ifDialect`; Lucid names accepted). */
|
|
2142
|
+
ifDialect(dialect, cb) {
|
|
2143
|
+
const set = (Array.isArray(dialect) ? dialect : [dialect]).map(normalizeDialect);
|
|
2144
|
+
if (set.includes(this.#dialect))
|
|
2145
|
+
cb(this);
|
|
2146
|
+
return this;
|
|
2147
|
+
}
|
|
2148
|
+
/** Apply `cb` on every dialect EXCEPT the given one(s) (Lucid `unlessDialect`). */
|
|
2149
|
+
unlessDialect(dialect, cb) {
|
|
2150
|
+
const set = (Array.isArray(dialect) ? dialect : [dialect]).map(normalizeDialect);
|
|
2151
|
+
if (!set.includes(this.#dialect))
|
|
2152
|
+
cb(this);
|
|
2153
|
+
return this;
|
|
2154
|
+
}
|
|
2155
|
+
/** Prepend a `/* … */` SQL comment to the compiled query (Lucid/Knex `comment`). */
|
|
2156
|
+
comment(text) {
|
|
2157
|
+
if (text.includes("*/")) {
|
|
2158
|
+
throw new Error("comment() text may not contain '*/'");
|
|
2159
|
+
}
|
|
2160
|
+
this.#comments.push(text);
|
|
2161
|
+
return this;
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Set a caller-facing statement timeout in milliseconds (Lucid `timeout(ms)`).
|
|
2165
|
+
* The awaiting promise rejects after `ms` on the primary result fetch. With
|
|
2166
|
+
* `{ cancel: true }` a SERVER-side statement timeout is also applied — Postgres
|
|
2167
|
+
* `statement_timeout`, MySQL `MAX_EXECUTION_TIME` (SELECT) — so the server
|
|
2168
|
+
* aborts the query, not just the client. (SQLite has no server timeout; the
|
|
2169
|
+
* client race applies.) Called with no argument it clears the timeout.
|
|
2170
|
+
*/
|
|
2171
|
+
timeout(ms, options) {
|
|
2172
|
+
// `{ cancel: true }` also applies a SERVER-side statement timeout (Postgres
|
|
2173
|
+
// statement_timeout / MySQL MAX_EXECUTION_TIME for SELECT); SQLite has none,
|
|
2174
|
+
// so the client race applies there.
|
|
2175
|
+
this.#timeoutMs = ms;
|
|
2176
|
+
this.#cancelTimeout = options?.cancel === true;
|
|
2177
|
+
return this;
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Race `work` against the configured `.timeout(ms)`. Rejects the awaiter after
|
|
2181
|
+
* `ms`; the losing DB promise is swallowed so a post-timeout driver error never
|
|
2182
|
+
* surfaces as an unhandled rejection. No timeout set → returns `work` as-is.
|
|
2183
|
+
*/
|
|
2184
|
+
#raceTimeout(work) {
|
|
2185
|
+
const ms = this.#timeoutMs;
|
|
2186
|
+
if (!ms || ms <= 0)
|
|
2187
|
+
return work;
|
|
2188
|
+
let timer;
|
|
2189
|
+
const guard = new Promise((_, reject) => {
|
|
2190
|
+
timer = setTimeout(() => reject(new Error(`Query timed out after ${ms}ms`)), ms);
|
|
2191
|
+
});
|
|
2192
|
+
work.catch(() => { });
|
|
2193
|
+
return Promise.race([work, guard]).finally(() => clearTimeout(timer));
|
|
2194
|
+
}
|
|
2195
|
+
/** The `/* … */` prefix for the compiled SQL, or empty when no comments. */
|
|
2196
|
+
/** Compile the registered CTEs to the wire shape — shared by SELECT and DML. */
|
|
2197
|
+
#compiledCtes() {
|
|
2198
|
+
return this.#ctes.map((c) => {
|
|
2199
|
+
const { sql, params } = c.query.#compiledNative();
|
|
2200
|
+
return {
|
|
2201
|
+
name: c.name,
|
|
2202
|
+
sql,
|
|
2203
|
+
params,
|
|
2204
|
+
recursive: c.recursive ?? false,
|
|
2205
|
+
materialized: c.materialized ?? null,
|
|
2206
|
+
columns: c.columns ?? [],
|
|
2207
|
+
};
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
#commentPrefix() {
|
|
2211
|
+
return this.#comments.length > 0
|
|
2212
|
+
? `${this.#comments.map((c) => `/* ${c} */`).join(" ")} `
|
|
2213
|
+
: "";
|
|
956
2214
|
}
|
|
957
2215
|
/**
|
|
958
2216
|
* Cached exec result. Memoizing the promise makes the builder a one-shot
|
|
@@ -976,8 +2234,8 @@ export class ModelQuery {
|
|
|
976
2234
|
return results;
|
|
977
2235
|
}
|
|
978
2236
|
async #doExec() {
|
|
979
|
-
const { sql, params } = this
|
|
980
|
-
const rawRows = await this.#db.query(sql, params);
|
|
2237
|
+
const { sql, params } = this.#compiledNative();
|
|
2238
|
+
const rawRows = await this.#raceTimeout(this.#db.query(sql, params, this.#meta("exec")));
|
|
981
2239
|
// Peel withCount / withAggregate alias columns off the raw row into $extras
|
|
982
2240
|
// BEFORE hydration, so the hydrator doesn't try to interpret them as columns.
|
|
983
2241
|
const extraKeys = this.#selectSubqueries.map((s) => s.alias);
|
|
@@ -992,14 +2250,48 @@ export class ModelQuery {
|
|
|
992
2250
|
const entity = this.#hydrateFn(row);
|
|
993
2251
|
for (const [k, v] of Object.entries(picked))
|
|
994
2252
|
entity.setExtra(k, v);
|
|
2253
|
+
// Thread query-level sideloaded context onto each hydrated instance.
|
|
2254
|
+
if (this.#sideloaded)
|
|
2255
|
+
entity.$sideloaded = { ...this.#sideloaded };
|
|
995
2256
|
return entity;
|
|
996
2257
|
});
|
|
997
2258
|
// Resolve preloads (eager loading)
|
|
998
2259
|
if (this.#preloads.size > 0 && this.#entityClass && entities.length > 0) {
|
|
999
2260
|
await this.#resolvePreloads(entities);
|
|
1000
2261
|
}
|
|
2262
|
+
// rowTransformer callbacks run per instance AFTER preloads, before resolve
|
|
2263
|
+
// (Lucid), so a transformer can read the eager-loaded relations.
|
|
2264
|
+
if (this.#rowTransformers.length > 0) {
|
|
2265
|
+
for (const entity of entities) {
|
|
2266
|
+
for (const transform of this.#rowTransformers)
|
|
2267
|
+
transform.run(entity);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
1001
2270
|
return entities;
|
|
1002
2271
|
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Execute and return PLAIN row objects (raw snake_case DB columns), skipping
|
|
2274
|
+
* model hydration, `@column({ consume })`, dirty-tracking and preloads —
|
|
2275
|
+
* AdonisJS Lucid `pojo()`. Fast read path for reports/exports where model
|
|
2276
|
+
* instances aren't needed.
|
|
2277
|
+
*/
|
|
2278
|
+
async pojo() {
|
|
2279
|
+
const { sql, params } = this.#compiledNative();
|
|
2280
|
+
return this.#db.query(sql, params);
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Thread arbitrary context onto every instance this query hydrates, exposed as
|
|
2284
|
+
* `entity.$sideloaded` (AdonisJS Lucid `sideload`) — e.g. the current tenant or
|
|
2285
|
+
* user, so hooks/computed can read it. REPLACES the current sideloaded data by
|
|
2286
|
+
* default (Lucid); pass `merge = true` to merge instead. The data also
|
|
2287
|
+
* propagates to preloaded relation queries. Chainable.
|
|
2288
|
+
*/
|
|
2289
|
+
sideload(values, merge = false) {
|
|
2290
|
+
this.#sideloaded = merge
|
|
2291
|
+
? { ...this.#sideloaded, ...values }
|
|
2292
|
+
: { ...values };
|
|
2293
|
+
return this;
|
|
2294
|
+
}
|
|
1003
2295
|
/** Resolve preloaded relations via batched subqueries (no N+1). */
|
|
1004
2296
|
async #resolvePreloads(entities) {
|
|
1005
2297
|
if (!this.#entityClass)
|
|
@@ -1013,39 +2305,104 @@ export class ModelQuery {
|
|
|
1013
2305
|
if (!ctx)
|
|
1014
2306
|
continue;
|
|
1015
2307
|
const allRelated = await this.#resolveOneRelation(entities, relationName, relation.type, ctx);
|
|
2308
|
+
// Propagate the parent query's sideloaded context onto the preloaded
|
|
2309
|
+
// relation instances (Adonis Lucid). The related row's own sideloaded
|
|
2310
|
+
// data (if any) wins over the inherited context.
|
|
2311
|
+
if (this.#sideloaded) {
|
|
2312
|
+
for (const related of allRelated) {
|
|
2313
|
+
related.$sideloaded = { ...this.#sideloaded, ...related.$sideloaded };
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
1016
2316
|
await this.#applyNestedPreloads(allRelated, ctx);
|
|
1017
2317
|
}
|
|
1018
2318
|
}
|
|
1019
2319
|
/** Per-preload constants (related class, table, pk, hydrator, query helper, nested callback). */
|
|
1020
2320
|
#buildPreloadContext(relation, relationName) {
|
|
1021
2321
|
const relatedClass = relation.target();
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
2322
|
+
// Boot the related model's metadata on demand (Lucid parity): a preload
|
|
2323
|
+
// must not silently no-op just because the related class hasn't been
|
|
2324
|
+
// touched yet elsewhere. ensureEntityMetadata synthesizes @Entity from the
|
|
2325
|
+
// static table / naming strategy when the decorator hasn't run.
|
|
2326
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1025
2327
|
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1026
2328
|
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
1027
2329
|
// on a freshly constructed instance, so `key in entity` is always false and
|
|
1028
2330
|
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1029
|
-
|
|
2331
|
+
// For m2m the pivot's otherKey references `relatedKey` (default the related
|
|
2332
|
+
// PK); the load must filter/index rows by THAT column, not always the PK.
|
|
2333
|
+
const relatedPkName = (relation.type === "manyToMany"
|
|
2334
|
+
? relation.pivot?.relatedKey
|
|
2335
|
+
: undefined) ??
|
|
2336
|
+
getPrimaryKey(relatedClass) ??
|
|
2337
|
+
"id";
|
|
1030
2338
|
const validColumns = new Set();
|
|
2339
|
+
// Reverse map (db column → property) so an explicit `@Column({ columnName })`
|
|
2340
|
+
// on the related entity hydrates correctly — mirrors `BaseRepository.#hydrate`.
|
|
2341
|
+
const byDbName = new Map();
|
|
2342
|
+
// Capture the related model's `@Column({ consume })` adapters + its date
|
|
2343
|
+
// columns so preloaded rows hydrate identically to a direct query — dates
|
|
2344
|
+
// become Chronos DateTime, decimal/etc adapters run. Without this, a
|
|
2345
|
+
// preloaded relation left column values raw (Lucid parity bug + a runtime
|
|
2346
|
+
// footgun for getters/serializers/hooks). Mirrors BaseRepository.#applyConsume.
|
|
2347
|
+
const consumes = new Map();
|
|
2348
|
+
let relatedPkDb = camelToSnake(relatedPkName);
|
|
1031
2349
|
for (const col of getColumnMetadata(relatedClass)) {
|
|
2350
|
+
const db = col.columnName ?? camelToSnake(col.propertyKey);
|
|
1032
2351
|
validColumns.add(col.propertyKey);
|
|
1033
|
-
validColumns.add(
|
|
2352
|
+
validColumns.add(db);
|
|
2353
|
+
byDbName.set(db, col.propertyKey);
|
|
2354
|
+
if (col.consume)
|
|
2355
|
+
consumes.set(col.propertyKey, col.consume);
|
|
2356
|
+
// The related PK may be multi-word (postId→post_id) or columnName-mapped;
|
|
2357
|
+
// its DB column name is what the WHERE + row indexing must use.
|
|
2358
|
+
if (col.propertyKey === relatedPkName)
|
|
2359
|
+
relatedPkDb = db;
|
|
1034
2360
|
}
|
|
1035
2361
|
validColumns.add(relatedPkName);
|
|
1036
2362
|
validColumns.add(camelToSnake(relatedPkName));
|
|
2363
|
+
const dateCols = getDateColumnConfig(relatedClass);
|
|
2364
|
+
const consumeValue = (prop, value, model) => {
|
|
2365
|
+
const c = consumes.get(prop);
|
|
2366
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
2367
|
+
if (c)
|
|
2368
|
+
return c(value, prop, model);
|
|
2369
|
+
if (dateCols[prop] && value != null)
|
|
2370
|
+
return dateTimeAtlasAdapter.consume(value);
|
|
2371
|
+
return value;
|
|
2372
|
+
};
|
|
2373
|
+
// A repository for the related model so preloaded instances are hydrated with
|
|
2374
|
+
// the SAME lifecycle state as a direct query: `$isPersisted`/not-`$isNew`,
|
|
2375
|
+
// not-`$isLocal`, a clean dirty snapshot, and a REPO_REF backing
|
|
2376
|
+
// refresh()/fresh()/load()/related(). Without this a preloaded relation
|
|
2377
|
+
// looked $isNew/$isLocal/$dirty and a later save() over-updated it.
|
|
2378
|
+
const relatedRepo = new BaseRepository(relatedClass, this.#db, {
|
|
2379
|
+
dialect: this.#dialect,
|
|
2380
|
+
});
|
|
2381
|
+
// Propagate the domain-event bus so save()/create() from a preloaded relation
|
|
2382
|
+
// still dispatch events (a fresh repo has none by default).
|
|
2383
|
+
relatedRepo.onDomainEvents = this.#onDomainEvents;
|
|
1037
2384
|
const hydrate = (row) => {
|
|
1038
2385
|
const entity = new relatedClass();
|
|
1039
2386
|
for (const [key, value] of Object.entries(row)) {
|
|
1040
2387
|
const camelKey = snakeToCamel(key);
|
|
1041
|
-
const targetKey =
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
2388
|
+
const targetKey = byDbName.get(key) ??
|
|
2389
|
+
(validColumns.has(camelKey)
|
|
2390
|
+
? camelKey
|
|
2391
|
+
: validColumns.has(key)
|
|
2392
|
+
? key
|
|
2393
|
+
: null);
|
|
1046
2394
|
if (targetKey !== null)
|
|
1047
|
-
entity.setProp(targetKey, value);
|
|
2395
|
+
entity.setProp(targetKey, consumeValue(targetKey, value, entity));
|
|
1048
2396
|
}
|
|
2397
|
+
// Freeze the clean snapshot + mark persisted/from-DB, and back-reference
|
|
2398
|
+
// the related repo (mirrors BaseRepository.#hydrate).
|
|
2399
|
+
entity.markAsPersisted();
|
|
2400
|
+
entity.markAsFromDatabase();
|
|
2401
|
+
Object.defineProperty(entity, REPO_REF, {
|
|
2402
|
+
value: relatedRepo,
|
|
2403
|
+
enumerable: false,
|
|
2404
|
+
configurable: true,
|
|
2405
|
+
});
|
|
1049
2406
|
return entity;
|
|
1050
2407
|
};
|
|
1051
2408
|
return {
|
|
@@ -1053,7 +2410,9 @@ export class ModelQuery {
|
|
|
1053
2410
|
relationName,
|
|
1054
2411
|
relatedClass,
|
|
1055
2412
|
relatedTable: relatedMeta.tableName,
|
|
1056
|
-
|
|
2413
|
+
// DB column name (not property) — used as the WHERE column in the related
|
|
2414
|
+
// query AND to index the returned DB rows by their PK value.
|
|
2415
|
+
relatedPk: relatedPkDb,
|
|
1057
2416
|
hydrate,
|
|
1058
2417
|
runInQuery: (table, column, values) => this.#runInQuery(table, column, values),
|
|
1059
2418
|
runRelationQuery: (column, values) => this.#runRelationQuery(relatedMeta.tableName, relatedClass, column, values, relation, this.#preloads.get(relationName)),
|
|
@@ -1087,15 +2446,17 @@ export class ModelQuery {
|
|
|
1087
2446
|
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
1088
2447
|
}
|
|
1089
2448
|
const throughClass = relation.through();
|
|
1090
|
-
const throughMeta =
|
|
1091
|
-
if (!throughMeta)
|
|
1092
|
-
throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
|
|
2449
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1093
2450
|
const throughTable = throughMeta.tableName;
|
|
1094
2451
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1095
2452
|
const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
1096
2453
|
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1097
2454
|
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1098
|
-
|
|
2455
|
+
// secondLocal indexes the THROUGH row (`row[secondLocal]`), so it must be a
|
|
2456
|
+
// DB column — resolve the through model's key (default: its PK), honouring a
|
|
2457
|
+
// multi-word / columnName PK. (parentLocal stays a property: it's read off
|
|
2458
|
+
// the parent ENTITY, not a row.)
|
|
2459
|
+
const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
|
|
1099
2460
|
const parentIds = entities
|
|
1100
2461
|
.map((e) => e[parentLocal])
|
|
1101
2462
|
.filter((v) => v != null);
|
|
@@ -1224,7 +2585,10 @@ export class ModelQuery {
|
|
|
1224
2585
|
// on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
|
|
1225
2586
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1226
2587
|
const otherKey = pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1227
|
-
|
|
2588
|
+
// The pivot FK stores `parent[localKey]` (default PK) — attach() writes it,
|
|
2589
|
+
// so preload MUST read back with the SAME key, else a custom-localKey m2m
|
|
2590
|
+
// writes `user_code = code` but reads `user_code IN (id)` and never matches.
|
|
2591
|
+
const pk = ctx.relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
1228
2592
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
1229
2593
|
if (ids.length === 0)
|
|
1230
2594
|
return [];
|
|
@@ -1233,11 +2597,42 @@ export class ModelQuery {
|
|
|
1233
2597
|
// also runs (again) inside runRelationQuery against the related table; both
|
|
1234
2598
|
// runs are pure builder mutations, and pivot constraints are inert there.
|
|
1235
2599
|
const pivotWheres = [];
|
|
2600
|
+
// Query-time pivotColumns([...]) requested in the preload callback (replayed
|
|
2601
|
+
// on the scratch builder below) OR directly on this query (related().query()).
|
|
2602
|
+
const extraPivotCols = [...this.#extraPivotColumns];
|
|
1236
2603
|
if (ctx.nestedCallback) {
|
|
1237
|
-
const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, (
|
|
2604
|
+
const scratch = new _a(ctx.relatedTable, this.#db, (r) => r, ctx.relatedClass, buildColumnResolver(ctx.relatedClass), false, this.#dialect, buildValuePreparer(ctx.relatedClass));
|
|
1238
2605
|
ctx.nestedCallback(scratch);
|
|
1239
|
-
|
|
1240
|
-
|
|
2606
|
+
extraPivotCols.push(...scratch.pivotColumnRequests);
|
|
2607
|
+
// Apply the pivot column adapters' `prepare` to wherePivot values, so a
|
|
2608
|
+
// filter like wherePivot('amount', new Money(1)) matches what attach()/
|
|
2609
|
+
// sync() stored (they prepare the same extras on write).
|
|
2610
|
+
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
2611
|
+
for (const c of scratch.pivotConstraints) {
|
|
2612
|
+
const prep = pivotAdapters[c.column]?.prepare;
|
|
2613
|
+
// Same guards as the attach()/sync() write path: wrap a throwing
|
|
2614
|
+
// adapter with a column-annotated error and reject async adapters,
|
|
2615
|
+
// so filter and write agree on the adapter contract.
|
|
2616
|
+
const apply = (v) => {
|
|
2617
|
+
if (!prep)
|
|
2618
|
+
return v;
|
|
2619
|
+
let out;
|
|
2620
|
+
try {
|
|
2621
|
+
// Adonis Lucid signature: (value, attribute, model). wherePivot is
|
|
2622
|
+
// a query filter — attribute known, no model instance.
|
|
2623
|
+
out = prep(v, c.column, undefined);
|
|
2624
|
+
}
|
|
2625
|
+
catch (err) {
|
|
2626
|
+
throw wrapAdapterError("prepare", c.column, err);
|
|
2627
|
+
}
|
|
2628
|
+
assertNotPromise("prepare", c.column, out);
|
|
2629
|
+
return out;
|
|
2630
|
+
};
|
|
2631
|
+
const value = Array.isArray(c.value)
|
|
2632
|
+
? c.value.map(apply)
|
|
2633
|
+
: apply(c.value);
|
|
2634
|
+
pivotWheres.push({ ...c, value });
|
|
2635
|
+
}
|
|
1241
2636
|
}
|
|
1242
2637
|
// Step 1 — pivot table: find (foreignKey → otherKey) pairs (+ wherePivot)
|
|
1243
2638
|
const pivotRows = await this.#runInQuery(pivot.pivotTable, foreignKey, ids, pivotWheres);
|
|
@@ -1251,7 +2646,11 @@ export class ModelQuery {
|
|
|
1251
2646
|
];
|
|
1252
2647
|
// Step 2 — load all related entities in one query
|
|
1253
2648
|
const relRows = await ctx.runRelationQuery(ctx.relatedPk, otherIds);
|
|
1254
|
-
|
|
2649
|
+
// Declared pivot columns (decorator) + any requested at query time
|
|
2650
|
+
// (`pivotColumns([...])`, from the callback or this query), de-duplicated.
|
|
2651
|
+
const pivotCols = [
|
|
2652
|
+
...new Set([...(pivot.pivotColumns ?? []), ...extraPivotCols]),
|
|
2653
|
+
];
|
|
1255
2654
|
const pivotAdapters = pivot.pivotColumnAdapters ?? {};
|
|
1256
2655
|
// When pivot extras are projected, each (parent, related) edge gets its OWN
|
|
1257
2656
|
// hydrated instance so per-edge `$extras.pivot_<col>` values never clobber
|
|
@@ -1282,7 +2681,9 @@ export class ModelQuery {
|
|
|
1282
2681
|
for (const col of pivotCols) {
|
|
1283
2682
|
const rawVal = pivotRow[col];
|
|
1284
2683
|
const adapter = pivotAdapters[col];
|
|
1285
|
-
related.setExtra(`pivot_${col}`,
|
|
2684
|
+
related.setExtra(`pivot_${col}`,
|
|
2685
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
2686
|
+
adapter?.consume ? adapter.consume(rawVal, col, related) : rawVal);
|
|
1286
2687
|
}
|
|
1287
2688
|
allRelated.push(related);
|
|
1288
2689
|
}
|
|
@@ -1305,7 +2706,7 @@ export class ModelQuery {
|
|
|
1305
2706
|
async #applyNestedPreloads(relatedEntities, ctx) {
|
|
1306
2707
|
if (!ctx.nestedCallback || relatedEntities.length === 0)
|
|
1307
2708
|
return;
|
|
1308
|
-
const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass);
|
|
2709
|
+
const sub = new _a(ctx.relatedTable, this.#db, (r) => ctx.hydrate(r), ctx.relatedClass, buildColumnResolver(ctx.relatedClass), hasSoftDeletes(ctx.relatedClass), this.#dialect, buildValuePreparer(ctx.relatedClass));
|
|
1309
2710
|
ctx.nestedCallback(sub);
|
|
1310
2711
|
if (sub.#preloads.size > 0) {
|
|
1311
2712
|
await sub.#resolveAgainst(relatedEntities, ctx.relatedClass);
|
|
@@ -1316,12 +2717,22 @@ export class ModelQuery {
|
|
|
1316
2717
|
const wheres = [
|
|
1317
2718
|
{ column, operator: "IN", value: values, type: "and" },
|
|
1318
2719
|
];
|
|
1319
|
-
|
|
2720
|
+
// The caller's filters go in a parenthesised group, never flat beside the
|
|
2721
|
+
// `IN`. Flat, an `orWherePivot` would read as
|
|
2722
|
+
// `WHERE fk IN (parents) OR active = 1` and hand back rows belonging to
|
|
2723
|
+
// other parents; grouped, it is `WHERE fk IN (parents) AND (… OR …)`.
|
|
2724
|
+
// With every filter ANDed the two forms are equivalent, so this changes
|
|
2725
|
+
// no existing query.
|
|
2726
|
+
if (extraWheres.length > 0) {
|
|
1320
2727
|
wheres.push({
|
|
1321
|
-
|
|
1322
|
-
operator: w.operator,
|
|
1323
|
-
value: w.value,
|
|
2728
|
+
kind: "group",
|
|
1324
2729
|
type: "and",
|
|
2730
|
+
conditions: extraWheres.map((w) => ({
|
|
2731
|
+
column: w.column,
|
|
2732
|
+
operator: w.operator,
|
|
2733
|
+
value: w.value,
|
|
2734
|
+
type: w.type ?? "and",
|
|
2735
|
+
})),
|
|
1325
2736
|
});
|
|
1326
2737
|
}
|
|
1327
2738
|
const spec = {
|
|
@@ -1356,7 +2767,11 @@ export class ModelQuery {
|
|
|
1356
2767
|
* inside the callback are re-collected later by `#applyNestedPreloads`.
|
|
1357
2768
|
*/
|
|
1358
2769
|
async #runRelationQuery(relatedTable, relatedClass, column, values, relation, userCallback) {
|
|
1359
|
-
const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
|
|
2770
|
+
const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
|
|
2771
|
+
// Resolve columns + prepare values against the RELATED model so a preload
|
|
2772
|
+
// constraint (onQuery / callback) targeting a columnName-mapped or date
|
|
2773
|
+
// column compiles/binds like a direct query on that model.
|
|
2774
|
+
buildColumnResolver(relatedClass),
|
|
1360
2775
|
// Propagate the RELATED entity's soft-delete flag — hardcoding
|
|
1361
2776
|
// false here meant `preload('posts')` returned soft-deleted
|
|
1362
2777
|
// posts even when Post is @SoftDeletes (a data leak). The
|
|
@@ -1364,15 +2779,58 @@ export class ModelQuery {
|
|
|
1364
2779
|
// matching a direct query on that entity. (with-trashed on the
|
|
1365
2780
|
// related set, if ever needed, would be opted-in via the
|
|
1366
2781
|
// preload callback.)
|
|
1367
|
-
hasSoftDeletes(relatedClass), this.#dialect);
|
|
2782
|
+
hasSoftDeletes(relatedClass), this.#dialect, buildValuePreparer(relatedClass));
|
|
1368
2783
|
sub.whereIn(column, values);
|
|
1369
2784
|
if (relation.onQuery)
|
|
1370
2785
|
relation.onQuery(sub);
|
|
1371
2786
|
if (userCallback)
|
|
1372
2787
|
userCallback(sub);
|
|
1373
|
-
|
|
2788
|
+
// Top-N per parent (Lucid groupLimit) → a ROW_NUMBER() window over the fk.
|
|
2789
|
+
if (sub.#groupLimit != null) {
|
|
2790
|
+
return this.#runGroupLimited(sub, column, sub.#groupLimit);
|
|
2791
|
+
}
|
|
2792
|
+
const { sql, params } = sub.#compiledNative();
|
|
1374
2793
|
return this.#db.query(sql, params);
|
|
1375
2794
|
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Run a has-many preload query capped to `n` rows PER PARENT (Lucid
|
|
2797
|
+
* `groupLimit`). Wraps the inner query with a `ROW_NUMBER() OVER (PARTITION BY
|
|
2798
|
+
* <fk> ORDER BY <groupOrderBy | pk>)` window, then keeps `rn <= n`. Supported on
|
|
2799
|
+
* Postgres, MySQL 8+, and SQLite 3.25+ (all have window functions).
|
|
2800
|
+
*/
|
|
2801
|
+
async #runGroupLimited(sub, fkColumn, n) {
|
|
2802
|
+
const quoteSeg = (name) => {
|
|
2803
|
+
const qc = this.#dialect === "mysql" ? "`" : '"';
|
|
2804
|
+
return name
|
|
2805
|
+
.split(".")
|
|
2806
|
+
.map((s) => {
|
|
2807
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
|
|
2808
|
+
throw new Error(`groupLimit: unsafe identifier '${name}'`);
|
|
2809
|
+
}
|
|
2810
|
+
return `${qc}${s}${qc}`;
|
|
2811
|
+
})
|
|
2812
|
+
.join(".");
|
|
2813
|
+
};
|
|
2814
|
+
const orderCol = sub.#groupOrderBy
|
|
2815
|
+
? sub.#resolveColumn(sub.#groupOrderBy.column)
|
|
2816
|
+
: (getPrimaryKey(sub.#entityClass) ?? "id");
|
|
2817
|
+
const dir = sub.#groupOrderBy?.direction === "desc" ? "DESC" : "ASC";
|
|
2818
|
+
sub.#selectRaw.push({
|
|
2819
|
+
sql: `ROW_NUMBER() OVER (PARTITION BY ${quoteSeg(fkColumn)} ORDER BY ${quoteSeg(orderCol)} ${dir}) AS __atlas_rn`,
|
|
2820
|
+
params: [],
|
|
2821
|
+
});
|
|
2822
|
+
const inner = sub.#compiledNative();
|
|
2823
|
+
const ph = this.#dialect === "postgres" ? `$${inner.params.length + 1}` : "?";
|
|
2824
|
+
const outerSql = `SELECT * FROM (${inner.sql}) AS __atlas_grp WHERE __atlas_rn <= ${ph}`;
|
|
2825
|
+
const rows = await this.#db.query(outerSql, [
|
|
2826
|
+
...inner.params,
|
|
2827
|
+
n,
|
|
2828
|
+
]);
|
|
2829
|
+
// Strip the window helper column so it doesn't leak into hydration.
|
|
2830
|
+
for (const row of rows)
|
|
2831
|
+
delete row.__atlas_rn;
|
|
2832
|
+
return rows;
|
|
2833
|
+
}
|
|
1376
2834
|
/**
|
|
1377
2835
|
* Build a correlated subquery over a relation. Returns `SubqueryProjection`
|
|
1378
2836
|
* used by withCount / withAggregate. Default select is `COUNT(*)` for `'count'`
|
|
@@ -1403,31 +2861,50 @@ export class ModelQuery {
|
|
|
1403
2861
|
throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
|
|
1404
2862
|
}
|
|
1405
2863
|
const relatedClass = relation.target();
|
|
1406
|
-
const relatedMeta =
|
|
1407
|
-
if (!relatedMeta) {
|
|
1408
|
-
throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
|
|
1409
|
-
}
|
|
2864
|
+
const relatedMeta = ensureEntityMetadata(relatedClass);
|
|
1410
2865
|
const relatedTable = relatedMeta.tableName;
|
|
1411
2866
|
const parentPk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
1412
2867
|
const parentTable = this.#tableName;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
2868
|
+
// Strict single-segment identifier quote. This builds a RAW correlated
|
|
2869
|
+
// subquery fragment (no bind params for identifiers), so every segment must
|
|
2870
|
+
// be validated — a table/key from relation metadata carrying a quote/backtick
|
|
2871
|
+
// would otherwise emit invalid or injectable SQL. Same policy as
|
|
2872
|
+
// BaseRepository's lazy m2m path.
|
|
2873
|
+
const q = (name) => {
|
|
2874
|
+
if (!/^[A-Za-z0-9_]+$/.test(name)) {
|
|
2875
|
+
throw new Error(`Unsafe identifier in relation metadata: '${name}'`);
|
|
2876
|
+
}
|
|
2877
|
+
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
2878
|
+
};
|
|
2879
|
+
// Table identifiers may be schema-qualified (`schema.table`) — quote each
|
|
2880
|
+
// dotted segment on its own (`"schema"."table"`), else a Postgres pivot like
|
|
2881
|
+
// `public.users_roles` gets wrapped as ONE identifier and silently targets a
|
|
2882
|
+
// table literally named with a dot. Each segment still passes the strict
|
|
2883
|
+
// guard above. Columns stay single-segment via `q`.
|
|
2884
|
+
const qTable = (name) => name.split(".").map(q).join(".");
|
|
2885
|
+
const sub = new _a(relatedTable, this.#db, (row) => row, relatedClass,
|
|
2886
|
+
// whereHas/withCount constraints run against the RELATED model — resolve
|
|
2887
|
+
// its columns (columnName/multi-word) and prepare its values like a direct query.
|
|
2888
|
+
buildColumnResolver(relatedClass), false, this.#dialect, buildValuePreparer(relatedClass));
|
|
2889
|
+
// `localKey`/`ownerKey`/`secondLocalKey` are MODEL properties (default to a
|
|
2890
|
+
// PK); resolve each to its DB column via the owning model so a multi-word or
|
|
2891
|
+
// `@Column({ columnName })` key produces valid SQL. `foreignKey`/`otherKey`/
|
|
2892
|
+
// `firstKey`/`secondKey` are DB column names already — left as-is.
|
|
2893
|
+
const resolveParent = buildColumnResolver(this.#entityClass);
|
|
1417
2894
|
switch (relation.type) {
|
|
1418
2895
|
case "hasOne":
|
|
1419
2896
|
case "hasMany": {
|
|
1420
2897
|
// Honour custom foreignKey/localKey exactly like the eager loader —
|
|
1421
2898
|
// hard-coding them here produced silently-wrong whereHas/withCount SQL.
|
|
1422
2899
|
const fk = relation.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1423
|
-
const localKey = relation.localKey ?? parentPk;
|
|
1424
|
-
sub.#pushWhereRaw(`${
|
|
2900
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2901
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(fk)} = ${qTable(parentTable)}.${q(localKey)}`);
|
|
1425
2902
|
break;
|
|
1426
2903
|
}
|
|
1427
2904
|
case "belongsTo": {
|
|
1428
2905
|
const fk = relation.foreignKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1429
|
-
const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
1430
|
-
sub.#pushWhereRaw(`${
|
|
2906
|
+
const ownerKey = buildColumnResolver(relatedClass)(relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id");
|
|
2907
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(ownerKey)} = ${qTable(parentTable)}.${q(fk)}`);
|
|
1431
2908
|
break;
|
|
1432
2909
|
}
|
|
1433
2910
|
case "manyToMany": {
|
|
@@ -1439,11 +2916,12 @@ export class ModelQuery {
|
|
|
1439
2916
|
// name stripped of a trailing `s` — see the eager loader above.
|
|
1440
2917
|
const foreignKey = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1441
2918
|
const otherKey = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1442
|
-
const
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
`
|
|
2919
|
+
const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
|
|
2920
|
+
const relatedPk = getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPkProp)?.columnName ?? camelToSnake(relatedPkProp);
|
|
2921
|
+
const localKey = resolveParent(relation.localKey ?? parentPk);
|
|
2922
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(relatedPk)} IN ` +
|
|
2923
|
+
`(SELECT ${q(otherKey)} FROM ${qTable(pivot.pivotTable)} ` +
|
|
2924
|
+
`WHERE ${qTable(pivot.pivotTable)}.${q(foreignKey)} = ${qTable(parentTable)}.${q(localKey)})`);
|
|
1447
2925
|
break;
|
|
1448
2926
|
}
|
|
1449
2927
|
case "hasOneThrough":
|
|
@@ -1455,36 +2933,45 @@ export class ModelQuery {
|
|
|
1455
2933
|
throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
|
|
1456
2934
|
}
|
|
1457
2935
|
const throughClass = relation.through();
|
|
1458
|
-
const throughMeta =
|
|
1459
|
-
if (!throughMeta) {
|
|
1460
|
-
throw new Error(`Entity metadata missing on through class ${throughClass.name}`);
|
|
1461
|
-
}
|
|
2936
|
+
const throughMeta = ensureEntityMetadata(throughClass);
|
|
1462
2937
|
const throughTable = throughMeta.tableName;
|
|
1463
2938
|
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
1464
|
-
const parentLocal = relation.localKey ?? parentPk;
|
|
2939
|
+
const parentLocal = resolveParent(relation.localKey ?? parentPk);
|
|
1465
2940
|
const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1466
2941
|
const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
1467
|
-
const secondLocal = relation.secondLocalKey ?? throughPk;
|
|
1468
|
-
sub.#pushWhereRaw(`${
|
|
1469
|
-
`(SELECT ${q(secondLocal)} FROM ${
|
|
1470
|
-
`WHERE ${
|
|
2942
|
+
const secondLocal = buildColumnResolver(throughClass)(relation.secondLocalKey ?? throughPk);
|
|
2943
|
+
sub.#pushWhereRaw(`${qTable(relatedTable)}.${q(secondKey)} IN ` +
|
|
2944
|
+
`(SELECT ${q(secondLocal)} FROM ${qTable(throughTable)} ` +
|
|
2945
|
+
`WHERE ${qTable(throughTable)}.${q(firstKey)} = ${qTable(parentTable)}.${q(parentLocal)})`);
|
|
1471
2946
|
break;
|
|
1472
2947
|
}
|
|
1473
2948
|
}
|
|
1474
2949
|
return sub;
|
|
1475
2950
|
}
|
|
1476
|
-
|
|
1477
|
-
return this.#pushJoin("INNER", table, leftOrBuild, right);
|
|
2951
|
+
join(table, leftOrBuild, operatorOrRight, right) {
|
|
2952
|
+
return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
|
|
2953
|
+
}
|
|
2954
|
+
innerJoin(table, leftOrBuild, operatorOrRight, right) {
|
|
2955
|
+
return this.#pushJoin("INNER", table, leftOrBuild, operatorOrRight, right);
|
|
1478
2956
|
}
|
|
1479
|
-
leftJoin(table, leftOrBuild, right) {
|
|
1480
|
-
return this.#pushJoin("LEFT", table, leftOrBuild, right);
|
|
2957
|
+
leftJoin(table, leftOrBuild, operatorOrRight, right) {
|
|
2958
|
+
return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
|
|
1481
2959
|
}
|
|
1482
|
-
|
|
1483
|
-
return this.#pushJoin("
|
|
2960
|
+
leftOuterJoin(table, leftOrBuild, operatorOrRight, right) {
|
|
2961
|
+
return this.#pushJoin("LEFT", table, leftOrBuild, operatorOrRight, right);
|
|
2962
|
+
}
|
|
2963
|
+
rightJoin(table, leftOrBuild, operatorOrRight, right) {
|
|
2964
|
+
return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
|
|
2965
|
+
}
|
|
2966
|
+
rightOuterJoin(table, leftOrBuild, operatorOrRight, right) {
|
|
2967
|
+
return this.#pushJoin("RIGHT", table, leftOrBuild, operatorOrRight, right);
|
|
2968
|
+
}
|
|
2969
|
+
fullOuterJoin(table, leftOrBuild, operatorOrRight, right) {
|
|
2970
|
+
return this.#pushJoin("FULL OUTER", table, leftOrBuild, operatorOrRight, right);
|
|
1484
2971
|
}
|
|
1485
2972
|
crossJoin(table) {
|
|
1486
|
-
const tq = this.#
|
|
1487
|
-
this.#joins.push(`CROSS JOIN ${tq}
|
|
2973
|
+
const tq = this.#quoteCol(table);
|
|
2974
|
+
this.#joins.push({ sql: `CROSS JOIN ${tq}`, params: [] });
|
|
1488
2975
|
return this;
|
|
1489
2976
|
}
|
|
1490
2977
|
/**
|
|
@@ -1503,12 +2990,12 @@ export class ModelQuery {
|
|
|
1503
2990
|
*
|
|
1504
2991
|
* @unsafe Raw SQL fragment — never concatenate user input into `fragment`.
|
|
1505
2992
|
*/
|
|
1506
|
-
joinRaw(fragment) {
|
|
2993
|
+
joinRaw(fragment, bindings = []) {
|
|
1507
2994
|
if (isAtlasStrictMode() && !isInternalBypass()) {
|
|
1508
2995
|
throw new Error("joinRaw() is disabled in Atlas strict mode. " +
|
|
1509
2996
|
"Use joinOn() or the callback form of innerJoin/leftJoin/rightJoin instead.");
|
|
1510
2997
|
}
|
|
1511
|
-
this.#joins.push(fragment);
|
|
2998
|
+
this.#joins.push({ sql: fragment, params: [...bindings] });
|
|
1512
2999
|
return this;
|
|
1513
3000
|
}
|
|
1514
3001
|
/**
|
|
@@ -1526,21 +3013,56 @@ export class ModelQuery {
|
|
|
1526
3013
|
return this.innerJoin(table, left, right);
|
|
1527
3014
|
}
|
|
1528
3015
|
// === Story 29.5 — aggregates / exists / pluck =====================================================
|
|
1529
|
-
|
|
3016
|
+
/**
|
|
3017
|
+
* SELECT DISTINCT (Lucid/Knex `distinct`). With columns, those replace the
|
|
3018
|
+
* projection too — `distinct('a', 'b')` ≈ `SELECT DISTINCT a, b`.
|
|
3019
|
+
*/
|
|
3020
|
+
distinct(...columns) {
|
|
1530
3021
|
this.#distinct = true;
|
|
3022
|
+
if (columns.length > 0) {
|
|
3023
|
+
this.#select = columns.map((c) => this.#resolveSelect(c));
|
|
3024
|
+
}
|
|
3025
|
+
return this;
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* `SELECT DISTINCT ON (cols) …` — keep the first row per distinct set of
|
|
3029
|
+
* `columns` (Lucid/Knex `distinctOn`). Takes precedence over
|
|
3030
|
+
* {@link distinct}.
|
|
3031
|
+
*
|
|
3032
|
+
* Postgres-only, and the compiler refuses it elsewhere: MySQL and SQLite
|
|
3033
|
+
* would parse `DISTINCT (a, b)` as a plain DISTINCT over a row value and
|
|
3034
|
+
* return a *different* result set rather than fail — a silent wrong answer
|
|
3035
|
+
* is worse than an error.
|
|
3036
|
+
*
|
|
3037
|
+
* Postgres also requires the leading `ORDER BY` terms to match `columns`;
|
|
3038
|
+
* that is left to the database to enforce.
|
|
3039
|
+
*/
|
|
3040
|
+
distinctOn(...columns) {
|
|
3041
|
+
for (const c of columns)
|
|
3042
|
+
this.#distinctOn.push(this.#resolveColumn(c));
|
|
1531
3043
|
return this;
|
|
1532
3044
|
}
|
|
1533
3045
|
/** `SELECT COUNT(DISTINCT col)`. */
|
|
1534
3046
|
async countDistinct(column) {
|
|
1535
|
-
return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(column)})`)) ?? 0);
|
|
3047
|
+
return Number((await this.#runScalar(`COUNT(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`)) ?? 0);
|
|
3048
|
+
}
|
|
3049
|
+
/** `SUM(DISTINCT col)` (Lucid parity). */
|
|
3050
|
+
async sumDistinct(column) {
|
|
3051
|
+
const v = await this.#runScalar(`SUM(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
3052
|
+
return v === null || v === undefined ? null : Number(v);
|
|
3053
|
+
}
|
|
3054
|
+
/** `AVG(DISTINCT col)` (Lucid parity). */
|
|
3055
|
+
async avgDistinct(column) {
|
|
3056
|
+
const v = await this.#runScalar(`AVG(DISTINCT ${this.#quoteCol(this.#resolveColumn(column))})`);
|
|
3057
|
+
return v === null || v === undefined ? null : Number(v);
|
|
1536
3058
|
}
|
|
1537
3059
|
/** `SELECT 1 FROM ... LIMIT 1` — returns boolean. */
|
|
1538
3060
|
async exists() {
|
|
1539
3061
|
const clone = this.clone();
|
|
1540
3062
|
clone.#select = ["1"];
|
|
1541
3063
|
clone.#limit = 1;
|
|
1542
|
-
const { sql, params } = clone
|
|
1543
|
-
const rows = await this.#db.query(sql, params);
|
|
3064
|
+
const { sql, params } = clone.#compiledNative();
|
|
3065
|
+
const rows = await this.#db.query(sql, params, this.#meta("exists"));
|
|
1544
3066
|
return rows.length > 0;
|
|
1545
3067
|
}
|
|
1546
3068
|
async doesntExist() {
|
|
@@ -1551,7 +3073,7 @@ export class ModelQuery {
|
|
|
1551
3073
|
const col = this.#resolveColumn(column);
|
|
1552
3074
|
const clone = this.clone();
|
|
1553
3075
|
clone.#select = [col];
|
|
1554
|
-
const { sql, params } = clone
|
|
3076
|
+
const { sql, params } = clone.#compiledNative();
|
|
1555
3077
|
const rows = await this.#db.query(sql, params);
|
|
1556
3078
|
return rows.map((row) => {
|
|
1557
3079
|
const v = row[col];
|
|
@@ -1596,9 +3118,11 @@ export class ModelQuery {
|
|
|
1596
3118
|
elseFn(this);
|
|
1597
3119
|
return this;
|
|
1598
3120
|
}
|
|
1599
|
-
unless(condition, fn) {
|
|
3121
|
+
unless(condition, fn, elseFn) {
|
|
1600
3122
|
if (!condition)
|
|
1601
3123
|
fn(this);
|
|
3124
|
+
else if (elseFn)
|
|
3125
|
+
elseFn(this);
|
|
1602
3126
|
return this;
|
|
1603
3127
|
}
|
|
1604
3128
|
// === Story 29.10 — pagination =====================================================================
|
|
@@ -1606,26 +3130,53 @@ export class ModelQuery {
|
|
|
1606
3130
|
async paginate(page, perPage) {
|
|
1607
3131
|
const p = Math.max(1, Math.floor(page));
|
|
1608
3132
|
const pp = Math.max(1, Math.floor(perPage));
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
|
|
1612
|
-
//
|
|
3133
|
+
// Adonis Lucid hook order:
|
|
3134
|
+
// beforePaginate([countQuery, query]) → beforeFetch(query)
|
|
3135
|
+
// → (count + data queries) → afterPaginate(paginator) → afterFetch(rows)
|
|
3136
|
+
// The COUNT and data builders are separate clones so a beforePaginate hook
|
|
3137
|
+
// can constrain BOTH (keeping totals in sync), exactly as documented.
|
|
1613
3138
|
const countQ = this.clone();
|
|
1614
|
-
|
|
3139
|
+
const dataQ = this.clone();
|
|
3140
|
+
await fireHooks(this.#entityClass, "beforePaginate", [countQ, dataQ]);
|
|
3141
|
+
// beforeFetch fires on the main (data) query before either query runs.
|
|
3142
|
+
await fireHooks(this.#entityClass, "beforeFetch", dataQ);
|
|
3143
|
+
// COUNT(*) — strip pagination/order noise from the count clone.
|
|
1615
3144
|
countQ.#limit = undefined;
|
|
1616
3145
|
countQ.#offset = undefined;
|
|
1617
3146
|
countQ.#orderBys = [];
|
|
1618
|
-
|
|
1619
|
-
|
|
3147
|
+
let cSql;
|
|
3148
|
+
let cParams;
|
|
3149
|
+
if (countQ.#groupBy.length > 0) {
|
|
3150
|
+
// A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP (each the
|
|
3151
|
+
// group's own size), so `rows[0].count` would be the first group's size, not
|
|
3152
|
+
// the number of pages. Lucid counts via a subquery: wrap the grouped query
|
|
3153
|
+
// (select + groupBy + having preserved) and count its rows = group count.
|
|
3154
|
+
const inner = countQ.#compiledNative();
|
|
3155
|
+
cSql = `SELECT COUNT(*) AS count FROM (${inner.sql}) AS __paginate_count`;
|
|
3156
|
+
cParams = inner.params;
|
|
3157
|
+
}
|
|
3158
|
+
else {
|
|
3159
|
+
countQ.#select = ["COUNT(*) AS count"];
|
|
3160
|
+
const flat = countQ.#compiledNative();
|
|
3161
|
+
cSql = flat.sql;
|
|
3162
|
+
cParams = flat.params;
|
|
3163
|
+
}
|
|
3164
|
+
const cRows = await this.#db.query(cSql, cParams, this.#meta("paginate"));
|
|
1620
3165
|
const total = Number(cRows[0]?.count ?? 0);
|
|
1621
|
-
const dataQ = this.clone();
|
|
1622
3166
|
dataQ.#limit = pp;
|
|
1623
3167
|
dataQ.#offset = (p - 1) * pp;
|
|
1624
|
-
// `#doExec`
|
|
1625
|
-
//
|
|
3168
|
+
// `#doExec` runs the raw fetch + preloads. beforeFetch already fired above;
|
|
3169
|
+
// afterFetch fires AFTER afterPaginate (Lucid order), so #doExec must not
|
|
3170
|
+
// fire either itself — hence #doExec, not exec().
|
|
1626
3171
|
const items = await dataQ.#doExec();
|
|
1627
|
-
|
|
1628
|
-
|
|
3172
|
+
const metaKeys = this.#entityClass
|
|
3173
|
+
? getNamingStrategy(this.#entityClass).paginationMetaKeys?.()
|
|
3174
|
+
: undefined;
|
|
3175
|
+
const paginator = new Paginator(items, { total, perPage: pp, currentPage: p }, metaKeys);
|
|
3176
|
+
// Lucid: afterPaginate(paginator) then afterFetch(rows).
|
|
3177
|
+
await fireHooks(this.#entityClass, "afterPaginate", paginator);
|
|
3178
|
+
await fireHooks(this.#entityClass, "afterFetch", items);
|
|
3179
|
+
return paginator;
|
|
1629
3180
|
}
|
|
1630
3181
|
/**
|
|
1631
3182
|
* Cursor-based pagination — base64 opaque keyset, multi-column aware.
|
|
@@ -1641,7 +3192,13 @@ export class ModelQuery {
|
|
|
1641
3192
|
* accepts row-value comparisons.
|
|
1642
3193
|
*/
|
|
1643
3194
|
async cursorPaginate(opts) {
|
|
1644
|
-
|
|
3195
|
+
// Keep BOTH forms: `props` (model property names) to read the cursor value
|
|
3196
|
+
// off the hydrated entity, and `cols` (resolved DB columns) for the SQL
|
|
3197
|
+
// ORDER BY / WHERE. Mixing them up made a columnName/camelCase order key
|
|
3198
|
+
// encode `undefined` into the cursor (entity exposes the property, not the
|
|
3199
|
+
// DB column) — an unstable / stuck cursor.
|
|
3200
|
+
const props = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy];
|
|
3201
|
+
const cols = props.map((c) => this.#resolveColumn(c));
|
|
1645
3202
|
if (cols.length === 0)
|
|
1646
3203
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
1647
3204
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
@@ -1685,7 +3242,7 @@ export class ModelQuery {
|
|
|
1685
3242
|
const items = hasMore ? rows.slice(0, lim) : rows;
|
|
1686
3243
|
const last = items[items.length - 1];
|
|
1687
3244
|
const nextCursor = hasMore && last
|
|
1688
|
-
? Buffer.from(JSON.stringify({ v:
|
|
3245
|
+
? Buffer.from(JSON.stringify({ v: props.map((p) => last[p]) })).toString("base64")
|
|
1689
3246
|
: null;
|
|
1690
3247
|
return { items, nextCursor, hasMore };
|
|
1691
3248
|
}
|
|
@@ -1702,75 +3259,178 @@ export class ModelQuery {
|
|
|
1702
3259
|
this.#debugFlag = flag;
|
|
1703
3260
|
return this;
|
|
1704
3261
|
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Attach arbitrary metadata to the `db:query` event this query emits (Adonis
|
|
3264
|
+
* Lucid `reporterData`) — request id, user id, feature flag, … A listener
|
|
3265
|
+
* reads it off `event.reporterData`. Repeated calls merge. Setting it also
|
|
3266
|
+
* forces emission (like {@link debug}), so the data actually reaches a listener.
|
|
3267
|
+
*/
|
|
3268
|
+
reporterData(data) {
|
|
3269
|
+
this.#reporterData = { ...this.#reporterData, ...data };
|
|
3270
|
+
this.#debugFlag = true;
|
|
3271
|
+
return this;
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Context attached to each statement this query runs, so a `db:query`
|
|
3275
|
+
* listener can say which model and which call produced it — and so
|
|
3276
|
+
* {@link debug} can force emission for this query alone.
|
|
3277
|
+
*
|
|
3278
|
+
* Note the connection's own `debug: true` emits every statement regardless;
|
|
3279
|
+
* `meta` only enriches the event and opens the per-query override.
|
|
3280
|
+
*/
|
|
3281
|
+
#meta(method) {
|
|
3282
|
+
return {
|
|
3283
|
+
model: this.#entityClass.name,
|
|
3284
|
+
method,
|
|
3285
|
+
debug: this.#debugFlag,
|
|
3286
|
+
reporterData: this.#reporterData,
|
|
3287
|
+
serverTimeoutMs: this.#cancelTimeout && this.#timeoutMs != null
|
|
3288
|
+
? this.#timeoutMs
|
|
3289
|
+
: undefined,
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
1705
3292
|
/** Returns the compiled SQL with bindings interpolated as dialect-safe literals. */
|
|
1706
3293
|
toQuery() {
|
|
1707
|
-
const { sql, params } = this
|
|
1708
|
-
|
|
1709
|
-
return sql.replace(/\?|\$\d+/g, () => {
|
|
1710
|
-
const v = params[i++];
|
|
1711
|
-
return this.#literalEscape(v);
|
|
1712
|
-
});
|
|
3294
|
+
const { sql, params } = this.#compiledNative();
|
|
3295
|
+
return interpolateQuery(sql, params);
|
|
1713
3296
|
}
|
|
1714
3297
|
/** Deep clone of this query — mutations on the clone never affect the original. */
|
|
1715
3298
|
clone() {
|
|
1716
|
-
const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect);
|
|
3299
|
+
const c = new _a(this.#tableName, this.#db, this.#hydrateFn, this.#entityClass, this.#resolveColumn, this.#softDeletes, this.#dialect, this.#prepareValue, this.#onDomainEvents);
|
|
1717
3300
|
c.#softScope = this.#softScope;
|
|
1718
3301
|
c.#wheres = structuredCloneSafe(this.#wheres);
|
|
1719
3302
|
c.#orderBys = [...this.#orderBys];
|
|
1720
3303
|
c.#select = [...this.#select];
|
|
1721
3304
|
c.#limit = this.#limit;
|
|
3305
|
+
c.#groupLimit = this.#groupLimit;
|
|
3306
|
+
c.#groupOrderBy = this.#groupOrderBy
|
|
3307
|
+
? { ...this.#groupOrderBy }
|
|
3308
|
+
: undefined;
|
|
1722
3309
|
c.#offset = this.#offset;
|
|
1723
3310
|
c.#preloads = new Map(this.#preloads);
|
|
3311
|
+
c.#rowTransformers = [...this.#rowTransformers];
|
|
1724
3312
|
c.#selectSubqueries = structuredClone(this.#selectSubqueries);
|
|
1725
|
-
c.#
|
|
3313
|
+
c.#selectRaw = this.#selectRaw.map((s) => ({
|
|
3314
|
+
...s,
|
|
3315
|
+
params: [...s.params],
|
|
3316
|
+
}));
|
|
3317
|
+
c.#timeoutMs = this.#timeoutMs;
|
|
3318
|
+
c.#cancelTimeout = this.#cancelTimeout;
|
|
3319
|
+
c.#joins = this.#joins.map((j) => ({ sql: j.sql, params: [...j.params] }));
|
|
1726
3320
|
c.#lockMode = this.#lockMode;
|
|
3321
|
+
c.#lockModifier = this.#lockModifier;
|
|
3322
|
+
c.#sideloaded = this.#sideloaded ? { ...this.#sideloaded } : null;
|
|
1727
3323
|
c.#distinct = this.#distinct;
|
|
3324
|
+
c.#distinctOn = [...this.#distinctOn];
|
|
1728
3325
|
c.#groupBy = [...this.#groupBy];
|
|
1729
3326
|
c.#having = structuredCloneSafe(this.#having);
|
|
1730
|
-
c.#ctes = this.#ctes.map((e) => ({
|
|
3327
|
+
c.#ctes = this.#ctes.map((e) => ({
|
|
3328
|
+
name: e.name,
|
|
3329
|
+
query: e.query.clone(),
|
|
3330
|
+
recursive: e.recursive,
|
|
3331
|
+
materialized: e.materialized,
|
|
3332
|
+
columns: e.columns ? [...e.columns] : undefined,
|
|
3333
|
+
}));
|
|
1731
3334
|
c.#unions = this.#unions.map((u) => ({
|
|
1732
3335
|
query: u.query.clone(),
|
|
1733
3336
|
all: u.all,
|
|
3337
|
+
op: u.op,
|
|
1734
3338
|
}));
|
|
1735
3339
|
c.#pivotWheres = structuredCloneSafe(this.#pivotWheres);
|
|
3340
|
+
c.#extraPivotColumns = [...this.#extraPivotColumns];
|
|
3341
|
+
// Pure closure over pivot metadata — safe to share by reference; it reads the
|
|
3342
|
+
// clone's own #pivotWheres at build time (passed in), holding no query state.
|
|
3343
|
+
c.#pivotExists = this.#pivotExists;
|
|
1736
3344
|
c.#debugFlag = this.#debugFlag;
|
|
3345
|
+
c.#reporterData = this.#reporterData
|
|
3346
|
+
? { ...this.#reporterData }
|
|
3347
|
+
: undefined;
|
|
3348
|
+
c.#comments = [...this.#comments];
|
|
1737
3349
|
return c;
|
|
1738
3350
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
3351
|
+
update(patchOrColumn, valueOrReturning) {
|
|
3352
|
+
const patch = typeof patchOrColumn === "string"
|
|
3353
|
+
? { [patchOrColumn]: valueOrReturning }
|
|
3354
|
+
: patchOrColumn;
|
|
3355
|
+
const returning = typeof patchOrColumn === "string"
|
|
3356
|
+
? undefined
|
|
3357
|
+
: valueOrReturning;
|
|
1742
3358
|
if (!patch || Object.keys(patch).length === 0) {
|
|
1743
3359
|
throw new Error("update() requires a non-empty payload");
|
|
1744
3360
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
3361
|
+
// Lower each value through prepare (DateTime → ISO, @Column adapters) exactly
|
|
3362
|
+
// like BaseRepository's write paths — the fluent update() must not bypass it.
|
|
3363
|
+
// A `db.raw(...)` value is emitted as a raw SET expression instead.
|
|
3364
|
+
const setPairs = Object.entries(patch).map(([k, v]) => v instanceof RawSql
|
|
3365
|
+
? [this.#resolveColumn(k), { raw: v.sql, rawParams: [...v.params] }]
|
|
3366
|
+
: [this.#resolveColumn(k), this.#prepareValue(k, v)]);
|
|
3367
|
+
return this.#makeDml((r) => ({
|
|
1747
3368
|
kind: "update",
|
|
1748
3369
|
table: this.#tableName,
|
|
1749
3370
|
set: setPairs,
|
|
1750
3371
|
wheres: this.#wheresForDml(),
|
|
1751
|
-
returning:
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
if (returning && returning.length > 0) {
|
|
1755
|
-
return this.#db.query(compiled.statements[0], compiled.params);
|
|
1756
|
-
}
|
|
1757
|
-
const r = await this.#db.execute(compiled.statements[0], compiled.params);
|
|
1758
|
-
return r.rowsAffected ?? 0;
|
|
3372
|
+
returning: r,
|
|
3373
|
+
ctes: this.#compiledCtes(),
|
|
3374
|
+
}), returning);
|
|
1759
3375
|
}
|
|
1760
|
-
/**
|
|
1761
|
-
|
|
1762
|
-
|
|
3376
|
+
/**
|
|
3377
|
+
* Execute a fluent DELETE. For a `@SoftDeletes` model this SOFT-deletes the
|
|
3378
|
+
* scoped rows (stamps `deleted_at`) — consistent with the entity-level
|
|
3379
|
+
* `delete()`; use {@link forceDelete} for a hard `DELETE`. For a non-soft-delete
|
|
3380
|
+
* model it issues a hard `DELETE`. Returns affected rows (or rows when
|
|
3381
|
+
* `returning` is set).
|
|
3382
|
+
*/
|
|
3383
|
+
delete(returning) {
|
|
3384
|
+
if (this.#softDeletes) {
|
|
3385
|
+
const stampedAt = new Date().toISOString();
|
|
3386
|
+
return this.#makeDml((r) => ({
|
|
3387
|
+
kind: "update",
|
|
3388
|
+
table: this.#tableName,
|
|
3389
|
+
set: [[this.#deletedAtColumn(), stampedAt]],
|
|
3390
|
+
wheres: this.#wheresForDml(),
|
|
3391
|
+
returning: r,
|
|
3392
|
+
ctes: this.#compiledCtes(),
|
|
3393
|
+
}), returning);
|
|
3394
|
+
}
|
|
3395
|
+
return this.forceDelete(returning);
|
|
3396
|
+
}
|
|
3397
|
+
/** Hard `DELETE` of the scoped rows, bypassing `@SoftDeletes` (AdonisJS/Lucid `forceDelete`). */
|
|
3398
|
+
forceDelete(returning) {
|
|
3399
|
+
return this.#makeDml((r) => ({
|
|
1763
3400
|
kind: "delete",
|
|
1764
3401
|
table: this.#tableName,
|
|
1765
3402
|
wheres: this.#wheresForDml(),
|
|
1766
|
-
returning:
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
3403
|
+
returning: r,
|
|
3404
|
+
ctes: this.#compiledCtes(),
|
|
3405
|
+
}), returning);
|
|
3406
|
+
}
|
|
3407
|
+
/**
|
|
3408
|
+
* Bulk restore: clear `deleted_at` on the trashed rows matching the user's
|
|
3409
|
+
* predicates (the soft-delete counterpart of {@link delete}). No-op count `0`
|
|
3410
|
+
* on a non-soft-delete model. Independent of the current soft-scope — it always
|
|
3411
|
+
* targets trashed rows (`deleted_at IS NOT NULL`).
|
|
3412
|
+
*/
|
|
3413
|
+
restore(returning) {
|
|
3414
|
+
if (!this.#softDeletes) {
|
|
3415
|
+
return new DmlBuilder(() => Promise.resolve(0), () => ({ sql: "", bindings: [], params: [] }), this.#dmlHooks());
|
|
1771
3416
|
}
|
|
1772
|
-
|
|
1773
|
-
|
|
3417
|
+
return this.#makeDml((r) => {
|
|
3418
|
+
const wheres = this.#userWheresForDml();
|
|
3419
|
+
wheres.push({
|
|
3420
|
+
column: this.#deletedAtColumn(),
|
|
3421
|
+
operator: "IS NOT NULL",
|
|
3422
|
+
value: null,
|
|
3423
|
+
type: "and",
|
|
3424
|
+
});
|
|
3425
|
+
return {
|
|
3426
|
+
kind: "update",
|
|
3427
|
+
table: this.#tableName,
|
|
3428
|
+
set: [[this.#deletedAtColumn(), null]],
|
|
3429
|
+
wheres,
|
|
3430
|
+
returning: r,
|
|
3431
|
+
ctes: this.#compiledCtes(),
|
|
3432
|
+
};
|
|
3433
|
+
}, returning);
|
|
1774
3434
|
}
|
|
1775
3435
|
increment(colOrPatch, amount = 1) {
|
|
1776
3436
|
return this.#runIncDec("increment", colOrPatch, amount);
|
|
@@ -1797,57 +3457,237 @@ export class ModelQuery {
|
|
|
1797
3457
|
}
|
|
1798
3458
|
return this;
|
|
1799
3459
|
}
|
|
3460
|
+
/** Postgres `FOR NO KEY UPDATE` — a weaker lock that doesn't block FK checks (AdonisJS/Knex). */
|
|
3461
|
+
forNoKeyUpdate() {
|
|
3462
|
+
if (this.#dialect === "postgres") {
|
|
3463
|
+
this.#lockMode = "FOR NO KEY UPDATE";
|
|
3464
|
+
}
|
|
3465
|
+
else {
|
|
3466
|
+
console.warn(`[atlas] forNoKeyUpdate ignored on ${this.#dialect} (Postgres-only lock)`);
|
|
3467
|
+
}
|
|
3468
|
+
return this;
|
|
3469
|
+
}
|
|
3470
|
+
/** Postgres `FOR KEY SHARE` — the weakest share lock (AdonisJS/Knex). */
|
|
3471
|
+
forKeyShare() {
|
|
3472
|
+
if (this.#dialect === "postgres") {
|
|
3473
|
+
this.#lockMode = "FOR KEY SHARE";
|
|
3474
|
+
}
|
|
3475
|
+
else {
|
|
3476
|
+
console.warn(`[atlas] forKeyShare ignored on ${this.#dialect} (Postgres-only lock)`);
|
|
3477
|
+
}
|
|
3478
|
+
return this;
|
|
3479
|
+
}
|
|
3480
|
+
/**
|
|
3481
|
+
* Append `SKIP LOCKED` to the lock clause — locked rows are skipped instead of
|
|
3482
|
+
* waited on (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
3483
|
+
*/
|
|
3484
|
+
skipLocked() {
|
|
3485
|
+
if (this.#dialect === "sqlite") {
|
|
3486
|
+
console.warn("[atlas] skipLocked ignored on sqlite (no row-level lock)");
|
|
3487
|
+
}
|
|
3488
|
+
else {
|
|
3489
|
+
this.#lockModifier = "SKIP LOCKED";
|
|
3490
|
+
}
|
|
3491
|
+
return this;
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* Append `NOWAIT` to the lock clause — error immediately instead of waiting on
|
|
3495
|
+
* a locked row (AdonisJS/Knex). Requires a base lock (`forUpdate`/`forShare`/…).
|
|
3496
|
+
*/
|
|
3497
|
+
noWait() {
|
|
3498
|
+
if (this.#dialect === "sqlite") {
|
|
3499
|
+
console.warn("[atlas] noWait ignored on sqlite (no row-level lock)");
|
|
3500
|
+
}
|
|
3501
|
+
else {
|
|
3502
|
+
this.#lockModifier = "NOWAIT";
|
|
3503
|
+
}
|
|
3504
|
+
return this;
|
|
3505
|
+
}
|
|
1800
3506
|
// === Private helpers ==============================================================================
|
|
1801
3507
|
#quote(name) {
|
|
1802
3508
|
return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
1803
3509
|
}
|
|
1804
3510
|
/** Quote a `table.column` reference on both sides of the dot. */
|
|
1805
3511
|
#quoteCol(ref) {
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
3512
|
+
// Validate BEFORE quoting — `#quote` only wraps in quotes/backticks, so an
|
|
3513
|
+
// identifier smuggling a `"`/backtick would break out of the quoting on the
|
|
3514
|
+
// join path (which the Rust screen doesn't re-validate). Strict
|
|
3515
|
+
// `[[schema.]table.]column` grammar (up to 3 dot segments); keeps join
|
|
3516
|
+
// helpers injection-safe. Use joinRaw() for anything more complex.
|
|
3517
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}$/.test(ref)) {
|
|
3518
|
+
throw new Error(`Invalid join/column identifier '${ref}' — expected [[schema.]table.]column (letters, digits, underscore). Use joinRaw() for anything else.`);
|
|
3519
|
+
}
|
|
3520
|
+
return ref
|
|
3521
|
+
.split(".")
|
|
3522
|
+
.map((seg) => this.#quote(seg))
|
|
3523
|
+
.join(".");
|
|
3524
|
+
}
|
|
3525
|
+
#pushJoin(kind, table, leftOrBuild, operatorOrRight, right) {
|
|
3526
|
+
const tq = this.#quoteCol(table);
|
|
1814
3527
|
if (typeof leftOrBuild === "function") {
|
|
3528
|
+
const parts = [];
|
|
1815
3529
|
const jb = {
|
|
1816
|
-
parts
|
|
1817
|
-
on(l, r) {
|
|
1818
|
-
|
|
1819
|
-
|
|
3530
|
+
parts,
|
|
3531
|
+
on: (l, opOrR, r) => {
|
|
3532
|
+
parts.push(r === undefined
|
|
3533
|
+
? { kind: "and", left: l, right: opOrR }
|
|
3534
|
+
: { kind: "and", left: l, operator: opOrR, right: r });
|
|
3535
|
+
return jb;
|
|
3536
|
+
},
|
|
3537
|
+
andOn: (l, opOrR, r) => {
|
|
3538
|
+
parts.push(r === undefined
|
|
3539
|
+
? { kind: "and", left: l, right: opOrR }
|
|
3540
|
+
: { kind: "and", left: l, operator: opOrR, right: r });
|
|
3541
|
+
return jb;
|
|
3542
|
+
},
|
|
3543
|
+
orOn: (l, opOrR, r) => {
|
|
3544
|
+
parts.push(r === undefined
|
|
3545
|
+
? { kind: "or", left: l, right: opOrR }
|
|
3546
|
+
: { kind: "or", left: l, operator: opOrR, right: r });
|
|
3547
|
+
return jb;
|
|
3548
|
+
},
|
|
3549
|
+
onVal: (l, v) => {
|
|
3550
|
+
parts.push({ kind: "and", left: l, value: { v } });
|
|
3551
|
+
return jb;
|
|
3552
|
+
},
|
|
3553
|
+
andOnVal: (l, v) => {
|
|
3554
|
+
parts.push({ kind: "and", left: l, value: { v } });
|
|
3555
|
+
return jb;
|
|
3556
|
+
},
|
|
3557
|
+
orOnVal: (l, v) => {
|
|
3558
|
+
parts.push({ kind: "or", left: l, value: { v } });
|
|
3559
|
+
return jb;
|
|
3560
|
+
},
|
|
3561
|
+
onIn: (l, values) => {
|
|
3562
|
+
parts.push({ kind: "and", left: l, values: [...values] });
|
|
3563
|
+
return jb;
|
|
1820
3564
|
},
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
3565
|
+
onNotIn: (l, values) => {
|
|
3566
|
+
parts.push({
|
|
3567
|
+
kind: "and",
|
|
3568
|
+
left: l,
|
|
3569
|
+
values: [...values],
|
|
3570
|
+
notIn: true,
|
|
3571
|
+
});
|
|
3572
|
+
return jb;
|
|
3573
|
+
},
|
|
3574
|
+
onNull: (l) => {
|
|
3575
|
+
parts.push({ kind: "and", left: l, nullOp: "IS NULL" });
|
|
3576
|
+
return jb;
|
|
3577
|
+
},
|
|
3578
|
+
onNotNull: (l) => {
|
|
3579
|
+
parts.push({ kind: "and", left: l, nullOp: "IS NOT NULL" });
|
|
3580
|
+
return jb;
|
|
3581
|
+
},
|
|
3582
|
+
onBetween: (l, range) => {
|
|
3583
|
+
parts.push({ kind: "and", left: l, between: [range[0], range[1]] });
|
|
3584
|
+
return jb;
|
|
1824
3585
|
},
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
3586
|
+
onNotBetween: (l, range) => {
|
|
3587
|
+
parts.push({
|
|
3588
|
+
kind: "and",
|
|
3589
|
+
left: l,
|
|
3590
|
+
between: [range[0], range[1]],
|
|
3591
|
+
notBetween: true,
|
|
3592
|
+
});
|
|
3593
|
+
return jb;
|
|
3594
|
+
},
|
|
3595
|
+
onExists: (sub) => {
|
|
3596
|
+
const { sql, params } = this.#resolveUnion(sub).#compiledNative();
|
|
3597
|
+
parts.push({ kind: "and", exists: { sql, params, not: false } });
|
|
3598
|
+
return jb;
|
|
3599
|
+
},
|
|
3600
|
+
onNotExists: (sub) => {
|
|
3601
|
+
const { sql, params } = this.#resolveUnion(sub).#compiledNative();
|
|
3602
|
+
parts.push({ kind: "and", exists: { sql, params, not: true } });
|
|
3603
|
+
return jb;
|
|
1828
3604
|
},
|
|
1829
3605
|
};
|
|
1830
3606
|
leftOrBuild(jb);
|
|
1831
|
-
|
|
3607
|
+
// A BASE-table column runs the full model prepare (DateTime→ISO + @Column
|
|
3608
|
+
// adapters/casts), keyed by its property. A FOREIGN join column must NOT
|
|
3609
|
+
// borrow the root model's adapter for a same-named column on another
|
|
3610
|
+
// table — apply only universal type-lowering, matching Knex's
|
|
3611
|
+
// model-agnostic join binding.
|
|
3612
|
+
const prep = (col, v) => {
|
|
3613
|
+
const dot = col.lastIndexOf(".");
|
|
3614
|
+
const tablePrefix = dot >= 0 ? col.slice(0, dot) : "";
|
|
3615
|
+
const leaf = dot >= 0 ? col.slice(dot + 1) : col;
|
|
3616
|
+
const isBaseColumn = tablePrefix === "" || sameTableRef(tablePrefix, this.#tableName);
|
|
3617
|
+
return isBaseColumn ? this.#prepareValue(leaf, v) : lowerJoinValue(v);
|
|
3618
|
+
};
|
|
3619
|
+
const params = [];
|
|
3620
|
+
const on = parts
|
|
1832
3621
|
.map((p, i) => {
|
|
1833
3622
|
const prefix = i === 0 ? "ON" : p.kind === "or" ? "OR" : "AND";
|
|
1834
|
-
|
|
3623
|
+
if (p.exists) {
|
|
3624
|
+
params.push(...p.exists.params);
|
|
3625
|
+
return `${prefix} ${p.exists.not ? "NOT EXISTS" : "EXISTS"} (${p.exists.sql})`;
|
|
3626
|
+
}
|
|
3627
|
+
const col = this.#quoteCol(p.left ?? "");
|
|
3628
|
+
if (p.nullOp) {
|
|
3629
|
+
return `${prefix} ${col} ${p.nullOp}`;
|
|
3630
|
+
}
|
|
3631
|
+
if (p.between) {
|
|
3632
|
+
params.push(prep(p.left ?? "", p.between[0]), prep(p.left ?? "", p.between[1]));
|
|
3633
|
+
return `${prefix} ${col} ${p.notBetween ? "NOT BETWEEN" : "BETWEEN"} ? AND ?`;
|
|
3634
|
+
}
|
|
3635
|
+
if (p.values) {
|
|
3636
|
+
const placeholders = p.values.map(() => "?").join(", ");
|
|
3637
|
+
for (const v of p.values)
|
|
3638
|
+
params.push(prep(p.left ?? "", v));
|
|
3639
|
+
return `${prefix} ${col} ${p.notIn ? "NOT IN" : "IN"} (${placeholders})`;
|
|
3640
|
+
}
|
|
3641
|
+
if (p.value) {
|
|
3642
|
+
params.push(prep(p.left ?? "", p.value.v));
|
|
3643
|
+
return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ?`;
|
|
3644
|
+
}
|
|
3645
|
+
return `${prefix} ${col} ${this.#validateJoinOp(p.operator ?? "=")} ${this.#quoteCol(p.right ?? "")}`;
|
|
1835
3646
|
})
|
|
1836
3647
|
.join(" ");
|
|
1837
|
-
this.#joins.push(`${kind} JOIN ${tq} ${on}
|
|
3648
|
+
this.#joins.push({ sql: `${kind} JOIN ${tq} ${on}`, params });
|
|
1838
3649
|
return this;
|
|
1839
3650
|
}
|
|
1840
|
-
|
|
3651
|
+
// String form: 3-arg `(left, right)` or 4-arg `(left, operator, right)`.
|
|
3652
|
+
const left = leftOrBuild;
|
|
3653
|
+
const operator = right === undefined ? "=" : (operatorOrRight ?? "=");
|
|
3654
|
+
const rightCol = right === undefined ? operatorOrRight : right;
|
|
3655
|
+
if (rightCol === undefined)
|
|
1841
3656
|
throw new Error("join() with string form requires both left and right operands");
|
|
1842
|
-
this.#joins.push(
|
|
3657
|
+
this.#joins.push({
|
|
3658
|
+
sql: `${kind} JOIN ${tq} ON ${this.#quoteCol(left)} ${this.#validateJoinOp(operator)} ${this.#quoteCol(rightCol)}`,
|
|
3659
|
+
params: [],
|
|
3660
|
+
});
|
|
1843
3661
|
return this;
|
|
1844
3662
|
}
|
|
3663
|
+
/** Allowlist the comparison operator embedded verbatim into a JOIN's ON SQL. */
|
|
3664
|
+
#validateJoinOp(op) {
|
|
3665
|
+
const t = op.trim();
|
|
3666
|
+
const up = t.toUpperCase();
|
|
3667
|
+
const allowed = new Set([
|
|
3668
|
+
"=",
|
|
3669
|
+
"<>",
|
|
3670
|
+
"!=",
|
|
3671
|
+
"<",
|
|
3672
|
+
"<=",
|
|
3673
|
+
">",
|
|
3674
|
+
">=",
|
|
3675
|
+
"LIKE",
|
|
3676
|
+
"NOT LIKE",
|
|
3677
|
+
"ILIKE",
|
|
3678
|
+
]);
|
|
3679
|
+
if (allowed.has(t))
|
|
3680
|
+
return t;
|
|
3681
|
+
if (allowed.has(up))
|
|
3682
|
+
return up;
|
|
3683
|
+
throw new Error(`Unsupported join operator '${op}'.`);
|
|
3684
|
+
}
|
|
1845
3685
|
async #runScalar(expr) {
|
|
1846
3686
|
const clone = this.clone();
|
|
1847
3687
|
clone.#select = [`${expr} AS __scalar__`];
|
|
1848
3688
|
clone.#orderBys = [];
|
|
1849
|
-
const { sql, params } = clone
|
|
1850
|
-
const rows = await this.#db.query(sql, params);
|
|
3689
|
+
const { sql, params } = clone.#compiledNative();
|
|
3690
|
+
const rows = await this.#raceTimeout(this.#db.query(sql, params));
|
|
1851
3691
|
const row = rows[0];
|
|
1852
3692
|
return row ? row.__scalar__ : null;
|
|
1853
3693
|
}
|
|
@@ -1860,42 +3700,101 @@ export class ModelQuery {
|
|
|
1860
3700
|
set: setPairs,
|
|
1861
3701
|
wheres: this.#wheresForDml(),
|
|
1862
3702
|
returning: [],
|
|
3703
|
+
ctes: this.#compiledCtes(),
|
|
1863
3704
|
};
|
|
1864
3705
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
1865
|
-
const r = await this.#db.execute(compiled.statements[0], compiled.params);
|
|
3706
|
+
const r = await this.#raceTimeout(this.#db.execute(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
|
|
1866
3707
|
return r.rowsAffected ?? 0;
|
|
1867
3708
|
}
|
|
1868
3709
|
/**
|
|
1869
|
-
*
|
|
1870
|
-
*
|
|
1871
|
-
*
|
|
1872
|
-
*
|
|
3710
|
+
* The user's own WHERE predicates for DML (no soft-delete scope). The DML
|
|
3711
|
+
* compiler now REUSES the SELECT compiler's WHERE lowering, so every predicate
|
|
3712
|
+
* the read builder accepts — standard, `whereRaw`, `whereExists`, sub-queries,
|
|
3713
|
+
* and nested groups — is valid for update/delete too. Passed through unchanged
|
|
3714
|
+
* (same as `#buildSpec` does for reads).
|
|
1873
3715
|
*/
|
|
3716
|
+
#userWheresForDml() {
|
|
3717
|
+
return [...this.#wheres];
|
|
3718
|
+
}
|
|
1874
3719
|
#wheresForDml() {
|
|
1875
|
-
const out =
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
3720
|
+
const out = this.#userWheresForDml();
|
|
3721
|
+
// Mirror the read scope (`#buildSpec`): a `@SoftDeletes` model's bulk
|
|
3722
|
+
// update/delete/increment/decrement must NOT touch trashed rows under the
|
|
3723
|
+
// default scope — otherwise `query().where(x)` would denote a different row
|
|
3724
|
+
// set for `.exec()` than for `.update()`/`.delete()`. `.withTrashed()` widens,
|
|
3725
|
+
// `.onlyTrashed()` restricts to trashed (mirrors reads).
|
|
3726
|
+
if (this.#softDeletes) {
|
|
3727
|
+
const deletedAtCol = this.#deletedAtColumn();
|
|
3728
|
+
if (this.#softScope === "default") {
|
|
3729
|
+
out.push({
|
|
3730
|
+
column: deletedAtCol,
|
|
3731
|
+
operator: "IS NULL",
|
|
3732
|
+
value: null,
|
|
3733
|
+
type: "and",
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
else if (this.#softScope === "only-trashed") {
|
|
3737
|
+
out.push({
|
|
3738
|
+
column: deletedAtCol,
|
|
3739
|
+
operator: "IS NOT NULL",
|
|
3740
|
+
value: null,
|
|
3741
|
+
type: "and",
|
|
3742
|
+
});
|
|
1889
3743
|
}
|
|
1890
|
-
out.push({
|
|
1891
|
-
column: w.column,
|
|
1892
|
-
operator: w.operator,
|
|
1893
|
-
value: w.value,
|
|
1894
|
-
type: w.type,
|
|
1895
|
-
});
|
|
1896
3744
|
}
|
|
1897
3745
|
return out;
|
|
1898
3746
|
}
|
|
3747
|
+
/** Compile a DML spec with the comment prefix — for a lazy builder's `.toSQL()`. */
|
|
3748
|
+
#compileDmlSpec(spec) {
|
|
3749
|
+
const compiled = compileStatementNative(spec, this.#dialect);
|
|
3750
|
+
const sql = this.#commentPrefix() + compiled.statements[0];
|
|
3751
|
+
return { sql, bindings: compiled.params, params: compiled.params };
|
|
3752
|
+
}
|
|
3753
|
+
/** Hooks a lazy DML builder delegates back to (upsert clauses are N/A on a model query). */
|
|
3754
|
+
#dmlHooks() {
|
|
3755
|
+
return {
|
|
3756
|
+
onConflict: () => { },
|
|
3757
|
+
merge: () => { },
|
|
3758
|
+
ignore: () => { },
|
|
3759
|
+
returning: (...cols) => {
|
|
3760
|
+
for (const c of cols) {
|
|
3761
|
+
if (Array.isArray(c))
|
|
3762
|
+
this.#dmlReturning.push(...c);
|
|
3763
|
+
else
|
|
3764
|
+
this.#dmlReturning.push(c);
|
|
3765
|
+
}
|
|
3766
|
+
},
|
|
3767
|
+
timeout: (ms) => {
|
|
3768
|
+
this.#timeoutMs = ms;
|
|
3769
|
+
},
|
|
3770
|
+
comment: (t) => {
|
|
3771
|
+
this.comment(t);
|
|
3772
|
+
},
|
|
3773
|
+
debug: (enabled) => {
|
|
3774
|
+
this.debug(enabled);
|
|
3775
|
+
},
|
|
3776
|
+
reporterData: (data) => {
|
|
3777
|
+
this.reporterData(data);
|
|
3778
|
+
},
|
|
3779
|
+
};
|
|
3780
|
+
}
|
|
3781
|
+
/** Wrap a DML spec-builder as a lazy, chainable, inspectable {@link DmlBuilder}. */
|
|
3782
|
+
#makeDml(build, paramReturning) {
|
|
3783
|
+
const resolved = () => [...(paramReturning ?? []), ...this.#dmlReturning].map((c) => this.#resolveSelect(c));
|
|
3784
|
+
return new DmlBuilder(() => {
|
|
3785
|
+
const r = resolved();
|
|
3786
|
+
return this.#runDml(build(r), r);
|
|
3787
|
+
}, () => this.#compileDmlSpec(build(resolved())), this.#dmlHooks());
|
|
3788
|
+
}
|
|
3789
|
+
/** Compile + run a DML spec: returns affected-row count, or rows when `returning` is set. */
|
|
3790
|
+
async #runDml(spec, returning) {
|
|
3791
|
+
const compiled = compileStatementNative(spec, this.#dialect);
|
|
3792
|
+
if (returning && returning.length > 0) {
|
|
3793
|
+
return this.#raceTimeout(this.#db.query(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
|
|
3794
|
+
}
|
|
3795
|
+
const r = await this.#raceTimeout(this.#db.execute(this.#commentPrefix() + compiled.statements[0], compiled.params, this.#meta("dml")));
|
|
3796
|
+
return r.rowsAffected ?? 0;
|
|
3797
|
+
}
|
|
1899
3798
|
/**
|
|
1900
3799
|
* !!! DEBUG ONLY — DO NOT USE FOR EXECUTION !!!
|
|
1901
3800
|
*
|
|
@@ -1909,18 +3808,6 @@ export class ModelQuery {
|
|
|
1909
3808
|
* parameters via the Rust compiler — this escaper is never on the hot path.
|
|
1910
3809
|
* If you are tempted to feed `.toQuery()` output into `db.prepare()`, STOP.
|
|
1911
3810
|
*/
|
|
1912
|
-
#literalEscape(v) {
|
|
1913
|
-
if (v === null || v === undefined)
|
|
1914
|
-
return "NULL";
|
|
1915
|
-
if (typeof v === "number")
|
|
1916
|
-
return String(v);
|
|
1917
|
-
if (typeof v === "boolean")
|
|
1918
|
-
return v ? "1" : "0";
|
|
1919
|
-
if (v instanceof Date)
|
|
1920
|
-
return `'${v.toISOString()}'`;
|
|
1921
|
-
// Strings — escape single quotes per SQL. NOT injection-safe against `\'`.
|
|
1922
|
-
return `'${String(v).replace(/'/g, "''")}'`;
|
|
1923
|
-
}
|
|
1924
3811
|
/**
|
|
1925
3812
|
* Build a parenthesised WHERE group from a callback. A throwaway ModelQuery
|
|
1926
3813
|
* on the SAME table is used as the scratch builder so the callback can call
|
|
@@ -1928,7 +3815,7 @@ export class ModelQuery {
|
|
|
1928
3815
|
* groups. We then copy its accumulated `#wheres` into a `GroupWhere` clause.
|
|
1929
3816
|
*/
|
|
1930
3817
|
#buildGroup(type, callback) {
|
|
1931
|
-
const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect);
|
|
3818
|
+
const scratch = new _a(this.#tableName, this.#db, (row) => row, this.#entityClass, this.#resolveColumn, false, this.#dialect, this.#prepareValue);
|
|
1932
3819
|
callback(scratch);
|
|
1933
3820
|
return { type, kind: "group", conditions: scratch.#wheres };
|
|
1934
3821
|
}
|
|
@@ -1968,7 +3855,7 @@ export class ModelQuery {
|
|
|
1968
3855
|
type,
|
|
1969
3856
|
column: resolved,
|
|
1970
3857
|
operator: "=",
|
|
1971
|
-
value: operatorOrValue,
|
|
3858
|
+
value: this.#prep(column, operatorOrValue),
|
|
1972
3859
|
});
|
|
1973
3860
|
}
|
|
1974
3861
|
else {
|
|
@@ -1976,11 +3863,21 @@ export class ModelQuery {
|
|
|
1976
3863
|
type,
|
|
1977
3864
|
column: resolved,
|
|
1978
3865
|
operator: operatorOrValue,
|
|
1979
|
-
value,
|
|
3866
|
+
value: this.#prep(column, value),
|
|
1980
3867
|
});
|
|
1981
3868
|
}
|
|
1982
3869
|
return this;
|
|
1983
3870
|
}
|
|
3871
|
+
/**
|
|
3872
|
+
* Lower a WHERE/search value (or each element of an array) to its DB form via
|
|
3873
|
+
* the prepare hook — so a `@column.dateTime` DateTime or a `@Column({ prepare })`
|
|
3874
|
+
* adapter column used as a predicate binds the same shape the write path stores.
|
|
3875
|
+
*/
|
|
3876
|
+
#prep(column, value) {
|
|
3877
|
+
return Array.isArray(value)
|
|
3878
|
+
? value.map((v) => this.#prepareValue(column, v))
|
|
3879
|
+
: this.#prepareValue(column, value);
|
|
3880
|
+
}
|
|
1984
3881
|
/**
|
|
1985
3882
|
* Resolve this ModelQuery's preloads against a pre-loaded set of entities.
|
|
1986
3883
|
* Used by the nested-preload machinery to recurse without re-running the root select.
|