@zerotal/orm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- package/src/support/identifiers.ts +62 -0
|
@@ -0,0 +1,1808 @@
|
|
|
1
|
+
import type { SQLInstance } from "../db/sql-types.ts";
|
|
2
|
+
import { Carbon } from "@zerotal/core/carbon";
|
|
3
|
+
import { QueryBuilder, OPERATORS, _inlineValue } from "../db/QueryBuilder.ts";
|
|
4
|
+
import {
|
|
5
|
+
toCamelKey as _toCamel,
|
|
6
|
+
toSnakeColumn as _toSnakeColumn,
|
|
7
|
+
ctorChain,
|
|
8
|
+
} from "../support/identifiers.ts";
|
|
9
|
+
import type {
|
|
10
|
+
PaginateResult,
|
|
11
|
+
SimplePaginateResult,
|
|
12
|
+
CursorPaginateResult,
|
|
13
|
+
KeysetOptions,
|
|
14
|
+
KeysetPaginateResult,
|
|
15
|
+
WhereOperator,
|
|
16
|
+
} from "../db/types.ts";
|
|
17
|
+
import { ModelNotFoundError } from "../errors/index.ts";
|
|
18
|
+
import { HookRegistry } from "./hooks/HookRegistry.ts";
|
|
19
|
+
import { type WithLoaded, type ManyToMany } from "./relations/RelationRegistry.ts";
|
|
20
|
+
import type { BaseModel } from "./BaseModel.ts";
|
|
21
|
+
import { type ColumnOptions } from "./decorators/column.ts";
|
|
22
|
+
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
23
|
+
import { currentOrmContext } from "./OrmContext.ts";
|
|
24
|
+
|
|
25
|
+
type StringCast = "datetime" | "array" | "json" | "date" | "boolean" | "integer" | "float";
|
|
26
|
+
type CastOption = ColumnOptions["cast"];
|
|
27
|
+
|
|
28
|
+
function _getCasts(ctor: Function): Record<string, CastOption> {
|
|
29
|
+
const merged: Record<string, CastOption> = {};
|
|
30
|
+
for (const entry of ctorChain(ctor)) {
|
|
31
|
+
const casts = (entry as { casts?: Record<string, CastOption> }).casts;
|
|
32
|
+
if (casts) Object.assign(merged, casts);
|
|
33
|
+
}
|
|
34
|
+
return merged;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _applyCastSet(value: unknown, cast: StringCast): unknown {
|
|
38
|
+
if (value === null || value === undefined) return value;
|
|
39
|
+
switch (cast) {
|
|
40
|
+
case "datetime":
|
|
41
|
+
if (value instanceof Carbon) return value.toDatabase();
|
|
42
|
+
if (value instanceof Date) return value.toISOString();
|
|
43
|
+
return value;
|
|
44
|
+
case "array":
|
|
45
|
+
case "json":
|
|
46
|
+
if (typeof value !== "string") return JSON.stringify(value);
|
|
47
|
+
return value;
|
|
48
|
+
case "date":
|
|
49
|
+
if (value instanceof Carbon) return value.toDatabase();
|
|
50
|
+
if (value instanceof Date) return value.toISOString();
|
|
51
|
+
return value;
|
|
52
|
+
case "boolean":
|
|
53
|
+
return value ? 1 : 0;
|
|
54
|
+
case "integer":
|
|
55
|
+
return parseInt(String(value), 10);
|
|
56
|
+
case "float":
|
|
57
|
+
return parseFloat(String(value));
|
|
58
|
+
default:
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* SQLite allows at most ~999 bound parameters per statement (32 766 in newer
|
|
65
|
+
* builds, but we stay conservative). Chunk large IN lists and union the results.
|
|
66
|
+
*/
|
|
67
|
+
const WHEREIN_CHUNK = 500;
|
|
68
|
+
|
|
69
|
+
interface _ChunkFactory<T> {
|
|
70
|
+
whereIn(col: string, vals: unknown[]): { get<R = T>(): Promise<R[]> };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function _whereInChunked<T = Record<string, unknown>>(
|
|
74
|
+
factory: () => _ChunkFactory<T>,
|
|
75
|
+
column: string,
|
|
76
|
+
ids: unknown[],
|
|
77
|
+
apply?: (builder: _ChunkFactory<T>) => void,
|
|
78
|
+
): Promise<T[]> {
|
|
79
|
+
const run = (vals: unknown[]): Promise<T[]> => {
|
|
80
|
+
const builder = factory();
|
|
81
|
+
if (apply) apply(builder);
|
|
82
|
+
return builder.whereIn(column, vals).get<T>();
|
|
83
|
+
};
|
|
84
|
+
if (ids.length <= WHEREIN_CHUNK) {
|
|
85
|
+
return run(ids);
|
|
86
|
+
}
|
|
87
|
+
const out: T[] = [];
|
|
88
|
+
for (let i = 0; i < ids.length; i += WHEREIN_CHUNK) {
|
|
89
|
+
const rows = await run(ids.slice(i, i + WHEREIN_CHUNK));
|
|
90
|
+
out.push(...rows);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Base related-model query for eager loading that honours the related model's
|
|
97
|
+
* soft-delete scope. `_unscopedQuery()` deliberately omits `deleted_at IS NULL`,
|
|
98
|
+
* so every relation loader re-applies it here — matching the withCount aggregate
|
|
99
|
+
* path, so `with('rel')` and `withCount('rel')` agree on which rows are visible.
|
|
100
|
+
*/
|
|
101
|
+
function _scopedRelated(RelatedClass: typeof BaseModel): QueryBuilder {
|
|
102
|
+
const q = RelatedClass._unscopedQuery() as unknown as QueryBuilder;
|
|
103
|
+
if (RelatedClass.softDeletes) q.whereNull("deleted_at");
|
|
104
|
+
return q;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A constraint closure applied to a relation subquery / eager-load query. */
|
|
108
|
+
export type RelationConstraint = (query: ModelQueryBuilder<BaseModel>) => void;
|
|
109
|
+
|
|
110
|
+
type AggregateEntry = {
|
|
111
|
+
relation: string;
|
|
112
|
+
fn: "COUNT" | "SUM" | "AVG" | "MIN" | "MAX";
|
|
113
|
+
column: string;
|
|
114
|
+
constraint?: RelationConstraint;
|
|
115
|
+
};
|
|
116
|
+
type CountEntry = { relation: string; constraint?: RelationConstraint };
|
|
117
|
+
type ExistsEntry = { relation: string; constraint?: RelationConstraint };
|
|
118
|
+
|
|
119
|
+
/** A parsed eager-load specification supporting constraints and nested (dot) paths. */
|
|
120
|
+
interface EagerSpec {
|
|
121
|
+
name: string;
|
|
122
|
+
constraint?: RelationConstraint;
|
|
123
|
+
children: EagerSpec[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Reduce an aggregate column expression to a safe identifier segment for the alias. */
|
|
127
|
+
function _aggColumnKey(column: string): string {
|
|
128
|
+
const bare = column.includes(".") ? column.split(".").pop()! : column;
|
|
129
|
+
return (
|
|
130
|
+
bare
|
|
131
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
132
|
+
.replace(/^_+|_+$/g, "")
|
|
133
|
+
.toLowerCase() || "value"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* SQL alias for a relation aggregate, e.g. `comments_sum_votes`. Including the
|
|
139
|
+
* column keeps multiple aggregates of the same function on one relation distinct
|
|
140
|
+
* instead of colliding on a bare `comments_sum`.
|
|
141
|
+
*/
|
|
142
|
+
export function aggregateAlias(relation: string, fn: string, column: string): string {
|
|
143
|
+
return `${relation}_${fn.toLowerCase()}_${_aggColumnKey(column)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Instance attribute name for a relation aggregate, e.g. `commentsSumVotes`. */
|
|
147
|
+
export function aggregateAttribute(relation: string, fn: string, column: string): string {
|
|
148
|
+
return _toCamel(aggregateAlias(relation, fn, column));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Instance attribute name for a relation count, e.g. `commentsCount`. */
|
|
152
|
+
export function countAttribute(relation: string): string {
|
|
153
|
+
return _toCamel(`${relation}_count`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wrap an array of related models in a ManyToMany<T> collection that has
|
|
158
|
+
* pivot manipulation methods (attach / detach / sync / toggle) wired directly
|
|
159
|
+
* to the pivot table. The array still behaves as a plain Array<T> for
|
|
160
|
+
* iteration, spread, and all standard array methods.
|
|
161
|
+
*/
|
|
162
|
+
function _createPivotCollection<T extends BaseModel>(
|
|
163
|
+
items: T[],
|
|
164
|
+
pivotTable: string,
|
|
165
|
+
pivotForeignKey: string,
|
|
166
|
+
pivotRelatedKey: string,
|
|
167
|
+
parentId: unknown,
|
|
168
|
+
sql: SQLInstance,
|
|
169
|
+
timestamps = false,
|
|
170
|
+
): ManyToMany<T> {
|
|
171
|
+
const arr = [...items] as ManyToMany<T>;
|
|
172
|
+
const _ts = (): Record<string, unknown> => {
|
|
173
|
+
if (!timestamps) return {};
|
|
174
|
+
const now = new Date().toISOString();
|
|
175
|
+
return { created_at: now, updated_at: now };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
arr.attach = async (
|
|
179
|
+
id: number | number[],
|
|
180
|
+
pivotData?: Record<string, unknown>,
|
|
181
|
+
): Promise<void> => {
|
|
182
|
+
const ids = Array.isArray(id) ? id : [id];
|
|
183
|
+
for (const relId of ids) {
|
|
184
|
+
await new QueryBuilder(pivotTable, sql).insert({
|
|
185
|
+
[pivotForeignKey]: parentId,
|
|
186
|
+
[pivotRelatedKey]: relId,
|
|
187
|
+
..._ts(),
|
|
188
|
+
...(pivotData ?? {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
arr.detach = async (id?: number | number[]): Promise<void> => {
|
|
194
|
+
const qb = new QueryBuilder(pivotTable, sql).where(pivotForeignKey, parentId);
|
|
195
|
+
if (id !== undefined) {
|
|
196
|
+
const ids = Array.isArray(id) ? id : [id];
|
|
197
|
+
qb.whereIn(pivotRelatedKey, ids as unknown[]);
|
|
198
|
+
}
|
|
199
|
+
await qb.delete();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
arr.sync = async (ids: number[]): Promise<void> => {
|
|
203
|
+
await new QueryBuilder(pivotTable, sql).where(pivotForeignKey, parentId).delete();
|
|
204
|
+
for (const relId of ids) {
|
|
205
|
+
await new QueryBuilder(pivotTable, sql).insert({
|
|
206
|
+
[pivotForeignKey]: parentId,
|
|
207
|
+
[pivotRelatedKey]: relId,
|
|
208
|
+
..._ts(),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
arr.syncWithoutDetaching = async (ids: number[]): Promise<void> => {
|
|
214
|
+
for (const relId of ids) {
|
|
215
|
+
const existing = await new QueryBuilder(pivotTable, sql)
|
|
216
|
+
.where(pivotForeignKey, parentId)
|
|
217
|
+
.where(pivotRelatedKey, relId)
|
|
218
|
+
.first<Record<string, unknown>>();
|
|
219
|
+
if (!existing) {
|
|
220
|
+
await new QueryBuilder(pivotTable, sql).insert({
|
|
221
|
+
[pivotForeignKey]: parentId,
|
|
222
|
+
[pivotRelatedKey]: relId,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
arr.updateExistingPivot = async (
|
|
229
|
+
id: number,
|
|
230
|
+
pivotData: Record<string, unknown>,
|
|
231
|
+
): Promise<void> => {
|
|
232
|
+
await new QueryBuilder(pivotTable, sql)
|
|
233
|
+
.where(pivotForeignKey, parentId)
|
|
234
|
+
.where(pivotRelatedKey, id)
|
|
235
|
+
.update(pivotData);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
arr.toggle = async (id: number | number[]): Promise<void> => {
|
|
239
|
+
const ids = Array.isArray(id) ? id : [id];
|
|
240
|
+
for (const relId of ids) {
|
|
241
|
+
const existing = await new QueryBuilder(pivotTable, sql)
|
|
242
|
+
.where(pivotForeignKey, parentId)
|
|
243
|
+
.where(pivotRelatedKey, relId)
|
|
244
|
+
.first<Record<string, unknown>>();
|
|
245
|
+
if (existing) {
|
|
246
|
+
await new QueryBuilder(pivotTable, sql)
|
|
247
|
+
.where(pivotForeignKey, parentId)
|
|
248
|
+
.where(pivotRelatedKey, relId)
|
|
249
|
+
.delete();
|
|
250
|
+
} else {
|
|
251
|
+
await new QueryBuilder(pivotTable, sql).insert({
|
|
252
|
+
[pivotForeignKey]: parentId,
|
|
253
|
+
[pivotRelatedKey]: relId,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
return arr;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Global scope registry ────────────────────────────────────────────────────
|
|
263
|
+
// Stored here (not in BaseModel) to avoid a circular runtime import.
|
|
264
|
+
// BaseModel imports ModelQueryBuilder at runtime; ModelQueryBuilder imports
|
|
265
|
+
// BaseModel as a type only — no cycle.
|
|
266
|
+
|
|
267
|
+
export type GlobalScopeCallback = (qb: ModelQueryBuilder<BaseModel>) => void;
|
|
268
|
+
export function _globalScopeRegistry(): Map<Function, Map<string, GlobalScopeCallback>> {
|
|
269
|
+
return currentOrmContext().globalScopes as unknown as Map<
|
|
270
|
+
Function,
|
|
271
|
+
Map<string, GlobalScopeCallback>
|
|
272
|
+
>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The model-aware query builder returned by `Model.query()`.
|
|
277
|
+
*
|
|
278
|
+
* Extends the low-level {@link QueryBuilder} with everything that makes a query
|
|
279
|
+
* "Active Record": rows are hydrated into model instances, relations can be
|
|
280
|
+
* eager-loaded, relationship-existence filters (`has` / `whereHas`) and relation
|
|
281
|
+
* aggregates (`withCount` / `withSum` / …) are available, named and global scopes
|
|
282
|
+
* are applied, and terminal methods (`get`, `first`, `firstOrFail`, `findOrFail`,
|
|
283
|
+
* `paginate`) return model instances instead of raw rows.
|
|
284
|
+
*
|
|
285
|
+
* @typeParam M - The model class this builder queries and hydrates.
|
|
286
|
+
*
|
|
287
|
+
* @remarks
|
|
288
|
+
* Behaviours layered on top of {@link QueryBuilder}:
|
|
289
|
+
*
|
|
290
|
+
* - **Model hydration** — `get()` / `first()` map each result row through
|
|
291
|
+
* `Model.fromRow()`, apply casts, run the `afterFind` hook, and attach any
|
|
292
|
+
* relation aggregate columns (e.g. `commentsCount`, `commentsExists`).
|
|
293
|
+
* - **Column-name resolution** — every column-taking method (`where`, `orderBy`,
|
|
294
|
+
* `select`, `sum`, …) accepts a model property name in camelCase and resolves
|
|
295
|
+
* it to the snake_case database column, so `where('createdAt', …)` targets
|
|
296
|
+
* `created_at`.
|
|
297
|
+
* - **Eager loading** — {@link with} declares relations (including nested
|
|
298
|
+
* dot-paths and constrained closures) to load in a batched follow-up query.
|
|
299
|
+
* - **Relationship existence** — {@link has} / {@link whereHas} /
|
|
300
|
+
* {@link doesntHave} filter the parent rows by correlated `EXISTS` subqueries.
|
|
301
|
+
* - **Global scopes** — scopes registered on the model (and inherited through the
|
|
302
|
+
* prototype chain) are applied lazily on the first terminal call; opt out per
|
|
303
|
+
* query with {@link withoutGlobalScope} / {@link withoutGlobalScopes}.
|
|
304
|
+
* - **Soft-delete scoping asymmetry** — {@link has}, {@link whereHas},
|
|
305
|
+
* {@link withCount} and the other relation aggregates exclude soft-deleted
|
|
306
|
+
* related rows (they add `deleted_at IS NULL` when the related model soft-deletes),
|
|
307
|
+
* but eager {@link with} loads related rows through the model's *unscoped* query
|
|
308
|
+
* and therefore does **not** apply the related model's soft-delete (or global)
|
|
309
|
+
* scopes. Trashed related records will appear in an eager-loaded relation.
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```ts
|
|
313
|
+
* // Hydrated User instances, each with its posts eager-loaded, paginated.
|
|
314
|
+
* const page = await User.query()
|
|
315
|
+
* .with('posts', (q) => q.where('published', true))
|
|
316
|
+
* .where('active', true)
|
|
317
|
+
* .orderBy('createdAt', 'desc')
|
|
318
|
+
* .paginate(15, 1); // 15 per page, page 1
|
|
319
|
+
*
|
|
320
|
+
* for (const user of page.data) {
|
|
321
|
+
* console.log(user.name, user.posts.length);
|
|
322
|
+
* }
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
326
|
+
private _ModelClass: typeof BaseModel;
|
|
327
|
+
private _eagerSpecs: EagerSpec[] = [];
|
|
328
|
+
private _withCounts: CountEntry[] = [];
|
|
329
|
+
private _withAggregates: AggregateEntry[] = [];
|
|
330
|
+
private _withExists: ExistsEntry[] = [];
|
|
331
|
+
private _excludedScopes = new Set<string>();
|
|
332
|
+
private _scopesApplied = false;
|
|
333
|
+
|
|
334
|
+
constructor(table: string, sql: SQLInstance, ModelClass: typeof BaseModel) {
|
|
335
|
+
super(table, sql);
|
|
336
|
+
this._ModelClass = ModelClass;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
protected override _newInstance(): QueryBuilder {
|
|
340
|
+
return new ModelQueryBuilder(this._state.table, this._sql, this._ModelClass);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Walk the prototype chain to find a single relation's metadata. */
|
|
344
|
+
private _findRelationMeta(
|
|
345
|
+
relation: string,
|
|
346
|
+
): import("./relations/RelationRegistry.ts").RelationMetadata | undefined {
|
|
347
|
+
return relationsFor(this._ModelClass).get(relation);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Merge relation metadata from the full prototype chain (child overrides parent). */
|
|
351
|
+
private _allRelationsMeta(): Map<
|
|
352
|
+
string,
|
|
353
|
+
import("./relations/RelationRegistry.ts").RelationMetadata
|
|
354
|
+
> {
|
|
355
|
+
return relationsFor(this._ModelClass);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Return a deep copy of this builder, including its eager-load specs, relation
|
|
360
|
+
* aggregate/count/exists entries and excluded-scope set. The copy has scopes
|
|
361
|
+
* un-applied so they run on its own first terminal call.
|
|
362
|
+
* @category Retrieval
|
|
363
|
+
*/
|
|
364
|
+
override clone(): this {
|
|
365
|
+
const c = super.clone() as unknown as ModelQueryBuilder<M>;
|
|
366
|
+
c._eagerSpecs = this._eagerSpecs.map((s) => _cloneSpec(s));
|
|
367
|
+
c._withCounts = this._withCounts.map((e) => ({ ...e }));
|
|
368
|
+
c._withAggregates = this._withAggregates.map((e) => ({ ...e }));
|
|
369
|
+
c._withExists = this._withExists.map((e) => ({ ...e }));
|
|
370
|
+
c._excludedScopes = new Set(this._excludedScopes);
|
|
371
|
+
c._scopesApplied = false;
|
|
372
|
+
return c as unknown as this;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Exclude one or more named global scopes from this query.
|
|
377
|
+
*
|
|
378
|
+
* @param names - Names of the global scopes to skip for this query only.
|
|
379
|
+
* @returns This builder for chaining.
|
|
380
|
+
* @category Scopes
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* // Skip the 'published' scope for this query only
|
|
385
|
+
* Post.query().withoutGlobalScope('published').get();
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
withoutGlobalScope(...names: string[]): this {
|
|
389
|
+
for (const n of names) this._excludedScopes.add(n);
|
|
390
|
+
return this;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Remove ALL global scopes registered on this model for this query.
|
|
395
|
+
*
|
|
396
|
+
* @returns This builder for chaining.
|
|
397
|
+
* @category Scopes
|
|
398
|
+
*/
|
|
399
|
+
withoutGlobalScopes(): this {
|
|
400
|
+
this._excludedScopes.add("__all__");
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Apply global scopes before any terminal compiles SQL.
|
|
406
|
+
*
|
|
407
|
+
* Overrides {@link QueryBuilder._beforeTerminal}, so this now runs for `update()`, `delete()`,
|
|
408
|
+
* `count()`, `exists()`, `pluck()`, `value()`, the aggregates and every paginator — not just
|
|
409
|
+
* `get()`/`first()` as before. `_applyGlobalScopes` is guarded by `_scopesApplied`, so
|
|
410
|
+
* repeat entry through a cloned builder is a no-op.
|
|
411
|
+
*
|
|
412
|
+
* @category Scopes
|
|
413
|
+
* @internal
|
|
414
|
+
*/
|
|
415
|
+
protected override _beforeTerminal(): void {
|
|
416
|
+
// Order matters: group the caller's predicates FIRST, so the scopes appended below join
|
|
417
|
+
// the outer AND chain rather than the caller's (possibly OR-joined) one.
|
|
418
|
+
this._groupUserWheres();
|
|
419
|
+
this._applyGlobalScopes();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private _applyGlobalScopes(): void {
|
|
423
|
+
if (this._scopesApplied) return;
|
|
424
|
+
this._scopesApplied = true;
|
|
425
|
+
if (this._excludedScopes.has("__all__")) return;
|
|
426
|
+
|
|
427
|
+
// Walk the prototype chain (base-first) so subclasses inherit parent scopes
|
|
428
|
+
// and can override them by name.
|
|
429
|
+
const merged = new Map<string, GlobalScopeCallback>();
|
|
430
|
+
const scopeReg = _globalScopeRegistry();
|
|
431
|
+
for (const cls of ctorChain(this._ModelClass)) {
|
|
432
|
+
const clsScopes = scopeReg.get(cls);
|
|
433
|
+
if (clsScopes) for (const [n, fn] of clsScopes) merged.set(n, fn);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const [name, fn] of merged) {
|
|
437
|
+
if (!this._excludedScopes.has(name)) fn(this as unknown as ModelQueryBuilder<BaseModel>);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Aggregate eager loads (constrained + object/array forms) ──────────────
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Add a `COUNT` subquery for a relation, injected as `<relation>Count` (camelCase)
|
|
445
|
+
* on every hydrated result.
|
|
446
|
+
*
|
|
447
|
+
* Supports hasMany, hasOne, belongsTo and manyToMany relations, and an optional
|
|
448
|
+
* constraint closure. The count excludes soft-deleted related rows when the related
|
|
449
|
+
* model soft-deletes.
|
|
450
|
+
*
|
|
451
|
+
* @param relation - A relation name, an array of names, or a `{ name: constraint }`
|
|
452
|
+
* map to count several relations (optionally constrained) at once.
|
|
453
|
+
* @param constraint - Optional closure narrowing which related rows are counted
|
|
454
|
+
* (applies when `relation` is a single string).
|
|
455
|
+
* @returns This builder for chaining.
|
|
456
|
+
* @category Aggregates
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* ```ts
|
|
460
|
+
* const posts = await Post.query().withCount('comments').get();
|
|
461
|
+
* // posts[0].commentsCount === 12
|
|
462
|
+
*
|
|
463
|
+
* await Post.query()
|
|
464
|
+
* .withCount({ comments: (q) => q.where('approved', true) })
|
|
465
|
+
* .get();
|
|
466
|
+
* ```
|
|
467
|
+
*/
|
|
468
|
+
withCount(
|
|
469
|
+
relation: string | string[] | Record<string, RelationConstraint>,
|
|
470
|
+
constraint?: RelationConstraint,
|
|
471
|
+
): this {
|
|
472
|
+
this._eachRelationArg(relation, constraint, (rel, c) => {
|
|
473
|
+
if (!this._withCounts.some((e) => e.relation === rel && !c))
|
|
474
|
+
this._withCounts.push({ relation: rel, ...(c ? { constraint: c } : {}) });
|
|
475
|
+
});
|
|
476
|
+
return this;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Add a `SUM(column)` subquery for a relation, injected as
|
|
481
|
+
* `<relation>Sum<Column>` (camelCase) on every hydrated result.
|
|
482
|
+
*
|
|
483
|
+
* @param relation - The relation to aggregate over.
|
|
484
|
+
* @param column - The related-table column to sum.
|
|
485
|
+
* @param constraint - Optional closure narrowing which related rows are summed.
|
|
486
|
+
* @returns This builder for chaining.
|
|
487
|
+
* @category Aggregates
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* ```ts
|
|
491
|
+
* const posts = await Post.query().withSum('comments', 'votes').get();
|
|
492
|
+
* // posts[0].commentsSumVotes === 42
|
|
493
|
+
* ```
|
|
494
|
+
*/
|
|
495
|
+
withSum(relation: string, column: string, constraint?: RelationConstraint): this {
|
|
496
|
+
this._withAggregates.push({
|
|
497
|
+
relation,
|
|
498
|
+
fn: "SUM",
|
|
499
|
+
column,
|
|
500
|
+
...(constraint ? { constraint } : {}),
|
|
501
|
+
});
|
|
502
|
+
return this;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Add an `AVG(column)` subquery for a relation, injected as
|
|
506
|
+
* `<relation>Avg<Column>` (camelCase) on every hydrated result.
|
|
507
|
+
*
|
|
508
|
+
* @param relation - The relation to aggregate over.
|
|
509
|
+
* @param column - The related-table column to average.
|
|
510
|
+
* @param constraint - Optional closure narrowing which related rows are averaged.
|
|
511
|
+
* @returns This builder for chaining.
|
|
512
|
+
* @category Aggregates
|
|
513
|
+
*/
|
|
514
|
+
withAvg(relation: string, column: string, constraint?: RelationConstraint): this {
|
|
515
|
+
this._withAggregates.push({
|
|
516
|
+
relation,
|
|
517
|
+
fn: "AVG",
|
|
518
|
+
column,
|
|
519
|
+
...(constraint ? { constraint } : {}),
|
|
520
|
+
});
|
|
521
|
+
return this;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Add a `MIN(column)` subquery for a relation, injected as
|
|
525
|
+
* `<relation>Min<Column>` (camelCase) on every hydrated result.
|
|
526
|
+
*
|
|
527
|
+
* @param relation - The relation to aggregate over.
|
|
528
|
+
* @param column - The related-table column to take the minimum of.
|
|
529
|
+
* @param constraint - Optional closure narrowing which related rows are considered.
|
|
530
|
+
* @returns This builder for chaining.
|
|
531
|
+
* @category Aggregates
|
|
532
|
+
*/
|
|
533
|
+
withMin(relation: string, column: string, constraint?: RelationConstraint): this {
|
|
534
|
+
this._withAggregates.push({
|
|
535
|
+
relation,
|
|
536
|
+
fn: "MIN",
|
|
537
|
+
column,
|
|
538
|
+
...(constraint ? { constraint } : {}),
|
|
539
|
+
});
|
|
540
|
+
return this;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Add a `MAX(column)` subquery for a relation, injected as
|
|
544
|
+
* `<relation>Max<Column>` (camelCase) on every hydrated result.
|
|
545
|
+
*
|
|
546
|
+
* @param relation - The relation to aggregate over.
|
|
547
|
+
* @param column - The related-table column to take the maximum of.
|
|
548
|
+
* @param constraint - Optional closure narrowing which related rows are considered.
|
|
549
|
+
* @returns This builder for chaining.
|
|
550
|
+
* @category Aggregates
|
|
551
|
+
*/
|
|
552
|
+
withMax(relation: string, column: string, constraint?: RelationConstraint): this {
|
|
553
|
+
this._withAggregates.push({
|
|
554
|
+
relation,
|
|
555
|
+
fn: "MAX",
|
|
556
|
+
column,
|
|
557
|
+
...(constraint ? { constraint } : {}),
|
|
558
|
+
});
|
|
559
|
+
return this;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Add an `EXISTS` subquery for a relation, injected as `<relation>Exists`
|
|
564
|
+
* (boolean) on every result — a single-query check that avoids loading the
|
|
565
|
+
* related rows. Optional constraint narrows what counts as "existing".
|
|
566
|
+
*
|
|
567
|
+
* @param relation - The relation to test for existence.
|
|
568
|
+
* @param constraint - Optional closure narrowing what counts as "existing".
|
|
569
|
+
* @returns This builder for chaining.
|
|
570
|
+
* @category Aggregates
|
|
571
|
+
*
|
|
572
|
+
* @example
|
|
573
|
+
* ```ts
|
|
574
|
+
* const posts = await Post.query().withExists('comments').get();
|
|
575
|
+
* // posts[0].commentsExists === true | false
|
|
576
|
+
* ```
|
|
577
|
+
*/
|
|
578
|
+
withExists(relation: string, constraint?: RelationConstraint): this {
|
|
579
|
+
this._withExists.push({ relation, ...(constraint ? { constraint } : {}) });
|
|
580
|
+
return this;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private _eachRelationArg(
|
|
584
|
+
arg: string | string[] | Record<string, RelationConstraint>,
|
|
585
|
+
constraint: RelationConstraint | undefined,
|
|
586
|
+
fn: (rel: string, c?: RelationConstraint) => void,
|
|
587
|
+
): void {
|
|
588
|
+
if (typeof arg === "string") fn(arg, constraint);
|
|
589
|
+
else if (Array.isArray(arg)) for (const r of arg) fn(r);
|
|
590
|
+
else for (const [k, v] of Object.entries(arg)) fn(k, typeof v === "function" ? v : undefined);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── Relationship existence queries ────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Filter to parent rows that HAVE the related records, via a correlated
|
|
597
|
+
* `EXISTS` subquery (soft-deleted related rows are excluded).
|
|
598
|
+
*
|
|
599
|
+
* Supported for hasMany, hasOne, belongsTo, manyToMany, morphMany and morphOne
|
|
600
|
+
* relations. The `*Through`, `morphToMany`, `morphedByMany` and `morphTo`
|
|
601
|
+
* relation types are **not** supported and throw — use eager {@link with}
|
|
602
|
+
* instead.
|
|
603
|
+
*
|
|
604
|
+
* @param relation - The relation that must exist.
|
|
605
|
+
* @param operator - Optional comparison operator against the related count (e.g. `'>='`).
|
|
606
|
+
* @param count - Optional count to compare against (defaults to 1 when an operator is given).
|
|
607
|
+
* @returns This builder for chaining.
|
|
608
|
+
* @throws Error if the relation is undefined on the model, or is a `*Through` /
|
|
609
|
+
* polymorphic-many / `morphTo` relation.
|
|
610
|
+
* @category Relationship constraints
|
|
611
|
+
*
|
|
612
|
+
* @example
|
|
613
|
+
* ```ts
|
|
614
|
+
* Post.query().has('comments'); // at least one comment
|
|
615
|
+
* Post.query().has('comments', '>=', 3); // three or more
|
|
616
|
+
* ```
|
|
617
|
+
*/
|
|
618
|
+
has(relation: string, operator?: string, count?: number): this {
|
|
619
|
+
if (operator === undefined && count === undefined)
|
|
620
|
+
return this._addHas(relation, undefined, "and", false);
|
|
621
|
+
return this._addHas(relation, undefined, "and", false, operator ?? ">=", count ?? 1);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* `OR` variant of {@link has} — combines with the previous condition via `OR`.
|
|
625
|
+
* @category Relationship constraints
|
|
626
|
+
*/
|
|
627
|
+
orHas(relation: string, operator?: string, count?: number): this {
|
|
628
|
+
if (operator === undefined && count === undefined)
|
|
629
|
+
return this._addHas(relation, undefined, "or", false);
|
|
630
|
+
return this._addHas(relation, undefined, "or", false, operator ?? ">=", count ?? 1);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Filter to parent rows that do NOT have the related records (`NOT EXISTS`).
|
|
634
|
+
*
|
|
635
|
+
* @param relation - The relation that must be absent.
|
|
636
|
+
* @param callback - Optional closure constraining which related rows count as present.
|
|
637
|
+
* @returns This builder for chaining.
|
|
638
|
+
* @category Relationship constraints
|
|
639
|
+
*/
|
|
640
|
+
doesntHave(relation: string, callback?: RelationConstraint): this {
|
|
641
|
+
return this._addHas(relation, callback, "and", true);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* `OR` variant of {@link doesntHave}.
|
|
645
|
+
* @category Relationship constraints
|
|
646
|
+
*/
|
|
647
|
+
orDoesntHave(relation: string, callback?: RelationConstraint): this {
|
|
648
|
+
return this._addHas(relation, callback, "or", true);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Filter by existence of related records matching the callback constraints.
|
|
652
|
+
*
|
|
653
|
+
* @param relation - The relation to test.
|
|
654
|
+
* @param callback - Optional closure applied to the related subquery.
|
|
655
|
+
* @param operator - Optional operator to compare the matching related count against.
|
|
656
|
+
* @param count - Optional count to compare against.
|
|
657
|
+
* @returns This builder for chaining.
|
|
658
|
+
* @category Relationship constraints
|
|
659
|
+
*
|
|
660
|
+
* @example
|
|
661
|
+
* ```ts
|
|
662
|
+
* User.query().whereHas('posts', (q) => q.where('published', true)).get();
|
|
663
|
+
* ```
|
|
664
|
+
*/
|
|
665
|
+
whereHas(
|
|
666
|
+
relation: string,
|
|
667
|
+
callback?: RelationConstraint,
|
|
668
|
+
operator?: string,
|
|
669
|
+
count?: number,
|
|
670
|
+
): this {
|
|
671
|
+
return this._addHas(relation, callback, "and", false, operator, count);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* `OR` variant of {@link whereHas}.
|
|
675
|
+
* @category Relationship constraints
|
|
676
|
+
*/
|
|
677
|
+
orWhereHas(relation: string, callback?: RelationConstraint): this {
|
|
678
|
+
return this._addHas(relation, callback, "or", false);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Filter to parent rows with NO related records matching the callback constraints.
|
|
682
|
+
* @category Relationship constraints
|
|
683
|
+
*/
|
|
684
|
+
whereDoesntHave(relation: string, callback?: RelationConstraint): this {
|
|
685
|
+
return this._addHas(relation, callback, "and", true);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* `OR` variant of {@link whereDoesntHave}.
|
|
689
|
+
* @category Relationship constraints
|
|
690
|
+
*/
|
|
691
|
+
orWhereDoesntHave(relation: string, callback?: RelationConstraint): this {
|
|
692
|
+
return this._addHas(relation, callback, "or", true);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Shorthand for {@link whereHas} with a single column condition on the related table.
|
|
696
|
+
*
|
|
697
|
+
* @param relation - The relation to test.
|
|
698
|
+
* @param column - The related-table column to compare.
|
|
699
|
+
* @param operatorOrValue - The operator, or the value when the 3-arg form is used.
|
|
700
|
+
* @param value - The value when an explicit operator is supplied.
|
|
701
|
+
* @returns This builder for chaining.
|
|
702
|
+
* @category Relationship constraints
|
|
703
|
+
*
|
|
704
|
+
* @example
|
|
705
|
+
* ```ts
|
|
706
|
+
* Post.query().whereRelation('comments', 'approved', true).get();
|
|
707
|
+
* ```
|
|
708
|
+
*/
|
|
709
|
+
whereRelation(relation: string, column: string, operatorOrValue: unknown, value?: unknown): this {
|
|
710
|
+
return this.whereHas(relation, (q) =>
|
|
711
|
+
value === undefined
|
|
712
|
+
? q.where(column, operatorOrValue)
|
|
713
|
+
: q.where(column, operatorOrValue as WhereOperator, value),
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* `OR` variant of {@link whereRelation}.
|
|
718
|
+
* @category Relationship constraints
|
|
719
|
+
*/
|
|
720
|
+
orWhereRelation(
|
|
721
|
+
relation: string,
|
|
722
|
+
column: string,
|
|
723
|
+
operatorOrValue: unknown,
|
|
724
|
+
value?: unknown,
|
|
725
|
+
): this {
|
|
726
|
+
return this.orWhereHas(relation, (q) =>
|
|
727
|
+
value === undefined
|
|
728
|
+
? q.where(column, operatorOrValue)
|
|
729
|
+
: q.where(column, operatorOrValue as WhereOperator, value),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* {@link whereHas} plus eager-loading the same relation with the same
|
|
734
|
+
* constraint — filter parents by matching related rows and load exactly those
|
|
735
|
+
* rows in one call.
|
|
736
|
+
*
|
|
737
|
+
* @param relation - The relation to filter by and eager-load.
|
|
738
|
+
* @param callback - Optional closure applied both to the existence subquery and
|
|
739
|
+
* the eager-load query.
|
|
740
|
+
* @returns This builder for chaining.
|
|
741
|
+
* @category Relationship constraints
|
|
742
|
+
*/
|
|
743
|
+
withWhereHas(relation: string, callback?: RelationConstraint): this {
|
|
744
|
+
this.whereHas(relation, callback);
|
|
745
|
+
this._addEager(relation, callback);
|
|
746
|
+
return this;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private _addHas(
|
|
750
|
+
relation: string,
|
|
751
|
+
callback: RelationConstraint | undefined,
|
|
752
|
+
boolean: "and" | "or",
|
|
753
|
+
negate: boolean,
|
|
754
|
+
operator?: string,
|
|
755
|
+
count?: number,
|
|
756
|
+
): this {
|
|
757
|
+
const sub = this._relationSubquery(relation, callback);
|
|
758
|
+
if (operator !== undefined && count !== undefined) {
|
|
759
|
+
// The operator lands in SQL by interpolation, not binding, so it is allowlisted like
|
|
760
|
+
// an identifier. `has('posts', req.query.op, 1)` was otherwise a way to append
|
|
761
|
+
// arbitrary predicate text — with a correct binding count, so it executed cleanly.
|
|
762
|
+
if (!OPERATORS.has(operator)) {
|
|
763
|
+
throw new Error(`[Zerotal ORM] has()/whereHas(): unsupported operator "${operator}".`);
|
|
764
|
+
}
|
|
765
|
+
sub.selectRaw("COUNT(*)");
|
|
766
|
+
const { sql, bindings } = sub.toSqlWithBindings();
|
|
767
|
+
const clause = `(${sql}) ${operator} ?`;
|
|
768
|
+
if (boolean === "or") this.orWhereRaw(clause, [...bindings, count]);
|
|
769
|
+
else this.whereRaw(clause, [...bindings, count]);
|
|
770
|
+
} else {
|
|
771
|
+
sub.selectRaw("1");
|
|
772
|
+
const { sql, bindings } = sub.toSqlWithBindings();
|
|
773
|
+
const kw = negate ? "NOT EXISTS" : "EXISTS";
|
|
774
|
+
if (boolean === "or") this.orWhereRaw(`${kw} (${sql})`, bindings);
|
|
775
|
+
else this.whereRaw(`${kw} (${sql})`, bindings);
|
|
776
|
+
}
|
|
777
|
+
return this;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Build a correlated subquery over a relation, with the join predicate that
|
|
782
|
+
* ties it to the parent table plus any caller constraint. Used by has/whereHas
|
|
783
|
+
* and the constrained withCount/withSum aggregates.
|
|
784
|
+
*/
|
|
785
|
+
private _relationSubquery(relation: string, constraint?: RelationConstraint): QueryBuilder {
|
|
786
|
+
const meta = this._findRelationMeta(relation);
|
|
787
|
+
if (!meta) {
|
|
788
|
+
throw new Error(`Relation "${relation}" is not defined on ${this._ModelClass.name}`);
|
|
789
|
+
}
|
|
790
|
+
const Related = meta.related() as typeof BaseModel;
|
|
791
|
+
const main = this._ModelClass.table;
|
|
792
|
+
let sub: QueryBuilder;
|
|
793
|
+
|
|
794
|
+
if (
|
|
795
|
+
meta.type === "hasManyThrough" ||
|
|
796
|
+
meta.type === "hasOneThrough" ||
|
|
797
|
+
meta.type === "morphToMany" ||
|
|
798
|
+
meta.type === "morphedByMany" ||
|
|
799
|
+
meta.type === "morphTo"
|
|
800
|
+
) {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`[Zerotal ORM] has()/whereHas() is not supported for "${meta.type}" relations ("${relation}"). Use eager loading (with()) instead.`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (meta.type === "manyToMany") {
|
|
807
|
+
const relTable = Related.table;
|
|
808
|
+
sub = new QueryBuilder(meta.pivotTable!, this._sql)
|
|
809
|
+
.join(
|
|
810
|
+
relTable,
|
|
811
|
+
`${relTable}.${Related.primaryKey}`,
|
|
812
|
+
"=",
|
|
813
|
+
`${meta.pivotTable}.${meta.pivotRelatedKey}`,
|
|
814
|
+
)
|
|
815
|
+
.whereColumn(`${meta.pivotTable}.${meta.pivotForeignKey}`, `${main}.${meta.localKey}`);
|
|
816
|
+
if (Related.softDeletes) sub.whereNull(`${relTable}.deleted_at`);
|
|
817
|
+
} else {
|
|
818
|
+
const relTable = Related.table;
|
|
819
|
+
sub = new QueryBuilder(relTable, this._sql);
|
|
820
|
+
if (meta.type === "belongsTo") {
|
|
821
|
+
sub.whereColumn(`${relTable}.${meta.localKey}`, `${main}.${meta.foreignKey}`);
|
|
822
|
+
} else {
|
|
823
|
+
sub.whereColumn(`${relTable}.${meta.foreignKey}`, `${main}.${meta.localKey}`);
|
|
824
|
+
if (meta.type === "morphMany" || meta.type === "morphOne") {
|
|
825
|
+
sub.where(`${relTable}.${meta.morphTypeColumn}`, this._ModelClass.name);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (Related.softDeletes) sub.whereNull(`${relTable}.deleted_at`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (constraint) constraint(sub as unknown as ModelQueryBuilder<BaseModel>);
|
|
832
|
+
return sub;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Compile a subquery builder to SQL with its bindings inlined as literals. */
|
|
836
|
+
private _inlinedSql(sub: QueryBuilder): string {
|
|
837
|
+
const { sql, bindings } = sub.toSqlWithBindings();
|
|
838
|
+
let i = 0;
|
|
839
|
+
return sql.replace(/\?/g, () => _inlineValue(bindings[i++]));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Build a relation aggregate sub-select string (bindings inlined), for both
|
|
844
|
+
* the constrained and unconstrained forms. Returns null for relation types
|
|
845
|
+
* `_relationSubquery` does not support, which the caller silently skips —
|
|
846
|
+
* matching the old string-built path's behaviour for those types.
|
|
847
|
+
*/
|
|
848
|
+
private _aggSubquery(
|
|
849
|
+
relation: string,
|
|
850
|
+
fn: string,
|
|
851
|
+
column: string,
|
|
852
|
+
constraint?: RelationConstraint,
|
|
853
|
+
): string | null {
|
|
854
|
+
try {
|
|
855
|
+
const sub = this._relationSubquery(relation, constraint);
|
|
856
|
+
sub.selectRaw(`${fn}(${column})`);
|
|
857
|
+
return this._inlinedSql(sub);
|
|
858
|
+
} catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Build a relation EXISTS sub-select string (bindings inlined). */
|
|
864
|
+
private _existsSubquery(relation: string, constraint?: RelationConstraint): string | null {
|
|
865
|
+
try {
|
|
866
|
+
const sub = this._relationSubquery(relation, constraint);
|
|
867
|
+
sub.selectRaw("1");
|
|
868
|
+
return this._inlinedSql(sub);
|
|
869
|
+
} catch {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Identifier-ingress override: resolve a model property name to its database
|
|
876
|
+
* column name (camelCase → snake_case). The base builder routes every
|
|
877
|
+
* caller-supplied column through this one hook, so resolution covers all
|
|
878
|
+
* column-taking methods — where/orderBy/select and equally whereNotIn,
|
|
879
|
+
* whereAny, pluck, increment, join columns and the cursor/keyset pagination
|
|
880
|
+
* option columns, which the 29 per-method overrides this hook replaces had
|
|
881
|
+
* drifted on. Idempotent for already-snake and qualified columns; raw
|
|
882
|
+
* expressions pass through untouched.
|
|
883
|
+
*/
|
|
884
|
+
protected override _column(column: string): string {
|
|
885
|
+
return _toSnakeColumn(column);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Value-ingress override: coerce a bound value through the column's cast
|
|
890
|
+
* metadata (Carbon → DB string, boolean → 0/1, custom cast setters), so a
|
|
891
|
+
* value compares in its stored representation everywhere a column-value pair
|
|
892
|
+
* enters the builder — where, whereIn and now whereBetween alike.
|
|
893
|
+
*/
|
|
894
|
+
protected override _bind(column: string, value: unknown, operator?: WhereOperator): unknown {
|
|
895
|
+
return this._coerceWhereValue(column, value, operator);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── Terminal overrides ───────────────────────────────────────────────────
|
|
899
|
+
// Signatures match the base to satisfy TS override compatibility.
|
|
900
|
+
// The class-level M is used internally; T is a passthrough.
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Execute the query and return an array of hydrated model instances.
|
|
904
|
+
*
|
|
905
|
+
* Applies global scopes, injects any {@link withCount} / {@link withSum} /
|
|
906
|
+
* {@link withExists} aggregate columns, hydrates each row via `Model.fromRow`
|
|
907
|
+
* (attaching the aggregate values as camelCase attributes), performs any eager
|
|
908
|
+
* loads declared with {@link with}, and runs the `afterFind` hook per instance.
|
|
909
|
+
*
|
|
910
|
+
* @typeParam T - Element type of the returned array (defaults to the model `M`).
|
|
911
|
+
* @returns The matched model instances.
|
|
912
|
+
* @category Retrieval
|
|
913
|
+
*/
|
|
914
|
+
override async get<T = M>(): Promise<T[]> {
|
|
915
|
+
this._beforeTerminal();
|
|
916
|
+
const hasAggSubqueries =
|
|
917
|
+
this._withCounts.length > 0 || this._withAggregates.length > 0 || this._withExists.length > 0;
|
|
918
|
+
if (hasAggSubqueries) {
|
|
919
|
+
if (this._state.selects.length === 0) {
|
|
920
|
+
this.select(`${this._ModelClass.table}.*`);
|
|
921
|
+
}
|
|
922
|
+
// Constrained and unconstrained forms both compile through
|
|
923
|
+
// _relationSubquery. This used to be two implementations — a string-built
|
|
924
|
+
// fast path for the unconstrained case and the builder path for the
|
|
925
|
+
// constrained one — whose SQL was documented as agreeing but did not:
|
|
926
|
+
// for manyToMany without soft deletes the fast path counted pivot rows
|
|
927
|
+
// directly, so an orphaned pivot row made withCount('tags') and
|
|
928
|
+
// withCount({ tags: q => … }) disagree on identical data. One path, one
|
|
929
|
+
// answer: related rows are joined and counted, orphans excluded.
|
|
930
|
+
for (const { relation, constraint } of this._withCounts) {
|
|
931
|
+
const sql = this._aggSubquery(relation, "COUNT", "*", constraint);
|
|
932
|
+
if (sql) this.selectRaw(`(${sql}) AS ${relation}_count`);
|
|
933
|
+
}
|
|
934
|
+
for (const { relation, fn, column, constraint } of this._withAggregates) {
|
|
935
|
+
const alias = aggregateAlias(relation, fn, column);
|
|
936
|
+
const sql = this._aggSubquery(relation, fn, column, constraint);
|
|
937
|
+
if (sql) this.selectRaw(`(${sql}) AS ${alias}`);
|
|
938
|
+
}
|
|
939
|
+
for (const { relation, constraint } of this._withExists) {
|
|
940
|
+
const sql = this._existsSubquery(relation, constraint);
|
|
941
|
+
if (sql)
|
|
942
|
+
this.selectRaw(`(CASE WHEN EXISTS (${sql}) THEN 1 ELSE 0 END) AS ${relation}_exists`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const rows = await super.get<Record<string, unknown>>();
|
|
947
|
+
// Aggregate result columns to detach from the raw row before fromRow().
|
|
948
|
+
const numericKeys = new Set<string>();
|
|
949
|
+
for (const { relation } of this._withCounts) numericKeys.add(`${relation}_count`);
|
|
950
|
+
for (const { relation, fn, column } of this._withAggregates)
|
|
951
|
+
numericKeys.add(aggregateAlias(relation, fn, column));
|
|
952
|
+
const boolKeys = new Set<string>();
|
|
953
|
+
for (const { relation } of this._withExists) boolKeys.add(`${relation}_exists`);
|
|
954
|
+
|
|
955
|
+
const instances = rows.map((row) => {
|
|
956
|
+
const camelValues: Record<string, number | boolean> = {};
|
|
957
|
+
for (const key of numericKeys) {
|
|
958
|
+
if (key in row) {
|
|
959
|
+
camelValues[_toCamel(key)] = Number(row[key]);
|
|
960
|
+
delete row[key];
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
for (const key of boolKeys) {
|
|
964
|
+
if (key in row) {
|
|
965
|
+
camelValues[_toCamel(key)] = Number(row[key]) !== 0;
|
|
966
|
+
delete row[key];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const inst = this._ModelClass.fromRow(row) as M;
|
|
970
|
+
const writable = inst as Record<string, unknown>;
|
|
971
|
+
for (const [k, v] of Object.entries(camelValues)) writable[k] = v;
|
|
972
|
+
return inst;
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
if (instances.length > 0 && this._eagerSpecs.length > 0) {
|
|
976
|
+
await this._eagerLoadRelations(instances);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
for (const inst of instances) {
|
|
980
|
+
await HookRegistry.run(this._ModelClass, "afterFind", inst);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return instances as unknown as T[];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Execute the query and return the first matching model instance, or `null`.
|
|
988
|
+
*
|
|
989
|
+
* Applies global scopes, hydrates the row, runs any eager loads declared with
|
|
990
|
+
* {@link with}, and runs the `afterFind` hook.
|
|
991
|
+
*
|
|
992
|
+
* @typeParam T - Result type (defaults to the model `M`).
|
|
993
|
+
* @returns The first matching instance, or `null` when none match.
|
|
994
|
+
* @category Retrieval
|
|
995
|
+
*/
|
|
996
|
+
override async first<T = M>(): Promise<T | null> {
|
|
997
|
+
this._beforeTerminal();
|
|
998
|
+
const row = await super.first<Record<string, unknown>>();
|
|
999
|
+
if (row === null) return null;
|
|
1000
|
+
const inst = this._ModelClass.fromRow(row) as M;
|
|
1001
|
+
|
|
1002
|
+
if (this._eagerSpecs.length > 0) {
|
|
1003
|
+
await this._eagerLoadRelations([inst]);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
await HookRegistry.run(this._ModelClass, "afterFind", inst);
|
|
1007
|
+
return inst as unknown as T;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ── Named methods ────────────────────────────────────────────────────────
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Find a single instance by primary key, or throw when it does not exist.
|
|
1014
|
+
*
|
|
1015
|
+
* Honours any constraints, scopes and eager loads already set on the builder.
|
|
1016
|
+
*
|
|
1017
|
+
* @param id - Primary-key value to look up.
|
|
1018
|
+
* @returns The matching model instance.
|
|
1019
|
+
* @throws {@link ModelNotFoundError} when no row has that primary key.
|
|
1020
|
+
* @category Retrieval
|
|
1021
|
+
*/
|
|
1022
|
+
async findOrFail(id: number): Promise<M> {
|
|
1023
|
+
const inst = await this.where(this._ModelClass.primaryKey, id).first<M>();
|
|
1024
|
+
if (inst === null) {
|
|
1025
|
+
throw new ModelNotFoundError(this._ModelClass.name, id);
|
|
1026
|
+
}
|
|
1027
|
+
return inst;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Return the first matching instance, or throw when none match.
|
|
1032
|
+
*
|
|
1033
|
+
* @returns The first matching model instance.
|
|
1034
|
+
* @throws {@link ModelNotFoundError} when the query matches no rows.
|
|
1035
|
+
* @category Retrieval
|
|
1036
|
+
*/
|
|
1037
|
+
async firstOrFail(): Promise<M> {
|
|
1038
|
+
const inst = await this.first<M>();
|
|
1039
|
+
if (inst === null) {
|
|
1040
|
+
throw new ModelNotFoundError(this._ModelClass.name);
|
|
1041
|
+
}
|
|
1042
|
+
return inst;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Declare one or more relations to eager-load in a batched follow-up query.
|
|
1047
|
+
*
|
|
1048
|
+
* Accepts a bare name, a nested dot-path, a name plus a constraint closure, an
|
|
1049
|
+
* array mixing names and `{ name: constraint }` maps, or a single such map:
|
|
1050
|
+
*
|
|
1051
|
+
* ```ts
|
|
1052
|
+
* .with('comments')
|
|
1053
|
+
* .with('author.profile') // nested (dot)
|
|
1054
|
+
* .with('comments', (q) => q.where('ok', true)) // constrained
|
|
1055
|
+
* .with({ comments: (q) => q.where('ok', true) })
|
|
1056
|
+
* .with(['author', { comments: (q) => q.where('ok', true) }])
|
|
1057
|
+
* ```
|
|
1058
|
+
*
|
|
1059
|
+
* The single-bare-string overload narrows the builder's type so callers keep
|
|
1060
|
+
* fully-typed access to the loaded relation.
|
|
1061
|
+
*
|
|
1062
|
+
* @remarks
|
|
1063
|
+
* Eager loads run through the related model's *unscoped* query, so the related
|
|
1064
|
+
* model's soft-delete and global scopes are **not** applied — trashed related
|
|
1065
|
+
* rows are included. Constraint closures may add their own filters. Contrast with
|
|
1066
|
+
* {@link has} / {@link withCount}, which do exclude soft-deleted related rows.
|
|
1067
|
+
*
|
|
1068
|
+
* @param relation - Relation name, dot-path, array, or `{ name: constraint }` map.
|
|
1069
|
+
* @param constraint - Optional constraint closure (single-string form only).
|
|
1070
|
+
* @returns This builder (type-narrowed for the single-string overload).
|
|
1071
|
+
* @category Eager loading
|
|
1072
|
+
*
|
|
1073
|
+
* @example
|
|
1074
|
+
* ```ts
|
|
1075
|
+
* const users = await User.query()
|
|
1076
|
+
* .with('posts', (q) => q.where('published', true))
|
|
1077
|
+
* .with('profile')
|
|
1078
|
+
* .get();
|
|
1079
|
+
* ```
|
|
1080
|
+
*/
|
|
1081
|
+
with<K extends keyof M & string>(relation: K): ModelQueryBuilder<WithLoaded<M, K> & BaseModel>;
|
|
1082
|
+
with(relation: string, constraint: RelationConstraint): this;
|
|
1083
|
+
with(relations: Array<string | Record<string, RelationConstraint>>): this;
|
|
1084
|
+
with(map: Record<string, RelationConstraint | true>): this;
|
|
1085
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1086
|
+
with(arg: unknown, constraint?: RelationConstraint): any {
|
|
1087
|
+
if (typeof arg === "string") {
|
|
1088
|
+
this._addEager(arg, constraint);
|
|
1089
|
+
} else if (Array.isArray(arg)) {
|
|
1090
|
+
for (const item of arg) {
|
|
1091
|
+
if (typeof item === "string") this._addEager(item);
|
|
1092
|
+
else
|
|
1093
|
+
for (const [k, v] of Object.entries(item))
|
|
1094
|
+
this._addEager(k, typeof v === "function" ? (v as RelationConstraint) : undefined);
|
|
1095
|
+
}
|
|
1096
|
+
} else if (arg && typeof arg === "object") {
|
|
1097
|
+
for (const [k, v] of Object.entries(arg))
|
|
1098
|
+
this._addEager(k, typeof v === "function" ? (v as RelationConstraint) : undefined);
|
|
1099
|
+
}
|
|
1100
|
+
return this;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private _addEager(path: string, constraint?: RelationConstraint): void {
|
|
1104
|
+
const parts = path.split(".");
|
|
1105
|
+
let level = this._eagerSpecs;
|
|
1106
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1107
|
+
const name = parts[i]!;
|
|
1108
|
+
let spec = level.find((s) => s.name === name);
|
|
1109
|
+
if (!spec) {
|
|
1110
|
+
spec = { name, children: [] };
|
|
1111
|
+
level.push(spec);
|
|
1112
|
+
}
|
|
1113
|
+
if (i === parts.length - 1 && constraint) spec.constraint = constraint;
|
|
1114
|
+
level = spec.children;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Apply one or more named scopes defined as static methods on the model.
|
|
1120
|
+
*
|
|
1121
|
+
* The callback receives a proxy whose methods mirror the model's static scope
|
|
1122
|
+
* methods; each returns a scope object whose `apply(query)` mutates this builder.
|
|
1123
|
+
*
|
|
1124
|
+
* @param callback - Receives a proxy of the model's named scopes to invoke.
|
|
1125
|
+
* @returns This builder for chaining.
|
|
1126
|
+
* @category Scopes
|
|
1127
|
+
*
|
|
1128
|
+
* @example
|
|
1129
|
+
* ```ts
|
|
1130
|
+
* User.query().withScopes((s) => { s.active(); s.byScore(50); }).get();
|
|
1131
|
+
* ```
|
|
1132
|
+
*/
|
|
1133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1134
|
+
withScopes(callback: (scopes: any) => void): this {
|
|
1135
|
+
const ModelClass = this._ModelClass;
|
|
1136
|
+
const self = this;
|
|
1137
|
+
const proxy = new Proxy({} as Record<string, (...args: unknown[]) => void>, {
|
|
1138
|
+
get(_target, prop: string | symbol) {
|
|
1139
|
+
return (...args: unknown[]) => {
|
|
1140
|
+
const fn = (ModelClass as unknown as Record<string | symbol, unknown>)[prop];
|
|
1141
|
+
if (typeof fn !== "function") return;
|
|
1142
|
+
const result = fn(...args) as { apply?: (q: unknown) => void } | null | undefined;
|
|
1143
|
+
if (result != null && typeof result.apply === "function") {
|
|
1144
|
+
result.apply(self);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
callback(proxy);
|
|
1150
|
+
return this;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Model-aware length-aware pagination — returns hydrated model instances (not
|
|
1155
|
+
* raw rows) and honours eager-load chains set via {@link with}.
|
|
1156
|
+
*
|
|
1157
|
+
* Runs a `count()` for the total, then fetches the page via `limit`/`offset`.
|
|
1158
|
+
* `page` and `perPage` are clamped to a minimum of 1.
|
|
1159
|
+
*
|
|
1160
|
+
* @typeParam T - Element type of the page data (defaults to the model `M`).
|
|
1161
|
+
* @param perPage - Rows per page (default 15).
|
|
1162
|
+
* @param page - 1-based page number (default 1).
|
|
1163
|
+
* @returns A paginate result with `data`, `total`, `page`, `perPage`, `lastPage`
|
|
1164
|
+
* and the standard pagination helpers.
|
|
1165
|
+
* @category Pagination
|
|
1166
|
+
*
|
|
1167
|
+
* @example
|
|
1168
|
+
* ```ts
|
|
1169
|
+
* const page = await User.query().with('posts').paginate(20, 2);
|
|
1170
|
+
* console.log(page.total, page.lastPage, page.data.length);
|
|
1171
|
+
* ```
|
|
1172
|
+
*/
|
|
1173
|
+
override paginate<T = M>(
|
|
1174
|
+
perPage = 15,
|
|
1175
|
+
page?: number,
|
|
1176
|
+
pageName = "page",
|
|
1177
|
+
): Promise<PaginateResult<T>> {
|
|
1178
|
+
// The base implementation already routes the page fetch through the
|
|
1179
|
+
// polymorphic this.get(), and — unlike the copy this replaced — restores
|
|
1180
|
+
// limit/offset afterwards so the builder is reusable.
|
|
1181
|
+
return super.paginate<T>(perPage, page, pageName);
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Model-aware {@link QueryBuilder.simplePaginate} — returns model instances
|
|
1185
|
+
* (not raw rows). Defaults the result element type to the model class.
|
|
1186
|
+
*
|
|
1187
|
+
* @remarks Argument order is `(perPage, page)`, matching {@link paginate}.
|
|
1188
|
+
*
|
|
1189
|
+
* @typeParam T - Element type of the page data (defaults to the model `M`).
|
|
1190
|
+
* @param perPage - Rows per page (default 15).
|
|
1191
|
+
* @param page - 1-based page number (default 1).
|
|
1192
|
+
* @category Pagination
|
|
1193
|
+
*/
|
|
1194
|
+
override simplePaginate<T = M>(perPage = 15, page = 1): Promise<SimplePaginateResult<T>> {
|
|
1195
|
+
return super.simplePaginate<T>(perPage, page);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Model-aware {@link QueryBuilder.cursorPaginate} — returns model instances
|
|
1200
|
+
* (not raw rows). Defaults the result element type to the model class.
|
|
1201
|
+
*
|
|
1202
|
+
* @typeParam T - Element type of the page data (defaults to the model `M`).
|
|
1203
|
+
* @param options - Optional `cursor` (last seen id) and `limit`.
|
|
1204
|
+
* @category Pagination
|
|
1205
|
+
*/
|
|
1206
|
+
override cursorPaginate<T = M>(options?: {
|
|
1207
|
+
cursor?: number;
|
|
1208
|
+
limit?: number;
|
|
1209
|
+
}): Promise<CursorPaginateResult<T>> {
|
|
1210
|
+
return super.cursorPaginate<T>(options);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Model-aware {@link QueryBuilder.keysetPaginate} — returns model instances (not raw
|
|
1215
|
+
* rows), with casts applied, `hidden` stripped, eager loads run and global scopes
|
|
1216
|
+
* honoured, because it now goes through `get()` like every other terminal.
|
|
1217
|
+
*
|
|
1218
|
+
* @typeParam T - Element type of the page data (defaults to the model `M`).
|
|
1219
|
+
* @param options - Sort `column`, `direction`, `limit` and opaque `cursor`.
|
|
1220
|
+
* @category Pagination
|
|
1221
|
+
*/
|
|
1222
|
+
override keysetPaginate<T = M>(options?: KeysetOptions): Promise<KeysetPaginateResult<T>> {
|
|
1223
|
+
return super.keysetPaginate<T>(options);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Ties are broken by the model's declared primary key, not a hard-coded `id` — a model
|
|
1228
|
+
* keyed on `uuid` or `code` was otherwise ordered and cursored by a column that need not
|
|
1229
|
+
* exist.
|
|
1230
|
+
* @internal
|
|
1231
|
+
*/
|
|
1232
|
+
protected override _keysetTiebreaker(): string {
|
|
1233
|
+
return this._ModelClass.primaryKey;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ── Private: Two-Query Dictionary Match ──────────────────────────────────
|
|
1237
|
+
|
|
1238
|
+
private _coerceWhereValue(column: string, value: unknown, operator?: WhereOperator): unknown {
|
|
1239
|
+
if (value === null || value === undefined) return value;
|
|
1240
|
+
|
|
1241
|
+
const rawKey = column.split(".").pop() ?? column;
|
|
1242
|
+
const camelKey = rawKey.includes("_") ? _toCamel(rawKey) : rawKey;
|
|
1243
|
+
const casts = _getCasts(this._ModelClass as unknown as Function);
|
|
1244
|
+
const colMeta = columnsFor(this._ModelClass as unknown as Function)?.get(camelKey);
|
|
1245
|
+
const castOpt = casts[rawKey] ?? casts[camelKey] ?? colMeta?.cast;
|
|
1246
|
+
const colType = colMeta?.type;
|
|
1247
|
+
|
|
1248
|
+
if (operator === "in" || operator === "not in") {
|
|
1249
|
+
if (Array.isArray(value)) {
|
|
1250
|
+
return value.map((v) => this._coerceWhereValue(column, v));
|
|
1251
|
+
}
|
|
1252
|
+
return value;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (castOpt && typeof castOpt === "object" && castOpt.set) {
|
|
1256
|
+
return castOpt.set(value);
|
|
1257
|
+
}
|
|
1258
|
+
if (typeof castOpt === "string") {
|
|
1259
|
+
return _applyCastSet(value, castOpt as StringCast);
|
|
1260
|
+
}
|
|
1261
|
+
if (colType === "boolean") {
|
|
1262
|
+
return value ? 1 : 0;
|
|
1263
|
+
}
|
|
1264
|
+
if (colType === "json" && typeof value !== "string") {
|
|
1265
|
+
return JSON.stringify(value);
|
|
1266
|
+
}
|
|
1267
|
+
// Carbon instances always serialize to ISO string regardless of column metadata.
|
|
1268
|
+
if (value instanceof Carbon) return value.toDatabase();
|
|
1269
|
+
return value;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Eager-load a tree of relation specs onto the given instances. For each spec
|
|
1274
|
+
* the relation is loaded (optionally constrained), then any nested children
|
|
1275
|
+
* are loaded recursively on the freshly-loaded related instances.
|
|
1276
|
+
*/
|
|
1277
|
+
private async _eagerLoadRelations(
|
|
1278
|
+
instances: M[],
|
|
1279
|
+
specs: EagerSpec[] = this._eagerSpecs,
|
|
1280
|
+
): Promise<void> {
|
|
1281
|
+
const metaMap = this._allRelationsMeta();
|
|
1282
|
+
|
|
1283
|
+
for (const spec of specs) {
|
|
1284
|
+
const meta = metaMap?.get(spec.name);
|
|
1285
|
+
if (!meta) {
|
|
1286
|
+
throw new Error(`Relation "${spec.name}" is not defined on ${this._ModelClass.name}`);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const related = await this._loadOneRelation(instances, spec.name, meta, spec.constraint);
|
|
1290
|
+
|
|
1291
|
+
if (spec.children.length > 0 && related.length > 0) {
|
|
1292
|
+
if (meta.type === "morphTo") {
|
|
1293
|
+
// Mixed related classes — group by constructor and recurse per group.
|
|
1294
|
+
const groups = new Map<Function, BaseModel[]>();
|
|
1295
|
+
for (const r of related) {
|
|
1296
|
+
const ctor = r.constructor as Function;
|
|
1297
|
+
if (!groups.has(ctor)) groups.set(ctor, []);
|
|
1298
|
+
groups.get(ctor)!.push(r);
|
|
1299
|
+
}
|
|
1300
|
+
for (const [ctor, group] of groups) {
|
|
1301
|
+
const RelatedClass = ctor as unknown as typeof BaseModel;
|
|
1302
|
+
const child = new ModelQueryBuilder(RelatedClass.table, this._sql, RelatedClass);
|
|
1303
|
+
await child._eagerLoadRelations(group as M[], spec.children);
|
|
1304
|
+
}
|
|
1305
|
+
} else {
|
|
1306
|
+
const RelatedClass = meta.related() as typeof BaseModel;
|
|
1307
|
+
const child = new ModelQueryBuilder(RelatedClass.table, this._sql, RelatedClass);
|
|
1308
|
+
await child._eagerLoadRelations(related as M[], spec.children);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Load a single relation onto `instances` and return the flat list of related
|
|
1316
|
+
* model instances that were attached (for nested eager loading).
|
|
1317
|
+
*/
|
|
1318
|
+
private async _loadOneRelation(
|
|
1319
|
+
instances: M[],
|
|
1320
|
+
relation: string,
|
|
1321
|
+
meta: import("./relations/RelationRegistry.ts").RelationMetadata,
|
|
1322
|
+
constraint?: RelationConstraint,
|
|
1323
|
+
): Promise<BaseModel[]> {
|
|
1324
|
+
const RelatedClass = meta.related() as typeof BaseModel;
|
|
1325
|
+
const applyConstraint = constraint
|
|
1326
|
+
? (b: unknown) => constraint(b as ModelQueryBuilder<BaseModel>)
|
|
1327
|
+
: undefined;
|
|
1328
|
+
|
|
1329
|
+
// Eager-loaded relations honour the related model's soft-delete scope.
|
|
1330
|
+
const scopedRelated = (): _ChunkFactory<BaseModel> =>
|
|
1331
|
+
_scopedRelated(RelatedClass) as unknown as _ChunkFactory<BaseModel>;
|
|
1332
|
+
|
|
1333
|
+
if (meta.type === "hasManyThrough" || meta.type === "hasOneThrough") {
|
|
1334
|
+
return this._loadThrough(
|
|
1335
|
+
instances,
|
|
1336
|
+
relation,
|
|
1337
|
+
meta,
|
|
1338
|
+
meta.type === "hasOneThrough",
|
|
1339
|
+
applyConstraint,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
if (meta.type === "morphToMany" || meta.type === "morphedByMany") {
|
|
1343
|
+
return this._loadMorphToMany(
|
|
1344
|
+
instances,
|
|
1345
|
+
relation,
|
|
1346
|
+
meta,
|
|
1347
|
+
meta.type === "morphedByMany",
|
|
1348
|
+
applyConstraint,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// ── manyToMany — pivot table loading ──────────────────────────────
|
|
1353
|
+
if (meta.type === "manyToMany") {
|
|
1354
|
+
const localKeyProp = _toCamel(meta.localKey);
|
|
1355
|
+
const parentIds = [
|
|
1356
|
+
...new Set(
|
|
1357
|
+
instances
|
|
1358
|
+
.map((i) => (i as unknown as Record<string, unknown>)[localKeyProp])
|
|
1359
|
+
.filter((v) => v !== undefined && v !== null),
|
|
1360
|
+
),
|
|
1361
|
+
];
|
|
1362
|
+
|
|
1363
|
+
if (parentIds.length === 0) {
|
|
1364
|
+
for (const inst of instances) this._attach(inst, relation, []);
|
|
1365
|
+
return [];
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const pivotTable = meta.pivotTable!;
|
|
1369
|
+
const pivotFK = meta.pivotForeignKey!;
|
|
1370
|
+
const sql = this._sql;
|
|
1371
|
+
const pivotRows = await _whereInChunked<Record<string, unknown>>(
|
|
1372
|
+
() => new QueryBuilder(pivotTable, sql),
|
|
1373
|
+
pivotFK,
|
|
1374
|
+
parentIds,
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
const relatedIds = [
|
|
1378
|
+
...new Set(pivotRows.map((r) => r[meta.pivotRelatedKey!]).filter((v) => v != null)),
|
|
1379
|
+
];
|
|
1380
|
+
|
|
1381
|
+
if (relatedIds.length === 0) {
|
|
1382
|
+
for (const inst of instances) this._attach(inst, relation, []);
|
|
1383
|
+
return [];
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const relatedRows = await _whereInChunked<BaseModel>(
|
|
1387
|
+
scopedRelated,
|
|
1388
|
+
RelatedClass.primaryKey,
|
|
1389
|
+
relatedIds,
|
|
1390
|
+
applyConstraint,
|
|
1391
|
+
);
|
|
1392
|
+
|
|
1393
|
+
const relatedDict = new Map<unknown, BaseModel>();
|
|
1394
|
+
for (const rm of relatedRows) {
|
|
1395
|
+
relatedDict.set(
|
|
1396
|
+
(rm as unknown as Record<string, unknown>)[_toCamel(RelatedClass.primaryKey)],
|
|
1397
|
+
rm,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const hydratePivot = !!(meta.pivotColumns?.length || meta.pivotTimestamps);
|
|
1402
|
+
const parentDict = new Map<unknown, BaseModel[]>();
|
|
1403
|
+
// The objects actually attached to parents. With `withPivot` each (parent, related)
|
|
1404
|
+
// pair gets its own copy so it can carry that pair's pivot row, and it is the copy
|
|
1405
|
+
// the parent holds — so nested eager loads have to run against these, not against
|
|
1406
|
+
// the shared originals.
|
|
1407
|
+
const attached: BaseModel[] = [];
|
|
1408
|
+
for (const prow of pivotRows) {
|
|
1409
|
+
const parentId = prow[meta.pivotForeignKey!];
|
|
1410
|
+
const relatedId = prow[meta.pivotRelatedKey!];
|
|
1411
|
+
const rm = relatedDict.get(relatedId);
|
|
1412
|
+
if (rm) {
|
|
1413
|
+
let item: BaseModel = rm;
|
|
1414
|
+
if (hydratePivot) {
|
|
1415
|
+
// Copy every own property *descriptor*, not just the enumerable values.
|
|
1416
|
+
// `Object.assign` skipped the non-enumerable lazy-load guards, so an unloaded
|
|
1417
|
+
// relation on a pivot-hydrated model silently read as `undefined` instead of
|
|
1418
|
+
// raising RelationNotLoadedError.
|
|
1419
|
+
item = Object.create(
|
|
1420
|
+
Object.getPrototypeOf(rm) as object,
|
|
1421
|
+
Object.getOwnPropertyDescriptors(rm),
|
|
1422
|
+
) as BaseModel;
|
|
1423
|
+
const pivot: Record<string, unknown> = {};
|
|
1424
|
+
for (const c of meta.pivotColumns ?? []) pivot[c] = prow[c];
|
|
1425
|
+
if (meta.pivotTimestamps) {
|
|
1426
|
+
pivot["created_at"] = prow["created_at"];
|
|
1427
|
+
pivot["updated_at"] = prow["updated_at"];
|
|
1428
|
+
}
|
|
1429
|
+
(item as unknown as Record<string, unknown>)["pivot"] = pivot;
|
|
1430
|
+
}
|
|
1431
|
+
if (!parentDict.has(parentId)) parentDict.set(parentId, []);
|
|
1432
|
+
parentDict.get(parentId)!.push(item);
|
|
1433
|
+
attached.push(item);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
for (const inst of instances) {
|
|
1438
|
+
const pid = (inst as unknown as Record<string, unknown>)[localKeyProp];
|
|
1439
|
+
this._attach(
|
|
1440
|
+
inst,
|
|
1441
|
+
relation,
|
|
1442
|
+
_createPivotCollection(
|
|
1443
|
+
parentDict.get(pid) ?? [],
|
|
1444
|
+
meta.pivotTable!,
|
|
1445
|
+
meta.pivotForeignKey!,
|
|
1446
|
+
meta.pivotRelatedKey!,
|
|
1447
|
+
pid,
|
|
1448
|
+
this._sql,
|
|
1449
|
+
!!meta.pivotTimestamps,
|
|
1450
|
+
),
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
// Deduplicated by identity: the same object can be attached to several parents when
|
|
1454
|
+
// pivot data is not being hydrated, and loading its children twice is wasted work.
|
|
1455
|
+
return [...new Set(attached)];
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ── Polymorphic types ──────────────────────────────────────────────
|
|
1459
|
+
if (meta.type === "morphTo") {
|
|
1460
|
+
return this._eagerLoadMorphTo(instances, relation, meta, applyConstraint);
|
|
1461
|
+
}
|
|
1462
|
+
if (meta.type === "morphMany") {
|
|
1463
|
+
return this._eagerLoadMorphInverse(instances, relation, meta, false, applyConstraint);
|
|
1464
|
+
}
|
|
1465
|
+
if (meta.type === "morphOne") {
|
|
1466
|
+
return this._eagerLoadMorphInverse(instances, relation, meta, true, applyConstraint);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const isBelongsTo = meta.type === "belongsTo";
|
|
1470
|
+
|
|
1471
|
+
const collectSnake = isBelongsTo ? meta.foreignKey : meta.localKey;
|
|
1472
|
+
const queryColumn = isBelongsTo ? meta.localKey : meta.foreignKey;
|
|
1473
|
+
const dictSnake = isBelongsTo ? meta.localKey : meta.foreignKey;
|
|
1474
|
+
const matchSnake = isBelongsTo ? meta.foreignKey : meta.localKey;
|
|
1475
|
+
|
|
1476
|
+
const collectProp = _toCamel(collectSnake);
|
|
1477
|
+
const dictProp = _toCamel(dictSnake);
|
|
1478
|
+
const matchProp = _toCamel(matchSnake);
|
|
1479
|
+
|
|
1480
|
+
const keyValues = [
|
|
1481
|
+
...new Set(
|
|
1482
|
+
instances
|
|
1483
|
+
.map((i) => (i as unknown as Record<string, unknown>)[collectProp])
|
|
1484
|
+
.filter((v) => v !== undefined && v !== null),
|
|
1485
|
+
),
|
|
1486
|
+
];
|
|
1487
|
+
|
|
1488
|
+
if (keyValues.length === 0) {
|
|
1489
|
+
const empty: unknown = meta.type === "hasMany" ? [] : null;
|
|
1490
|
+
if (empty === null && meta.withDefault !== undefined && meta.withDefault !== false) {
|
|
1491
|
+
for (const inst of instances) {
|
|
1492
|
+
this._attach(inst, relation, _makeDefault(RelatedClass, meta.withDefault));
|
|
1493
|
+
}
|
|
1494
|
+
} else {
|
|
1495
|
+
for (const inst of instances) this._attach(inst, relation, empty);
|
|
1496
|
+
}
|
|
1497
|
+
return [];
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const relatedRows = await _whereInChunked<BaseModel>(
|
|
1501
|
+
scopedRelated,
|
|
1502
|
+
queryColumn,
|
|
1503
|
+
keyValues,
|
|
1504
|
+
applyConstraint,
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
const dict = new Map<unknown, BaseModel[]>();
|
|
1508
|
+
for (const rm of relatedRows) {
|
|
1509
|
+
const key = (rm as unknown as Record<string, unknown>)[dictProp];
|
|
1510
|
+
if (!dict.has(key)) dict.set(key, []);
|
|
1511
|
+
dict.get(key)!.push(rm);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
for (const inst of instances) {
|
|
1515
|
+
const matchVal = (inst as unknown as Record<string, unknown>)[matchProp];
|
|
1516
|
+
const matched = dict.get(matchVal) ?? [];
|
|
1517
|
+
let value: unknown;
|
|
1518
|
+
if (meta.type === "hasMany") {
|
|
1519
|
+
value = matched;
|
|
1520
|
+
} else {
|
|
1521
|
+
value = matched[0] ?? null;
|
|
1522
|
+
if (value === null && meta.withDefault !== undefined && meta.withDefault !== false) {
|
|
1523
|
+
value = _makeDefault(RelatedClass, meta.withDefault);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
this._attach(inst, relation, value);
|
|
1527
|
+
}
|
|
1528
|
+
return relatedRows;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// ── Polymorphic eager loading ─────────────────────────────────────────────
|
|
1532
|
+
|
|
1533
|
+
private async _eagerLoadMorphTo(
|
|
1534
|
+
instances: M[],
|
|
1535
|
+
relation: string,
|
|
1536
|
+
meta: import("./relations/RelationRegistry.ts").RelationMetadata,
|
|
1537
|
+
applyConstraint?: (b: unknown) => void,
|
|
1538
|
+
): Promise<BaseModel[]> {
|
|
1539
|
+
const typeColProp = _toCamel(meta.morphTypeColumn!);
|
|
1540
|
+
const idColProp = _toCamel(meta.foreignKey);
|
|
1541
|
+
const collected: BaseModel[] = [];
|
|
1542
|
+
|
|
1543
|
+
const byType = new Map<string, { ids: unknown[]; indices: number[] }>();
|
|
1544
|
+
for (let i = 0; i < instances.length; i++) {
|
|
1545
|
+
const inst = instances[i] as unknown as Record<string, unknown>;
|
|
1546
|
+
const tname = inst[typeColProp] as string | null | undefined;
|
|
1547
|
+
const id = inst[idColProp];
|
|
1548
|
+
if (!tname || id == null) {
|
|
1549
|
+
this._attach(instances[i]!, relation, null);
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
if (!byType.has(tname)) byType.set(tname, { ids: [], indices: [] });
|
|
1553
|
+
byType.get(tname)!.ids.push(id);
|
|
1554
|
+
byType.get(tname)!.indices.push(i);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
for (const [typeName, { ids, indices }] of byType) {
|
|
1558
|
+
const factory = meta.morphMap?.[typeName];
|
|
1559
|
+
if (!factory) {
|
|
1560
|
+
for (const i of indices) this._attach(instances[i]!, relation, null);
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
const RelatedClass = factory() as typeof BaseModel;
|
|
1564
|
+
const rows = await _whereInChunked<BaseModel>(
|
|
1565
|
+
() => _scopedRelated(RelatedClass) as unknown as _ChunkFactory<BaseModel>,
|
|
1566
|
+
RelatedClass.primaryKey,
|
|
1567
|
+
ids,
|
|
1568
|
+
applyConstraint,
|
|
1569
|
+
);
|
|
1570
|
+
const dict = new Map<unknown, BaseModel>();
|
|
1571
|
+
for (const r of rows) {
|
|
1572
|
+
dict.set((r as unknown as Record<string, unknown>)[_toCamel(RelatedClass.primaryKey)], r);
|
|
1573
|
+
}
|
|
1574
|
+
for (let k = 0; k < indices.length; k++) {
|
|
1575
|
+
const inst = instances[indices[k]!] as unknown as Record<string, unknown>;
|
|
1576
|
+
const matched = dict.get(inst[idColProp]) ?? null;
|
|
1577
|
+
this._attach(instances[indices[k]!]!, relation, matched);
|
|
1578
|
+
if (matched) collected.push(matched);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return collected;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
private async _eagerLoadMorphInverse(
|
|
1585
|
+
instances: M[],
|
|
1586
|
+
relation: string,
|
|
1587
|
+
meta: import("./relations/RelationRegistry.ts").RelationMetadata,
|
|
1588
|
+
singular: boolean,
|
|
1589
|
+
applyConstraint?: (b: unknown) => void,
|
|
1590
|
+
): Promise<BaseModel[]> {
|
|
1591
|
+
const RelatedClass = meta.related() as typeof BaseModel;
|
|
1592
|
+
const localKeyProp = _toCamel(meta.localKey);
|
|
1593
|
+
const typeColumn = meta.morphTypeColumn!;
|
|
1594
|
+
const idColumn = meta.foreignKey;
|
|
1595
|
+
const typeName = this._ModelClass.name;
|
|
1596
|
+
|
|
1597
|
+
const parentIds = [
|
|
1598
|
+
...new Set(
|
|
1599
|
+
instances
|
|
1600
|
+
.map((i) => (i as unknown as Record<string, unknown>)[localKeyProp])
|
|
1601
|
+
.filter((v) => v != null),
|
|
1602
|
+
),
|
|
1603
|
+
];
|
|
1604
|
+
|
|
1605
|
+
if (parentIds.length === 0) {
|
|
1606
|
+
for (const inst of instances) this._attach(inst, relation, singular ? null : []);
|
|
1607
|
+
return [];
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const rows = await _whereInChunked<BaseModel>(
|
|
1611
|
+
() =>
|
|
1612
|
+
_scopedRelated(RelatedClass).where(
|
|
1613
|
+
typeColumn,
|
|
1614
|
+
typeName,
|
|
1615
|
+
) as unknown as _ChunkFactory<BaseModel>,
|
|
1616
|
+
idColumn,
|
|
1617
|
+
parentIds,
|
|
1618
|
+
applyConstraint,
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
const idColProp = _toCamel(idColumn);
|
|
1622
|
+
const dict = new Map<unknown, BaseModel[]>();
|
|
1623
|
+
for (const r of rows) {
|
|
1624
|
+
const key = (r as unknown as Record<string, unknown>)[idColProp];
|
|
1625
|
+
if (!dict.has(key)) dict.set(key, []);
|
|
1626
|
+
dict.get(key)!.push(r);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
for (const inst of instances) {
|
|
1630
|
+
const pid = (inst as unknown as Record<string, unknown>)[localKeyProp];
|
|
1631
|
+
const matched = dict.get(pid) ?? [];
|
|
1632
|
+
this._attach(inst, relation, singular ? (matched[0] ?? null) : matched);
|
|
1633
|
+
}
|
|
1634
|
+
return rows;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// ── has*Through eager loading ──────────────────────────────────────────────
|
|
1638
|
+
|
|
1639
|
+
private async _loadThrough(
|
|
1640
|
+
instances: M[],
|
|
1641
|
+
relation: string,
|
|
1642
|
+
meta: import("./relations/RelationRegistry.ts").RelationMetadata,
|
|
1643
|
+
singular: boolean,
|
|
1644
|
+
applyConstraint?: (b: unknown) => void,
|
|
1645
|
+
): Promise<BaseModel[]> {
|
|
1646
|
+
const Through = meta.through!() as typeof BaseModel;
|
|
1647
|
+
const Related = meta.related() as typeof BaseModel;
|
|
1648
|
+
const localKeyProp = _toCamel(meta.localKey); // parent PK prop
|
|
1649
|
+
const firstKeyProp = _toCamel(meta.firstKey!); // through.<firstKey>
|
|
1650
|
+
const throughLocalProp = _toCamel(meta.throughLocalKey!); // through PK prop
|
|
1651
|
+
const secondKeyProp = _toCamel(meta.foreignKey); // related.<secondKey>
|
|
1652
|
+
|
|
1653
|
+
const parentIds = [
|
|
1654
|
+
...new Set(
|
|
1655
|
+
instances
|
|
1656
|
+
.map((i) => (i as unknown as Record<string, unknown>)[localKeyProp])
|
|
1657
|
+
.filter((v) => v != null),
|
|
1658
|
+
),
|
|
1659
|
+
];
|
|
1660
|
+
if (parentIds.length === 0) {
|
|
1661
|
+
for (const inst of instances) this._attach(inst, relation, singular ? null : []);
|
|
1662
|
+
return [];
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const throughRows = await _whereInChunked<BaseModel>(
|
|
1666
|
+
() => _scopedRelated(Through) as unknown as _ChunkFactory<BaseModel>,
|
|
1667
|
+
meta.firstKey!,
|
|
1668
|
+
parentIds,
|
|
1669
|
+
);
|
|
1670
|
+
const throughToParent = new Map<unknown, unknown>();
|
|
1671
|
+
const throughKeys: unknown[] = [];
|
|
1672
|
+
for (const tr of throughRows) {
|
|
1673
|
+
const rec = tr as unknown as Record<string, unknown>;
|
|
1674
|
+
throughToParent.set(rec[throughLocalProp], rec[firstKeyProp]);
|
|
1675
|
+
throughKeys.push(rec[throughLocalProp]);
|
|
1676
|
+
}
|
|
1677
|
+
const uniqThrough = [...new Set(throughKeys.filter((v) => v != null))];
|
|
1678
|
+
if (uniqThrough.length === 0) {
|
|
1679
|
+
for (const inst of instances) this._attach(inst, relation, singular ? null : []);
|
|
1680
|
+
return [];
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const relatedRows = await _whereInChunked<BaseModel>(
|
|
1684
|
+
() => _scopedRelated(Related) as unknown as _ChunkFactory<BaseModel>,
|
|
1685
|
+
meta.foreignKey,
|
|
1686
|
+
uniqThrough,
|
|
1687
|
+
applyConstraint,
|
|
1688
|
+
);
|
|
1689
|
+
|
|
1690
|
+
const parentDict = new Map<unknown, BaseModel[]>();
|
|
1691
|
+
for (const rr of relatedRows) {
|
|
1692
|
+
const throughId = (rr as unknown as Record<string, unknown>)[secondKeyProp];
|
|
1693
|
+
const parentId = throughToParent.get(throughId);
|
|
1694
|
+
if (parentId == null) continue;
|
|
1695
|
+
if (!parentDict.has(parentId)) parentDict.set(parentId, []);
|
|
1696
|
+
parentDict.get(parentId)!.push(rr);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
for (const inst of instances) {
|
|
1700
|
+
const pid = (inst as unknown as Record<string, unknown>)[localKeyProp];
|
|
1701
|
+
const matched = parentDict.get(pid) ?? [];
|
|
1702
|
+
this._attach(inst, relation, singular ? (matched[0] ?? null) : matched);
|
|
1703
|
+
}
|
|
1704
|
+
return relatedRows;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// ── morphToMany / morphedByMany eager loading ──────────────────────────────
|
|
1708
|
+
|
|
1709
|
+
private async _loadMorphToMany(
|
|
1710
|
+
instances: M[],
|
|
1711
|
+
relation: string,
|
|
1712
|
+
meta: import("./relations/RelationRegistry.ts").RelationMetadata,
|
|
1713
|
+
inverse: boolean,
|
|
1714
|
+
applyConstraint?: (b: unknown) => void,
|
|
1715
|
+
): Promise<BaseModel[]> {
|
|
1716
|
+
const Related = meta.related() as typeof BaseModel;
|
|
1717
|
+
const localKeyProp = _toCamel(meta.localKey);
|
|
1718
|
+
const pivotTable = meta.pivotTable!;
|
|
1719
|
+
const pivotFK = meta.pivotForeignKey!;
|
|
1720
|
+
const pivotRK = meta.pivotRelatedKey!;
|
|
1721
|
+
const morphType = meta.pivotMorphType!;
|
|
1722
|
+
const morphValue = inverse ? Related.name : this._ModelClass.name;
|
|
1723
|
+
const sql = this._sql;
|
|
1724
|
+
|
|
1725
|
+
const parentIds = [
|
|
1726
|
+
...new Set(
|
|
1727
|
+
instances
|
|
1728
|
+
.map((i) => (i as unknown as Record<string, unknown>)[localKeyProp])
|
|
1729
|
+
.filter((v) => v != null),
|
|
1730
|
+
),
|
|
1731
|
+
];
|
|
1732
|
+
if (parentIds.length === 0) {
|
|
1733
|
+
for (const inst of instances) this._attach(inst, relation, []);
|
|
1734
|
+
return [];
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const pivotRows = await _whereInChunked<Record<string, unknown>>(
|
|
1738
|
+
() =>
|
|
1739
|
+
new QueryBuilder(pivotTable, sql).where(morphType, morphValue) as unknown as _ChunkFactory<
|
|
1740
|
+
Record<string, unknown>
|
|
1741
|
+
>,
|
|
1742
|
+
pivotFK,
|
|
1743
|
+
parentIds,
|
|
1744
|
+
);
|
|
1745
|
+
|
|
1746
|
+
const relatedIds = [...new Set(pivotRows.map((r) => r[pivotRK]).filter((v) => v != null))];
|
|
1747
|
+
if (relatedIds.length === 0) {
|
|
1748
|
+
for (const inst of instances) this._attach(inst, relation, []);
|
|
1749
|
+
return [];
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const relatedRows = await _whereInChunked<BaseModel>(
|
|
1753
|
+
() => _scopedRelated(Related) as unknown as _ChunkFactory<BaseModel>,
|
|
1754
|
+
Related.primaryKey,
|
|
1755
|
+
relatedIds,
|
|
1756
|
+
applyConstraint,
|
|
1757
|
+
);
|
|
1758
|
+
const relatedDict = new Map<unknown, BaseModel>();
|
|
1759
|
+
for (const rm of relatedRows) {
|
|
1760
|
+
relatedDict.set((rm as unknown as Record<string, unknown>)[_toCamel(Related.primaryKey)], rm);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const parentDict = new Map<unknown, BaseModel[]>();
|
|
1764
|
+
for (const prow of pivotRows) {
|
|
1765
|
+
const parentId = prow[pivotFK];
|
|
1766
|
+
const rm = relatedDict.get(prow[pivotRK]);
|
|
1767
|
+
if (rm) {
|
|
1768
|
+
if (!parentDict.has(parentId)) parentDict.set(parentId, []);
|
|
1769
|
+
parentDict.get(parentId)!.push(rm);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
for (const inst of instances) {
|
|
1774
|
+
const pid = (inst as unknown as Record<string, unknown>)[localKeyProp];
|
|
1775
|
+
this._attach(inst, relation, parentDict.get(pid) ?? []);
|
|
1776
|
+
}
|
|
1777
|
+
return relatedRows;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/** Write the loaded value via Object.defineProperty, overwriting the lazy-load guard. */
|
|
1781
|
+
private _attach(inst: M, relation: string, value: unknown): void {
|
|
1782
|
+
Object.defineProperty(inst, relation, {
|
|
1783
|
+
value,
|
|
1784
|
+
enumerable: true,
|
|
1785
|
+
configurable: true,
|
|
1786
|
+
writable: true,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function _cloneSpec(s: EagerSpec): EagerSpec {
|
|
1792
|
+
return {
|
|
1793
|
+
name: s.name,
|
|
1794
|
+
...(s.constraint ? { constraint: s.constraint } : {}),
|
|
1795
|
+
children: s.children.map(_cloneSpec),
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/** Build a default (unsaved) related instance for belongsTo/hasOne withDefault. */
|
|
1800
|
+
function _makeDefault(
|
|
1801
|
+
RelatedClass: typeof BaseModel,
|
|
1802
|
+
spec: boolean | Record<string, unknown> | ((m: unknown) => void),
|
|
1803
|
+
): BaseModel {
|
|
1804
|
+
const inst = new (RelatedClass as unknown as new () => BaseModel)();
|
|
1805
|
+
if (typeof spec === "function") spec(inst);
|
|
1806
|
+
else if (spec && typeof spec === "object") Object.assign(inst, spec);
|
|
1807
|
+
return inst;
|
|
1808
|
+
}
|