metal-orm 1.0.96 → 1.0.98
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/dist/index.cjs +25 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/hydration/types.ts +57 -57
- package/src/orm/entity-relations.ts +19 -19
- package/src/orm/hydration.ts +9 -1
- package/src/orm/lazy-batch/belongs-to-many.ts +134 -134
- package/src/orm/lazy-batch/belongs-to.ts +108 -108
- package/src/orm/lazy-batch/has-many.ts +69 -69
- package/src/orm/lazy-batch/has-one.ts +68 -68
- package/src/orm/lazy-batch/shared.ts +125 -125
- package/src/orm/save-graph.ts +48 -48
- package/src/query-builder/column-selector.ts +9 -9
- package/src/query-builder/hydration-manager.ts +353 -353
- package/src/query-builder/hydration-planner.ts +22 -22
- package/src/query-builder/relation-conditions.ts +80 -80
- package/src/query-builder/select/projection-facet.ts +23 -23
- package/src/query-builder/select-query-state.ts +213 -213
- package/src/query-builder/select.ts +1155 -1133
- package/src/schema/relation.ts +22 -22
|
@@ -1,353 +1,353 @@
|
|
|
1
|
-
import { TableDef } from '../schema/table.js';
|
|
2
|
-
import { RelationDef, RelationKinds } from '../schema/relation.js';
|
|
3
|
-
import { CommonTableExpressionNode, OrderByNode, SelectQueryNode } from '../core/ast/query.js';
|
|
4
|
-
import { HydrationPlan } from '../core/hydration/types.js';
|
|
5
|
-
import { HydrationPlanner } from './hydration-planner.js';
|
|
6
|
-
import { ProjectionNode, SelectQueryState } from './select-query-state.js';
|
|
7
|
-
import { ColumnNode, eq } from '../core/ast/expression.js';
|
|
8
|
-
import { createJoinNode } from '../core/ast/join-node.js';
|
|
9
|
-
import { JOIN_KINDS } from '../core/sql/sql.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Manages hydration planning for query results
|
|
13
|
-
*/
|
|
14
|
-
export class HydrationManager {
|
|
15
|
-
/**
|
|
16
|
-
* Creates a new HydrationManager instance
|
|
17
|
-
* @param table - Table definition
|
|
18
|
-
* @param planner - Hydration planner
|
|
19
|
-
*/
|
|
20
|
-
constructor(
|
|
21
|
-
private readonly table: TableDef,
|
|
22
|
-
private readonly planner: HydrationPlanner
|
|
23
|
-
) { }
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Creates a new HydrationManager with updated planner
|
|
27
|
-
* @param nextPlanner - Updated hydration planner
|
|
28
|
-
* @returns New HydrationManager instance
|
|
29
|
-
*/
|
|
30
|
-
private clone(nextPlanner: HydrationPlanner): HydrationManager {
|
|
31
|
-
return new HydrationManager(this.table, nextPlanner);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Handles column selection for hydration planning
|
|
36
|
-
* @param state - Current query state
|
|
37
|
-
* @param newColumns - Newly selected columns
|
|
38
|
-
* @returns Updated HydrationManager with captured columns
|
|
39
|
-
*/
|
|
40
|
-
onColumnsSelected(state: SelectQueryState, newColumns: ProjectionNode[]): HydrationManager {
|
|
41
|
-
const updated = this.planner.captureRootColumns(newColumns);
|
|
42
|
-
return this.clone(updated);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Handles relation inclusion for hydration planning
|
|
47
|
-
* @param state - Current query state
|
|
48
|
-
* @param relation - Relation definition
|
|
49
|
-
* @param relationName - Name of the relation
|
|
50
|
-
* @param aliasPrefix - Alias prefix for the relation
|
|
51
|
-
* @param targetColumns - Target columns to include
|
|
52
|
-
* @returns Updated HydrationManager with included relation
|
|
53
|
-
*/
|
|
54
|
-
onRelationIncluded(
|
|
55
|
-
state: SelectQueryState,
|
|
56
|
-
relation: RelationDef,
|
|
57
|
-
relationName: string,
|
|
58
|
-
aliasPrefix: string,
|
|
59
|
-
targetColumns: string[],
|
|
60
|
-
pivot?: { aliasPrefix: string; columns: string[] }
|
|
61
|
-
): HydrationManager {
|
|
62
|
-
const withRoots = this.planner.captureRootColumns(state.ast.columns);
|
|
63
|
-
const next = withRoots.includeRelation(relation, relationName, aliasPrefix, targetColumns, pivot);
|
|
64
|
-
return this.clone(next);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Applies hydration plan to the AST
|
|
69
|
-
* @param ast - Query AST to modify
|
|
70
|
-
* @returns AST with hydration metadata
|
|
71
|
-
*/
|
|
72
|
-
applyToAst(ast: SelectQueryNode): SelectQueryNode {
|
|
73
|
-
// Hydration is not applied to compound set queries since row identity is ambiguous.
|
|
74
|
-
if (ast.setOps && ast.setOps.length > 0) {
|
|
75
|
-
return ast;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const plan = this.planner.getPlan();
|
|
79
|
-
if (!plan) return ast;
|
|
80
|
-
|
|
81
|
-
const needsPaginationGuard = this.requiresParentPagination(ast, plan);
|
|
82
|
-
const rewritten = needsPaginationGuard ? this.wrapForParentPagination(ast, plan) : ast;
|
|
83
|
-
return this.attachHydrationMeta(rewritten, plan);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Gets the current hydration plan
|
|
88
|
-
* @returns Hydration plan or undefined if none exists
|
|
89
|
-
*/
|
|
90
|
-
getPlan(): HydrationPlan | undefined {
|
|
91
|
-
return this.planner.getPlan();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Attaches hydration metadata to a query AST node.
|
|
96
|
-
*/
|
|
97
|
-
private attachHydrationMeta(ast: SelectQueryNode, plan: HydrationPlan): SelectQueryNode {
|
|
98
|
-
return {
|
|
99
|
-
...ast,
|
|
100
|
-
meta: {
|
|
101
|
-
...(ast.meta || {}),
|
|
102
|
-
hydration: plan
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Determines whether the query needs pagination rewriting to keep LIMIT/OFFSET
|
|
109
|
-
* applied to parent rows when eager-loading multiplicative relations.
|
|
110
|
-
*/
|
|
111
|
-
private requiresParentPagination(ast: SelectQueryNode, plan: HydrationPlan): boolean {
|
|
112
|
-
const hasPagination = ast.limit !== undefined || ast.offset !== undefined;
|
|
113
|
-
return hasPagination && this.hasMultiplyingRelations(plan);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Checks if the hydration plan contains relations that multiply rows
|
|
118
|
-
* @param plan - Hydration plan to check
|
|
119
|
-
* @returns True if plan has HasMany or BelongsToMany relations
|
|
120
|
-
*/
|
|
121
|
-
private hasMultiplyingRelations(plan: HydrationPlan): boolean {
|
|
122
|
-
return plan.relations.some(
|
|
123
|
-
rel => rel.type === RelationKinds.HasMany || rel.type === RelationKinds.BelongsToMany
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Rewrites the query using CTEs so LIMIT/OFFSET target distinct parent rows
|
|
129
|
-
* instead of the joined result set.
|
|
130
|
-
*
|
|
131
|
-
* The strategy:
|
|
132
|
-
* - Hoist the original query (minus limit/offset) into a base CTE.
|
|
133
|
-
* - Select distinct parent ids from that base CTE with the original ordering and pagination.
|
|
134
|
-
* - Join the base CTE against the paged ids to retrieve the joined rows for just that page.
|
|
135
|
-
*/
|
|
136
|
-
private wrapForParentPagination(ast: SelectQueryNode, plan: HydrationPlan): SelectQueryNode {
|
|
137
|
-
const projectionNames = this.getProjectionNames(ast.columns);
|
|
138
|
-
if (!projectionNames) {
|
|
139
|
-
return ast;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const projectionAliases = this.buildProjectionAliasMap(ast.columns);
|
|
143
|
-
const projectionSet = new Set(projectionNames);
|
|
144
|
-
const rootPkAlias = projectionAliases.get(`${plan.rootTable}.${plan.rootPrimaryKey}`) ?? plan.rootPrimaryKey;
|
|
145
|
-
|
|
146
|
-
const baseCteName = this.nextCteName(ast.ctes, '__metal_pagination_base');
|
|
147
|
-
const baseQuery: SelectQueryNode = {
|
|
148
|
-
...ast,
|
|
149
|
-
ctes: undefined,
|
|
150
|
-
limit: undefined,
|
|
151
|
-
offset: undefined,
|
|
152
|
-
orderBy: undefined,
|
|
153
|
-
meta: undefined
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const baseCte: CommonTableExpressionNode = {
|
|
157
|
-
type: 'CommonTableExpression',
|
|
158
|
-
name: baseCteName,
|
|
159
|
-
query: baseQuery,
|
|
160
|
-
recursive: false
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const orderBy = this.mapOrderBy(ast.orderBy, plan, projectionAliases, baseCteName, projectionSet);
|
|
164
|
-
// When an order-by uses child-table columns we cannot safely rewrite pagination,
|
|
165
|
-
// so preserve the original query to avoid changing semantics.
|
|
166
|
-
if (orderBy === null) {
|
|
167
|
-
return ast;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const pageCteName = this.nextCteName([...(ast.ctes ?? []), baseCte], '__metal_pagination_page');
|
|
171
|
-
const pagingColumns = this.buildPagingColumns(rootPkAlias, orderBy, baseCteName);
|
|
172
|
-
|
|
173
|
-
const pageCte: CommonTableExpressionNode = {
|
|
174
|
-
type: 'CommonTableExpression',
|
|
175
|
-
name: pageCteName,
|
|
176
|
-
query: {
|
|
177
|
-
type: 'SelectQuery',
|
|
178
|
-
from: { type: 'Table', name: baseCteName },
|
|
179
|
-
columns: pagingColumns,
|
|
180
|
-
joins: [],
|
|
181
|
-
distinct: [{ type: 'Column', table: baseCteName, name: rootPkAlias }],
|
|
182
|
-
orderBy,
|
|
183
|
-
limit: ast.limit,
|
|
184
|
-
offset: ast.offset
|
|
185
|
-
},
|
|
186
|
-
recursive: false
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const joinCondition = eq(
|
|
190
|
-
{ type: 'Column', table: baseCteName, name: rootPkAlias },
|
|
191
|
-
{ type: 'Column', table: pageCteName, name: rootPkAlias }
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
const outerColumns: ColumnNode[] = projectionNames.map(name => ({
|
|
195
|
-
type: 'Column',
|
|
196
|
-
table: baseCteName,
|
|
197
|
-
name,
|
|
198
|
-
alias: name
|
|
199
|
-
}));
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
type: 'SelectQuery',
|
|
203
|
-
from: { type: 'Table', name: baseCteName },
|
|
204
|
-
columns: outerColumns,
|
|
205
|
-
joins: [createJoinNode(JOIN_KINDS.INNER, pageCteName, joinCondition)],
|
|
206
|
-
orderBy,
|
|
207
|
-
ctes: [...(ast.ctes ?? []), baseCte, pageCte]
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Generates a unique CTE name by appending a suffix if needed
|
|
213
|
-
* @param existing - Existing CTE nodes
|
|
214
|
-
* @param baseName - Base name for the CTE
|
|
215
|
-
* @returns Unique CTE name
|
|
216
|
-
*/
|
|
217
|
-
private nextCteName(existing: CommonTableExpressionNode[] | undefined, baseName: string): string {
|
|
218
|
-
const names = new Set((existing ?? []).map(cte => cte.name));
|
|
219
|
-
let candidate = baseName;
|
|
220
|
-
let suffix = 1;
|
|
221
|
-
|
|
222
|
-
while (names.has(candidate)) {
|
|
223
|
-
suffix += 1;
|
|
224
|
-
candidate = `${baseName}_${suffix}`;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return candidate;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Extracts projection names from column nodes
|
|
232
|
-
* @param columns - Projection nodes
|
|
233
|
-
* @returns Array of names or undefined if any column lacks name/alias
|
|
234
|
-
*/
|
|
235
|
-
private getProjectionNames(columns: ProjectionNode[]): string[] | undefined {
|
|
236
|
-
const names: string[] = [];
|
|
237
|
-
for (const col of columns) {
|
|
238
|
-
const node = col as { alias?: string; name?: string };
|
|
239
|
-
const alias = node.alias ?? node.name;
|
|
240
|
-
if (!alias) return undefined;
|
|
241
|
-
names.push(alias);
|
|
242
|
-
}
|
|
243
|
-
return names;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Builds a map of column keys to their aliases from projection nodes
|
|
248
|
-
* @param columns - Projection nodes
|
|
249
|
-
* @returns Map of 'table.name' to alias
|
|
250
|
-
*/
|
|
251
|
-
private buildProjectionAliasMap(columns: ProjectionNode[]): Map<string, string> {
|
|
252
|
-
const map = new Map<string, string>();
|
|
253
|
-
for (const col of columns) {
|
|
254
|
-
if ((col as ColumnNode).type !== 'Column') continue;
|
|
255
|
-
const node = col as ColumnNode;
|
|
256
|
-
const key = `${node.table}.${node.name}`;
|
|
257
|
-
map.set(key, node.alias ?? node.name);
|
|
258
|
-
}
|
|
259
|
-
return map;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Maps order by nodes to use base CTE alias
|
|
264
|
-
* @param orderBy - Original order by nodes
|
|
265
|
-
* @param plan - Hydration plan
|
|
266
|
-
* @param projectionAliases - Map of column aliases
|
|
267
|
-
* @param baseAlias - Base CTE alias
|
|
268
|
-
* @param availableColumns - Set of available column names
|
|
269
|
-
* @returns Mapped order by nodes, null if cannot map
|
|
270
|
-
*/
|
|
271
|
-
private mapOrderBy(
|
|
272
|
-
orderBy: OrderByNode[] | undefined,
|
|
273
|
-
plan: HydrationPlan,
|
|
274
|
-
projectionAliases: Map<string, string>,
|
|
275
|
-
baseAlias: string,
|
|
276
|
-
availableColumns: Set<string>
|
|
277
|
-
): OrderByNode[] | undefined | null {
|
|
278
|
-
if (!orderBy || orderBy.length === 0) {
|
|
279
|
-
return undefined;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const mapped: OrderByNode[] = [];
|
|
283
|
-
|
|
284
|
-
for (const ob of orderBy) {
|
|
285
|
-
const mappedTerm = this.mapOrderingTerm(ob.term, plan, projectionAliases, baseAlias, availableColumns);
|
|
286
|
-
if (!mappedTerm) return null;
|
|
287
|
-
|
|
288
|
-
mapped.push({ ...ob, term: mappedTerm });
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return mapped;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Maps a single ordering term to use base CTE alias
|
|
296
|
-
* @param term - Ordering term to map
|
|
297
|
-
* @param plan - Hydration plan
|
|
298
|
-
* @param projectionAliases - Map of column aliases
|
|
299
|
-
* @param baseAlias - Base CTE alias
|
|
300
|
-
* @param availableColumns - Set of available column names
|
|
301
|
-
* @returns Mapped term or null if cannot map
|
|
302
|
-
*/
|
|
303
|
-
private mapOrderingTerm(
|
|
304
|
-
term: OrderByNode['term'],
|
|
305
|
-
plan: HydrationPlan,
|
|
306
|
-
projectionAliases: Map<string, string>,
|
|
307
|
-
baseAlias: string,
|
|
308
|
-
availableColumns: Set<string>
|
|
309
|
-
): OrderByNode['term'] | null {
|
|
310
|
-
if (term.type === 'Column') {
|
|
311
|
-
const col = term as ColumnNode;
|
|
312
|
-
if (col.table !== plan.rootTable) return null;
|
|
313
|
-
const alias = projectionAliases.get(`${col.table}.${col.name}`) ?? col.name;
|
|
314
|
-
if (!availableColumns.has(alias)) return null;
|
|
315
|
-
return { type: 'Column', table: baseAlias, name: alias };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (term.type === 'AliasRef') {
|
|
319
|
-
const aliasName = term.name;
|
|
320
|
-
if (!availableColumns.has(aliasName)) return null;
|
|
321
|
-
return { type: 'Column', table: baseAlias, name: aliasName };
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Builds column nodes for paging CTE
|
|
329
|
-
* @param primaryKey - Primary key name
|
|
330
|
-
* @param orderBy - Order by nodes
|
|
331
|
-
* @param tableAlias - Table alias for columns
|
|
332
|
-
* @returns Array of column nodes for paging
|
|
333
|
-
*/
|
|
334
|
-
private buildPagingColumns(primaryKey: string, orderBy: OrderByNode[] | undefined, tableAlias: string): ColumnNode[] {
|
|
335
|
-
const columns: ColumnNode[] = [{ type: 'Column', table: tableAlias, name: primaryKey, alias: primaryKey }];
|
|
336
|
-
|
|
337
|
-
if (!orderBy) return columns;
|
|
338
|
-
|
|
339
|
-
for (const ob of orderBy) {
|
|
340
|
-
const term = ob.term as ColumnNode;
|
|
341
|
-
if (!columns.some(col => col.name === term.name)) {
|
|
342
|
-
columns.push({
|
|
343
|
-
type: 'Column',
|
|
344
|
-
table: tableAlias,
|
|
345
|
-
name: term.name,
|
|
346
|
-
alias: term.name
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return columns;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
1
|
+
import { TableDef } from '../schema/table.js';
|
|
2
|
+
import { RelationDef, RelationKinds } from '../schema/relation.js';
|
|
3
|
+
import { CommonTableExpressionNode, OrderByNode, SelectQueryNode } from '../core/ast/query.js';
|
|
4
|
+
import { HydrationPlan } from '../core/hydration/types.js';
|
|
5
|
+
import { HydrationPlanner } from './hydration-planner.js';
|
|
6
|
+
import { ProjectionNode, SelectQueryState } from './select-query-state.js';
|
|
7
|
+
import { ColumnNode, eq } from '../core/ast/expression.js';
|
|
8
|
+
import { createJoinNode } from '../core/ast/join-node.js';
|
|
9
|
+
import { JOIN_KINDS } from '../core/sql/sql.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Manages hydration planning for query results
|
|
13
|
+
*/
|
|
14
|
+
export class HydrationManager {
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new HydrationManager instance
|
|
17
|
+
* @param table - Table definition
|
|
18
|
+
* @param planner - Hydration planner
|
|
19
|
+
*/
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly table: TableDef,
|
|
22
|
+
private readonly planner: HydrationPlanner
|
|
23
|
+
) { }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a new HydrationManager with updated planner
|
|
27
|
+
* @param nextPlanner - Updated hydration planner
|
|
28
|
+
* @returns New HydrationManager instance
|
|
29
|
+
*/
|
|
30
|
+
private clone(nextPlanner: HydrationPlanner): HydrationManager {
|
|
31
|
+
return new HydrationManager(this.table, nextPlanner);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handles column selection for hydration planning
|
|
36
|
+
* @param state - Current query state
|
|
37
|
+
* @param newColumns - Newly selected columns
|
|
38
|
+
* @returns Updated HydrationManager with captured columns
|
|
39
|
+
*/
|
|
40
|
+
onColumnsSelected(state: SelectQueryState, newColumns: ProjectionNode[]): HydrationManager {
|
|
41
|
+
const updated = this.planner.captureRootColumns(newColumns);
|
|
42
|
+
return this.clone(updated);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Handles relation inclusion for hydration planning
|
|
47
|
+
* @param state - Current query state
|
|
48
|
+
* @param relation - Relation definition
|
|
49
|
+
* @param relationName - Name of the relation
|
|
50
|
+
* @param aliasPrefix - Alias prefix for the relation
|
|
51
|
+
* @param targetColumns - Target columns to include
|
|
52
|
+
* @returns Updated HydrationManager with included relation
|
|
53
|
+
*/
|
|
54
|
+
onRelationIncluded(
|
|
55
|
+
state: SelectQueryState,
|
|
56
|
+
relation: RelationDef,
|
|
57
|
+
relationName: string,
|
|
58
|
+
aliasPrefix: string,
|
|
59
|
+
targetColumns: string[],
|
|
60
|
+
pivot?: { aliasPrefix: string; columns: string[] }
|
|
61
|
+
): HydrationManager {
|
|
62
|
+
const withRoots = this.planner.captureRootColumns(state.ast.columns);
|
|
63
|
+
const next = withRoots.includeRelation(relation, relationName, aliasPrefix, targetColumns, pivot);
|
|
64
|
+
return this.clone(next);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Applies hydration plan to the AST
|
|
69
|
+
* @param ast - Query AST to modify
|
|
70
|
+
* @returns AST with hydration metadata
|
|
71
|
+
*/
|
|
72
|
+
applyToAst(ast: SelectQueryNode): SelectQueryNode {
|
|
73
|
+
// Hydration is not applied to compound set queries since row identity is ambiguous.
|
|
74
|
+
if (ast.setOps && ast.setOps.length > 0) {
|
|
75
|
+
return ast;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const plan = this.planner.getPlan();
|
|
79
|
+
if (!plan) return ast;
|
|
80
|
+
|
|
81
|
+
const needsPaginationGuard = this.requiresParentPagination(ast, plan);
|
|
82
|
+
const rewritten = needsPaginationGuard ? this.wrapForParentPagination(ast, plan) : ast;
|
|
83
|
+
return this.attachHydrationMeta(rewritten, plan);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Gets the current hydration plan
|
|
88
|
+
* @returns Hydration plan or undefined if none exists
|
|
89
|
+
*/
|
|
90
|
+
getPlan(): HydrationPlan | undefined {
|
|
91
|
+
return this.planner.getPlan();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Attaches hydration metadata to a query AST node.
|
|
96
|
+
*/
|
|
97
|
+
private attachHydrationMeta(ast: SelectQueryNode, plan: HydrationPlan): SelectQueryNode {
|
|
98
|
+
return {
|
|
99
|
+
...ast,
|
|
100
|
+
meta: {
|
|
101
|
+
...(ast.meta || {}),
|
|
102
|
+
hydration: plan
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Determines whether the query needs pagination rewriting to keep LIMIT/OFFSET
|
|
109
|
+
* applied to parent rows when eager-loading multiplicative relations.
|
|
110
|
+
*/
|
|
111
|
+
private requiresParentPagination(ast: SelectQueryNode, plan: HydrationPlan): boolean {
|
|
112
|
+
const hasPagination = ast.limit !== undefined || ast.offset !== undefined;
|
|
113
|
+
return hasPagination && this.hasMultiplyingRelations(plan);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checks if the hydration plan contains relations that multiply rows
|
|
118
|
+
* @param plan - Hydration plan to check
|
|
119
|
+
* @returns True if plan has HasMany or BelongsToMany relations
|
|
120
|
+
*/
|
|
121
|
+
private hasMultiplyingRelations(plan: HydrationPlan): boolean {
|
|
122
|
+
return plan.relations.some(
|
|
123
|
+
rel => rel.type === RelationKinds.HasMany || rel.type === RelationKinds.BelongsToMany
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Rewrites the query using CTEs so LIMIT/OFFSET target distinct parent rows
|
|
129
|
+
* instead of the joined result set.
|
|
130
|
+
*
|
|
131
|
+
* The strategy:
|
|
132
|
+
* - Hoist the original query (minus limit/offset) into a base CTE.
|
|
133
|
+
* - Select distinct parent ids from that base CTE with the original ordering and pagination.
|
|
134
|
+
* - Join the base CTE against the paged ids to retrieve the joined rows for just that page.
|
|
135
|
+
*/
|
|
136
|
+
private wrapForParentPagination(ast: SelectQueryNode, plan: HydrationPlan): SelectQueryNode {
|
|
137
|
+
const projectionNames = this.getProjectionNames(ast.columns);
|
|
138
|
+
if (!projectionNames) {
|
|
139
|
+
return ast;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const projectionAliases = this.buildProjectionAliasMap(ast.columns);
|
|
143
|
+
const projectionSet = new Set(projectionNames);
|
|
144
|
+
const rootPkAlias = projectionAliases.get(`${plan.rootTable}.${plan.rootPrimaryKey}`) ?? plan.rootPrimaryKey;
|
|
145
|
+
|
|
146
|
+
const baseCteName = this.nextCteName(ast.ctes, '__metal_pagination_base');
|
|
147
|
+
const baseQuery: SelectQueryNode = {
|
|
148
|
+
...ast,
|
|
149
|
+
ctes: undefined,
|
|
150
|
+
limit: undefined,
|
|
151
|
+
offset: undefined,
|
|
152
|
+
orderBy: undefined,
|
|
153
|
+
meta: undefined
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const baseCte: CommonTableExpressionNode = {
|
|
157
|
+
type: 'CommonTableExpression',
|
|
158
|
+
name: baseCteName,
|
|
159
|
+
query: baseQuery,
|
|
160
|
+
recursive: false
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const orderBy = this.mapOrderBy(ast.orderBy, plan, projectionAliases, baseCteName, projectionSet);
|
|
164
|
+
// When an order-by uses child-table columns we cannot safely rewrite pagination,
|
|
165
|
+
// so preserve the original query to avoid changing semantics.
|
|
166
|
+
if (orderBy === null) {
|
|
167
|
+
return ast;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const pageCteName = this.nextCteName([...(ast.ctes ?? []), baseCte], '__metal_pagination_page');
|
|
171
|
+
const pagingColumns = this.buildPagingColumns(rootPkAlias, orderBy, baseCteName);
|
|
172
|
+
|
|
173
|
+
const pageCte: CommonTableExpressionNode = {
|
|
174
|
+
type: 'CommonTableExpression',
|
|
175
|
+
name: pageCteName,
|
|
176
|
+
query: {
|
|
177
|
+
type: 'SelectQuery',
|
|
178
|
+
from: { type: 'Table', name: baseCteName },
|
|
179
|
+
columns: pagingColumns,
|
|
180
|
+
joins: [],
|
|
181
|
+
distinct: [{ type: 'Column', table: baseCteName, name: rootPkAlias }],
|
|
182
|
+
orderBy,
|
|
183
|
+
limit: ast.limit,
|
|
184
|
+
offset: ast.offset
|
|
185
|
+
},
|
|
186
|
+
recursive: false
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const joinCondition = eq(
|
|
190
|
+
{ type: 'Column', table: baseCteName, name: rootPkAlias },
|
|
191
|
+
{ type: 'Column', table: pageCteName, name: rootPkAlias }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const outerColumns: ColumnNode[] = projectionNames.map(name => ({
|
|
195
|
+
type: 'Column',
|
|
196
|
+
table: baseCteName,
|
|
197
|
+
name,
|
|
198
|
+
alias: name
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
type: 'SelectQuery',
|
|
203
|
+
from: { type: 'Table', name: baseCteName },
|
|
204
|
+
columns: outerColumns,
|
|
205
|
+
joins: [createJoinNode(JOIN_KINDS.INNER, pageCteName, joinCondition)],
|
|
206
|
+
orderBy,
|
|
207
|
+
ctes: [...(ast.ctes ?? []), baseCte, pageCte]
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Generates a unique CTE name by appending a suffix if needed
|
|
213
|
+
* @param existing - Existing CTE nodes
|
|
214
|
+
* @param baseName - Base name for the CTE
|
|
215
|
+
* @returns Unique CTE name
|
|
216
|
+
*/
|
|
217
|
+
private nextCteName(existing: CommonTableExpressionNode[] | undefined, baseName: string): string {
|
|
218
|
+
const names = new Set((existing ?? []).map(cte => cte.name));
|
|
219
|
+
let candidate = baseName;
|
|
220
|
+
let suffix = 1;
|
|
221
|
+
|
|
222
|
+
while (names.has(candidate)) {
|
|
223
|
+
suffix += 1;
|
|
224
|
+
candidate = `${baseName}_${suffix}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return candidate;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extracts projection names from column nodes
|
|
232
|
+
* @param columns - Projection nodes
|
|
233
|
+
* @returns Array of names or undefined if any column lacks name/alias
|
|
234
|
+
*/
|
|
235
|
+
private getProjectionNames(columns: ProjectionNode[]): string[] | undefined {
|
|
236
|
+
const names: string[] = [];
|
|
237
|
+
for (const col of columns) {
|
|
238
|
+
const node = col as { alias?: string; name?: string };
|
|
239
|
+
const alias = node.alias ?? node.name;
|
|
240
|
+
if (!alias) return undefined;
|
|
241
|
+
names.push(alias);
|
|
242
|
+
}
|
|
243
|
+
return names;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Builds a map of column keys to their aliases from projection nodes
|
|
248
|
+
* @param columns - Projection nodes
|
|
249
|
+
* @returns Map of 'table.name' to alias
|
|
250
|
+
*/
|
|
251
|
+
private buildProjectionAliasMap(columns: ProjectionNode[]): Map<string, string> {
|
|
252
|
+
const map = new Map<string, string>();
|
|
253
|
+
for (const col of columns) {
|
|
254
|
+
if ((col as ColumnNode).type !== 'Column') continue;
|
|
255
|
+
const node = col as ColumnNode;
|
|
256
|
+
const key = `${node.table}.${node.name}`;
|
|
257
|
+
map.set(key, node.alias ?? node.name);
|
|
258
|
+
}
|
|
259
|
+
return map;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Maps order by nodes to use base CTE alias
|
|
264
|
+
* @param orderBy - Original order by nodes
|
|
265
|
+
* @param plan - Hydration plan
|
|
266
|
+
* @param projectionAliases - Map of column aliases
|
|
267
|
+
* @param baseAlias - Base CTE alias
|
|
268
|
+
* @param availableColumns - Set of available column names
|
|
269
|
+
* @returns Mapped order by nodes, null if cannot map
|
|
270
|
+
*/
|
|
271
|
+
private mapOrderBy(
|
|
272
|
+
orderBy: OrderByNode[] | undefined,
|
|
273
|
+
plan: HydrationPlan,
|
|
274
|
+
projectionAliases: Map<string, string>,
|
|
275
|
+
baseAlias: string,
|
|
276
|
+
availableColumns: Set<string>
|
|
277
|
+
): OrderByNode[] | undefined | null {
|
|
278
|
+
if (!orderBy || orderBy.length === 0) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const mapped: OrderByNode[] = [];
|
|
283
|
+
|
|
284
|
+
for (const ob of orderBy) {
|
|
285
|
+
const mappedTerm = this.mapOrderingTerm(ob.term, plan, projectionAliases, baseAlias, availableColumns);
|
|
286
|
+
if (!mappedTerm) return null;
|
|
287
|
+
|
|
288
|
+
mapped.push({ ...ob, term: mappedTerm });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return mapped;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Maps a single ordering term to use base CTE alias
|
|
296
|
+
* @param term - Ordering term to map
|
|
297
|
+
* @param plan - Hydration plan
|
|
298
|
+
* @param projectionAliases - Map of column aliases
|
|
299
|
+
* @param baseAlias - Base CTE alias
|
|
300
|
+
* @param availableColumns - Set of available column names
|
|
301
|
+
* @returns Mapped term or null if cannot map
|
|
302
|
+
*/
|
|
303
|
+
private mapOrderingTerm(
|
|
304
|
+
term: OrderByNode['term'],
|
|
305
|
+
plan: HydrationPlan,
|
|
306
|
+
projectionAliases: Map<string, string>,
|
|
307
|
+
baseAlias: string,
|
|
308
|
+
availableColumns: Set<string>
|
|
309
|
+
): OrderByNode['term'] | null {
|
|
310
|
+
if (term.type === 'Column') {
|
|
311
|
+
const col = term as ColumnNode;
|
|
312
|
+
if (col.table !== plan.rootTable) return null;
|
|
313
|
+
const alias = projectionAliases.get(`${col.table}.${col.name}`) ?? col.name;
|
|
314
|
+
if (!availableColumns.has(alias)) return null;
|
|
315
|
+
return { type: 'Column', table: baseAlias, name: alias };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (term.type === 'AliasRef') {
|
|
319
|
+
const aliasName = term.name;
|
|
320
|
+
if (!availableColumns.has(aliasName)) return null;
|
|
321
|
+
return { type: 'Column', table: baseAlias, name: aliasName };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Builds column nodes for paging CTE
|
|
329
|
+
* @param primaryKey - Primary key name
|
|
330
|
+
* @param orderBy - Order by nodes
|
|
331
|
+
* @param tableAlias - Table alias for columns
|
|
332
|
+
* @returns Array of column nodes for paging
|
|
333
|
+
*/
|
|
334
|
+
private buildPagingColumns(primaryKey: string, orderBy: OrderByNode[] | undefined, tableAlias: string): ColumnNode[] {
|
|
335
|
+
const columns: ColumnNode[] = [{ type: 'Column', table: tableAlias, name: primaryKey, alias: primaryKey }];
|
|
336
|
+
|
|
337
|
+
if (!orderBy) return columns;
|
|
338
|
+
|
|
339
|
+
for (const ob of orderBy) {
|
|
340
|
+
const term = ob.term as ColumnNode;
|
|
341
|
+
if (!columns.some(col => col.name === term.name)) {
|
|
342
|
+
columns.push({
|
|
343
|
+
type: 'Column',
|
|
344
|
+
table: tableAlias,
|
|
345
|
+
name: term.name,
|
|
346
|
+
alias: term.name
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return columns;
|
|
352
|
+
}
|
|
353
|
+
}
|