metal-orm 1.0.12 → 1.0.13

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,8 +1,11 @@
1
- import { TableDef } from '../schema/table.js';
2
- import { RelationDef } from '../schema/relation.js';
3
- import { SelectQueryNode, HydrationPlan } from '../core/ast/query.js';
4
- import { HydrationPlanner } from './hydration-planner.js';
5
- import { SelectQueryState, ProjectionNode } from './select-query-state.js';
1
+ import { TableDef } from '../schema/table.js';
2
+ import { RelationDef, RelationKinds } from '../schema/relation.js';
3
+ import { CommonTableExpressionNode, HydrationPlan, OrderByNode, SelectQueryNode } from '../core/ast/query.js';
4
+ import { HydrationPlanner } from './hydration-planner.js';
5
+ import { ProjectionNode, SelectQueryState } from './select-query-state.js';
6
+ import { ColumnNode, eq } from '../core/ast/expression.js';
7
+ import { createJoinNode } from '../core/ast/join-node.js';
8
+ import { JOIN_KINDS } from '../core/sql/sql.js';
6
9
 
7
10
  /**
8
11
  * Manages hydration planning for query results
@@ -60,28 +63,229 @@ export class HydrationManager {
60
63
  return this.clone(next);
61
64
  }
62
65
 
63
- /**
64
- * Applies hydration plan to the AST
65
- * @param ast - Query AST to modify
66
- * @returns AST with hydration metadata
67
- */
68
- applyToAst(ast: SelectQueryNode): SelectQueryNode {
69
- const plan = this.planner.getPlan();
70
- if (!plan) return ast;
71
- return {
72
- ...ast,
73
- meta: {
74
- ...(ast.meta || {}),
75
- hydration: plan
76
- }
77
- };
78
- }
66
+ /**
67
+ * Applies hydration plan to the AST
68
+ * @param ast - Query AST to modify
69
+ * @returns AST with hydration metadata
70
+ */
71
+ applyToAst(ast: SelectQueryNode): SelectQueryNode {
72
+ // Hydration is not applied to compound set queries since row identity is ambiguous.
73
+ if (ast.setOps && ast.setOps.length > 0) {
74
+ return ast;
75
+ }
76
+
77
+ const plan = this.planner.getPlan();
78
+ if (!plan) return ast;
79
+
80
+ const needsPaginationGuard = this.requiresParentPagination(ast, plan);
81
+ const rewritten = needsPaginationGuard ? this.wrapForParentPagination(ast, plan) : ast;
82
+ return this.attachHydrationMeta(rewritten, plan);
83
+ }
79
84
 
80
85
  /**
81
86
  * Gets the current hydration plan
82
87
  * @returns Hydration plan or undefined if none exists
83
88
  */
