metal-orm 1.0.97 → 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.
@@ -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
+ }