84
- getPlan(): HydrationPlan | undefined {
85
- return this.planner.getPlan();
86
- }
87
- }
89
+ getPlan(): HydrationPlan | undefined {
90
+ return this.planner.getPlan();
91
+ }
92
+
93
+ /**
94
+ * Attaches hydration metadata to a query AST node.
95
+ */
96
+ private attachHydrationMeta(ast: SelectQueryNode, plan: HydrationPlan): SelectQueryNode {
97
+ return {
98
+ ...ast,
99
+ meta: {
100
+ ...(ast.meta || {}),
101
+ hydration: plan
102
+ }
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Determines whether the query needs pagination rewriting to keep LIMIT/OFFSET
108
+ * applied to parent rows when eager-loading multiplicative relations.
109
+ */
110
+ private requiresParentPagination(ast: SelectQueryNode, plan: HydrationPlan): boolean {
111
+ const hasPagination = ast.limit !== undefined || ast.offset !== undefined;
112
+ return hasPagination && this.hasMultiplyingRelations(plan);
113
+ }
114
+
115
+ private hasMultiplyingRelations(plan: HydrationPlan): boolean {
116
+ return plan.relations.some(
117
+ rel => rel.type === RelationKinds.HasMany || rel.type === RelationKinds.BelongsToMany
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Rewrites the query using CTEs so LIMIT/OFFSET target distinct parent rows
123
+ * instead of the joined result set.
124
+ *
125
+ * The strategy:
126
+ * - Hoist the original query (minus limit/offset) into a base CTE.
127
+ * - Select distinct parent ids from that base CTE with the original ordering and pagination.
128
+ * - Join the base CTE against the paged ids to retrieve the joined rows for just that page.
129
+ */
130
+ private wrapForParentPagination(ast: SelectQueryNode, plan: HydrationPlan): SelectQueryNode {
131
+ const projectionNames = this.getProjectionNames(ast.columns);
132
+ if (!projectionNames) {
133
+ return ast;
134
+ }
135
+
136
+ const projectionAliases = this.buildProjectionAliasMap(ast.columns);
137
+ const projectionSet = new Set(projectionNames);
138
+ const rootPkAlias = projectionAliases.get(`${plan.rootTable}.${plan.rootPrimaryKey}`) ?? plan.rootPrimaryKey;
139
+
140
+ const baseCteName = this.nextCteName(ast.ctes, '__metal_pagination_base');
141
+ const baseQuery: SelectQueryNode = {
142
+ ...ast,
143
+ ctes: undefined,
144
+ limit: undefined,
145
+ offset: undefined,
146
+ orderBy: undefined,
147
+ meta: undefined
148
+ };
149
+
150
+ const baseCte: CommonTableExpressionNode = {
151
+ type: 'CommonTableExpression',
152
+ name: baseCteName,
153
+ query: baseQuery,
154
+ recursive: false
155
+ };
156
+
157
+ const orderBy = this.mapOrderBy(ast.orderBy, plan, projectionAliases, baseCteName, projectionSet);
158
+ // When an order-by uses child-table columns we cannot safely rewrite pagination,
159
+ // so preserve the original query to avoid changing semantics.
160
+ if (orderBy === null) {
161
+ return ast;
162
+ }
163
+
164
+ const pageCteName = this.nextCteName([...(ast.ctes ?? []), baseCte], '__metal_pagination_page');
165
+ const pagingColumns = this.buildPagingColumns(rootPkAlias, orderBy, baseCteName);
166
+
167
+ const pageCte: CommonTableExpressionNode = {
168
+ type: 'CommonTableExpression',
169
+ name: pageCteName,
170
+ query: {
171
+ type: 'SelectQuery',
172
+ from: { type: 'Table', name: baseCteName },
173
+ columns: pagingColumns,
174
+ joins: [],
175
+ distinct: [{ type: 'Column', table: baseCteName, name: rootPkAlias }],
176
+ orderBy,
177
+ limit: ast.limit,
178
+ offset: ast.offset
179
+ },
180
+ recursive: false
181
+ };
182
+
183
+ const joinCondition = eq(
184
+ { type: 'Column', table: baseCteName, name: rootPkAlias },
185
+ { type: 'Column', table: pageCteName, name: rootPkAlias }
186
+ );
187
+
188
+ const outerColumns: ColumnNode[] = projectionNames.map(name => ({
189
+ type: 'Column',
190
+ table: baseCteName,
191
+ name,
192
+ alias: name
193
+ }));
194
+
195
+ return {
196
+ type: 'SelectQuery',
197
+ from: { type: 'Table', name: baseCteName },
198
+ columns: outerColumns,
199
+ joins: [createJoinNode(JOIN_KINDS.INNER, pageCteName, joinCondition)],
200
+ orderBy,
201
+ ctes: [...(ast.ctes ?? []), baseCte, pageCte]
202
+ };
203
+ }
204
+
205
+ private nextCteName(existing: CommonTableExpressionNode[] | undefined, baseName: string): string {
206
+ const names = new Set((existing ?? []).map(cte => cte.name));
207
+ let candidate = baseName;
208
+ let suffix = 1;
209
+
210
+ while (names.has(candidate)) {
211
+ suffix += 1;
212
+ candidate = `${baseName}_${suffix}`;
213
+ }
214
+
215
+ return candidate;
216
+ }
217
+
218
+ private getProjectionNames(columns: ProjectionNode[]): string[] | undefined {
219
+ const names: string[] = [];
220
+ for (const col of columns) {
221
+ const alias = (col as any).alias ?? (col as any).name;
222
+ if (!alias) return undefined;
223
+ names.push(alias);
224
+ }
225
+ return names;
226
+ }
227
+
228
+ private buildProjectionAliasMap(columns: ProjectionNode[]): Map<string, string> {
229
+ const map = new Map<string, string>();
230
+ for (const col of columns) {
231
+ if ((col as ColumnNode).type !== 'Column') continue;
232
+ const node = col as ColumnNode;
233
+ const key = `${node.table}.${node.name}`;
234
+ map.set(key, node.alias ?? node.name);
235
+ }
236
+ return map;
237
+ }
238
+
239
+ private mapOrderBy(
240
+ orderBy: OrderByNode[] | undefined,
241
+ plan: HydrationPlan,
242
+ projectionAliases: Map<string, string>,
243
+ baseAlias: string,
244
+ availableColumns: Set<string>
245
+ ): OrderByNode[] | undefined | null {
246
+ if (!orderBy || orderBy.length === 0) {
247
+ return undefined;
248
+ }
249
+
250
+ const mapped: OrderByNode[] = [];
251
+
252
+ for (const ob of orderBy) {
253
+ // Only rewrite when ordering by root columns; child columns would reintroduce the pagination bug.
254
+ if (ob.column.table !== plan.rootTable) {
255
+ return null;
256
+ }
257
+
258
+ const alias = projectionAliases.get(`${ob.column.table}.${ob.column.name}`) ?? ob.column.name;
259
+ if (!availableColumns.has(alias)) {
260
+ return null;
261
+ }
262
+
263
+ mapped.push({
264
+ type: 'OrderBy',
265
+ column: { type: 'Column', table: baseAlias, name: alias },
266
+ direction: ob.direction
267
+ });
268
+ }
269
+
270
+ return mapped;
271
+ }
272
+
273
+ private buildPagingColumns(primaryKey: string, orderBy: OrderByNode[] | undefined, tableAlias: string): ColumnNode[] {
274
+ const columns: ColumnNode[] = [{ type: 'Column', table: tableAlias, name: primaryKey, alias: primaryKey }];
275
+
276
+ if (!orderBy) return columns;
277
+
278
+ for (const ob of orderBy) {
279
+ if (!columns.some(col => col.name === ob.column.name)) {
280
+ columns.push({
281
+ type: 'Column',
282
+ table: tableAlias,
283
+ name: ob.column.name,
284
+ alias: ob.column.name
285
+ });
286
+ }
287
+ }
288
+
289
+ return columns;
290
+ }
291
+ }
@@ -1,6 +1,6 @@
1
1
  import { TableDef } from '../schema/table.js';
2
2
  import { ColumnDef } from '../schema/column.js';
3
- import { SelectQueryNode, CommonTableExpressionNode } from '../core/ast/query.js';
3
+ import { SelectQueryNode, CommonTableExpressionNode, SetOperationKind, SetOperationNode } from '../core/ast/query.js';
4
4
  import { buildColumnNode } from '../core/ast/builders.js';
5
5
  import {
6
6
  ColumnNode,
@@ -95,17 +95,32 @@ export class QueryAstService {
95
95
  * @param recursive - Whether the CTE is recursive
96
96
  * @returns Updated query state with CTE
97
97
  */
98
- withCte(name: string, query: SelectQueryNode, columns?: string[], recursive = false): SelectQueryState {
99
- const cte: CommonTableExpressionNode = {
100
- type: 'CommonTableExpression',
101
- name,
102
- query,
103
- columns,
104
- recursive
105
- };
106
-
107
- return this.state.withCte(cte);
108
- }
98
+ withCte(name: string, query: SelectQueryNode, columns?: string[], recursive = false): SelectQueryState {
99
+ const cte: CommonTableExpressionNode = {
100
+ type: 'CommonTableExpression',
101
+ name,
102
+ query,
103
+ columns,
104
+ recursive
105
+ };
106
+
107
+ return this.state.withCte(cte);
108
+ }
109
+
110
+ /**
111
+ * Adds a set operation (UNION/UNION ALL/INTERSECT/EXCEPT) to the query
112
+ * @param operator - Set operator
113
+ * @param query - Right-hand side query
114
+ * @returns Updated query state with set operation
115
+ */
116
+ withSetOperation(operator: SetOperationKind, query: SelectQueryNode): SelectQueryState {
117
+ const op: SetOperationNode = {
118
+ type: 'SetOperation',
119
+ operator,
120
+ query
121
+ };
122
+ return this.state.withSetOperation(op);
123
+ }
109
124
 
110
125
  /**
111
126
  * Selects a subquery as a column
@@ -1,5 +1,5 @@
1
1
  import { TableDef } from '../schema/table.js';
2
- import { SelectQueryNode, CommonTableExpressionNode, OrderByNode } from '../core/ast/query.js';
2
+ import { SelectQueryNode, CommonTableExpressionNode, OrderByNode, SetOperationNode } from '../core/ast/query.js';
3
3
  import {
4
4
  ColumnNode,
5
5
  ExpressionNode,
@@ -166,14 +166,26 @@ export class SelectQueryState {
166
166
  }
167
167
 
168
168
  /**
169
- * Adds a Common Table Expression (CTE) to the query
170
- * @param cte - CTE node to add
171
- * @returns New SelectQueryState with CTE
172
- */
173
- withCte(cte: CommonTableExpressionNode): SelectQueryState {
174
- return this.clone({
175
- ...this.ast,
176
- ctes: [...(this.ast.ctes ?? []), cte]
177
- });
178
- }
179
- }
169
+ * Adds a Common Table Expression (CTE) to the query
170
+ * @param cte - CTE node to add
171
+ * @returns New SelectQueryState with CTE
172
+ */
173
+ withCte(cte: CommonTableExpressionNode): SelectQueryState {
174
+ return this.clone({
175
+ ...this.ast,
176
+ ctes: [...(this.ast.ctes ?? []), cte]
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Adds a set operation (UNION/INTERSECT/EXCEPT) to the query
182
+ * @param op - Set operation node to add
183
+ * @returns New SelectQueryState with set operation
184
+ */
185
+ withSetOperation(op: SetOperationNode): SelectQueryState {
186
+ return this.clone({
187
+ ...this.ast,
188
+ setOps: [...(this.ast.setOps ?? []), op]
189
+ });
190
+ }
191
+ }
@@ -1,6 +1,6 @@
1
1
  import { TableDef } from '../schema/table.js';
2
2
  import { ColumnDef } from '../schema/column.js';
3
- import { SelectQueryNode, HydrationPlan } from '../core/ast/query.js';
3
+ import { SelectQueryNode, HydrationPlan, SetOperationKind } from '../core/ast/query.js';
4
4
  import {
5
5
  ColumnNode,
6
6
  ExpressionNode,
@@ -96,15 +96,23 @@ export class SelectQueryBuilder<T = any, TTable extends TableDef = TableDef> {
96
96
  return { state: nextState, hydration: context.hydration };
97
97
  }
98
98
 
99
- private applyJoin(
100
- context: SelectQueryBuilderContext,
101
- table: TableDef,
102
- condition: BinaryExpressionNode,
103
- kind: JoinKind
104
- ): SelectQueryBuilderContext {
105
- const joinNode = createJoinNode(kind, table.name, condition);
106
- return this.applyAst(context, service => service.withJoin(joinNode));
107
- }
99
+ private applyJoin(
100
+ context: SelectQueryBuilderContext,
101
+ table: TableDef,
102
+ condition: BinaryExpressionNode,
103
+ kind: JoinKind
104
+ ): SelectQueryBuilderContext {
105
+ const joinNode = createJoinNode(kind, table.name, condition);
106
+ return this.applyAst(context, service => service.withJoin(joinNode));
107
+ }
108
+
109
+ private applySetOperation(
110
+ operator: SetOperationKind,
111
+ query: SelectQueryBuilder<any, TableDef<any>> | SelectQueryNode
112
+ ): SelectQueryBuilderContext {
113
+ const subAst = this.resolveQueryNode(query);
114
+ return this.applyAst(this.context, service => service.withSetOperation(operator, subAst));
115
+ }
108
116
 
109
117
  /**
110
118
  * Selects specific columns for the query
@@ -315,10 +323,46 @@ export class SelectQueryBuilder<T = any, TTable extends TableDef = TableDef> {
315
323
  * @param n - Number of rows to skip
316
324
  * @returns New query builder instance with the OFFSET clause
317
325
  */
318
- offset(n: number): SelectQueryBuilder<T, TTable> {
319
- const nextContext = this.applyAst(this.context, service => service.withOffset(n));
320
- return this.clone(nextContext);
321
- }
326
+ offset(n: number): SelectQueryBuilder<T, TTable> {
327
+ const nextContext = this.applyAst(this.context, service => service.withOffset(n));
328
+ return this.clone(nextContext);
329
+ }
330
+
331
+ /**
332
+ * Combines this query with another using UNION
333
+ * @param query - Query to union with
334
+ * @returns New query builder instance with the set operation
335
+ */
336
+ union(query: SelectQueryBuilder<any, TableDef<any>> | SelectQueryNode): SelectQueryBuilder<T, TTable> {
337
+ return this.clone(this.applySetOperation('UNION', query));
338
+ }
339
+
340
+ /**
341
+ * Combines this query with another using UNION ALL
342
+ * @param query - Query to union with
343
+ * @returns New query builder instance with the set operation
344
+ */
345
+ unionAll(query: SelectQueryBuilder<any, TableDef<any>> | SelectQueryNode): SelectQueryBuilder<T, TTable> {
346
+ return this.clone(this.applySetOperation('UNION ALL', query));
347
+ }
348
+
349
+ /**
350
+ * Combines this query with another using INTERSECT
351
+ * @param query - Query to intersect with
352
+ * @returns New query builder instance with the set operation
353
+ */
354
+ intersect(query: SelectQueryBuilder<any, TableDef<any>> | SelectQueryNode): SelectQueryBuilder<T, TTable> {
355
+ return this.clone(this.applySetOperation('INTERSECT', query));
356
+ }
357
+
358
+ /**
359
+ * Combines this query with another using EXCEPT
360
+ * @param query - Query to subtract
361
+ * @returns New query builder instance with the set operation
362
+ */
363
+ except(query: SelectQueryBuilder<any, TableDef<any>> | SelectQueryNode): SelectQueryBuilder<T, TTable> {
364
+ return this.clone(this.applySetOperation('EXCEPT', query));
365
+ }
322
366
 
323
367
  /**
324
368
  * Adds a WHERE EXISTS condition to the query