Skip to content

Commit 96d298f

Browse files
rootclaude
authored andcommitted
feat: move ORDER BY from BinaryQuery right side to SimpleQuery level
Clarifies UNION ORDER BY semantics by extracting ORDER BY clause from the rightmost query in binary operations and moving it to the outer SimpleSelectQuery level. This transforms ambiguous ORDER BY positioning into explicit subquery structure. Changes: - Add ORDER BY extraction logic to QueryBuilder.buildSimpleBinaryQuery() - Implement recursive ORDER BY detection in binary query trees - Move ORDER BY from individual subqueries to wrapper SimpleQuery - Add comprehensive tests for ORDER BY movement scenarios - Document SQL standard behavior for UNION ORDER BY clauses 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent a1cc479 commit 96d298f

File tree

2 files changed

+167
-7
lines changed

2 files changed

+167
-7
lines changed

packages/core/src/transformers/QueryBuilder.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SetClause, SetClauseItem, FromClause, WhereClause, SelectClause, SelectItem, SourceAliasExpression, SourceExpression, SubQuerySource, WithClause, TableSource, UpdateClause, InsertClause } from '../models/Clause';
1+
import { SetClause, SetClauseItem, FromClause, WhereClause, SelectClause, SelectItem, SourceAliasExpression, SourceExpression, SubQuerySource, WithClause, TableSource, UpdateClause, InsertClause, OrderByClause } from '../models/Clause';
22
import { UpdateQuery } from '../models/UpdateQuery';
33
import { BinaryExpression, ColumnReference } from '../models/ValueComponent';
44
import { SelectValueCollector } from './SelectValueCollector';
@@ -66,7 +66,10 @@ export class QueryBuilder {
6666
}
6767

6868
private static buildSimpleBinaryQuery(query: BinarySelectQuery): SimpleSelectQuery {
69-
// Create a subquery source from the binary query
69+
// Extract ORDER BY from the rightmost query in the binary tree and remove it
70+
const extractedOrderBy = QueryBuilder.extractAndRemoveOrderByFromBinaryQuery(query);
71+
72+
// Create a subquery source from the binary query (now without ORDER BY)
7073
const subQuerySource = new SubQuerySource(query);
7174

7275
// Create a source expression with alias
@@ -81,17 +84,65 @@ export class QueryBuilder {
8184
// Create SELECT clause with * (all columns)
8285
const selectClause = QueryBuilder.createSelectAllClause();
8386

84-
// Create the final simple select query
87+
// Create the final simple select query with extracted ORDER BY
8588
const q = new SimpleSelectQuery(
8689
{
8790
selectClause,
88-
fromClause
91+
fromClause,
92+
orderByClause: extractedOrderBy
8993
}
9094
);
9195

9296
return CTENormalizer.normalize(q) as SimpleSelectQuery;
9397
}
9498

99+
/**
100+
* Extracts ORDER BY clause from the rightmost query in a binary query tree and removes it.
101+
* This clarifies the semantics by moving the ORDER BY from the ambiguous position
102+
* in the UNION to the explicit outer SimpleSelectQuery level.
103+
*
104+
* NOTE: ORDER BY in UNION context applies to the entire result set, not individual subqueries.
105+
* Therefore, table prefixes (e.g., "a.column") in ORDER BY are invalid SQL and would cause
106+
* syntax errors. Valid ORDER BY clauses should only reference column names without prefixes
107+
* or use positional notation (ORDER BY 1, 2). Since we only process valid SQL, the current
108+
* implementation correctly handles legitimate cases without additional prefix processing.
109+
*
110+
* @param query BinarySelectQuery to process
111+
* @returns Extracted OrderByClause or null if none found
112+
*/
113+
private static extractAndRemoveOrderByFromBinaryQuery(query: BinarySelectQuery): OrderByClause | null {
114+
return QueryBuilder.findAndRemoveRightmostOrderBy(query);
115+
}
116+
117+
/**
118+
* Recursively finds and removes ORDER BY from the rightmost query in a binary tree.
119+
*
120+
* @param query Current query being processed
121+
* @returns Extracted OrderByClause or null
122+
*/
123+
private static findAndRemoveRightmostOrderBy(query: SelectQuery): OrderByClause | null {
124+
if (query instanceof BinarySelectQuery) {
125+
// For binary queries, check right side first (rightmost takes precedence)
126+
const rightOrderBy = QueryBuilder.findAndRemoveRightmostOrderBy(query.right);
127+
if (rightOrderBy) {
128+
return rightOrderBy;
129+
}
130+
131+
// If no ORDER BY on right side, check left side
132+
return QueryBuilder.findAndRemoveRightmostOrderBy(query.left);
133+
}
134+
else if (query instanceof SimpleSelectQuery) {
135+
// Extract ORDER BY from SimpleSelectQuery and remove it
136+
const orderBy = query.orderByClause;
137+
if (orderBy) {
138+
query.orderByClause = null;
139+
return orderBy;
140+
}
141+
}
142+
143+
return null;
144+
}
145+
95146
/**
96147
* Converts a ValuesQuery to a SimpleSelectQuery with sequentially numbered columns or user-specified columns
97148
*

packages/core/tests/models/SelectQuery.toSimpleQuery.test.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('SelectQuery toSimpleQuery() conversion', () => {
4949

5050
test('should enable CTE management on converted BinarySelectQuery', () => {
5151
// Test that converted binary query supports CTE operations
52-
const query1 = SelectQueryParser.parse('SELECT id FROM users');
52+
const query1 = SelectQueryParser.parse('SELECT id FROM users').toSimpleQuery();
5353
const query2 = SelectQueryParser.parse('SELECT id FROM customers');
5454
const binaryQuery = query1.toUnion(query2);
5555
const cteQuery = SelectQueryParser.parse('SELECT id FROM accounts WHERE active = true');
@@ -65,7 +65,7 @@ describe('SelectQuery toSimpleQuery() conversion', () => {
6565

6666
test('should produce valid SQL with complex UNION and CTE', () => {
6767
// Test full workflow: UNION -> toSimpleQuery -> addCTE -> format
68-
const query1 = SelectQueryParser.parse('SELECT id, name FROM employees');
68+
const query1 = SelectQueryParser.parse('SELECT id, name FROM employees').toSimpleQuery();
6969
const query2 = SelectQueryParser.parse('SELECT id, name FROM contractors');
7070
const binaryQuery = query1.toUnionAll(query2);
7171
const cteQuery = SelectQueryParser.parse('SELECT id FROM departments WHERE active = true');
@@ -84,7 +84,7 @@ describe('SelectQuery toSimpleQuery() conversion', () => {
8484
describe('Method chaining patterns', () => {
8585
test('should support fluent API: binary -> toSimpleQuery -> CTE operations', () => {
8686
// Test the recommended usage pattern
87-
const query1 = SelectQueryParser.parse('SELECT id FROM table1');
87+
const query1 = SelectQueryParser.parse('SELECT id FROM table1').toSimpleQuery();
8888
const query2 = SelectQueryParser.parse('SELECT id FROM table2');
8989
const cte1 = SelectQueryParser.parse('SELECT id FROM temp1');
9090
const cte2 = SelectQueryParser.parse('SELECT id FROM temp2');
@@ -100,6 +100,115 @@ describe('SelectQuery toSimpleQuery() conversion', () => {
100100
});
101101
});
102102

103+
describe('BinarySelectQuery ORDER BY handling', () => {
104+
// NOTE: In SQL standard, ORDER BY in UNION context applies to the entire result set.
105+
// Table prefixes (e.g., "a.column") would be invalid SQL syntax in ORDER BY clauses
106+
// following UNION operations. Only column names without prefixes or positional notation
107+
// (ORDER BY 1, 2) are valid. This implementation correctly handles valid SQL cases.
108+
109+
test('should move ORDER BY from right query to SimpleQuery when converting', () => {
110+
// Test ORDER BY removal from right query and movement to SimpleQuery
111+
const query1 = SelectQueryParser.parse('SELECT id, name FROM users').toSimpleQuery();
112+
const query2 = SelectQueryParser.parse('SELECT id, name FROM customers ORDER BY name ASC');
113+
const binaryQuery = query1.toUnion(query2);
114+
115+
const result = binaryQuery.toSimpleQuery();
116+
const formatted = formatter.format(result);
117+
118+
// ORDER BY should be at SimpleQuery level, not in the subquery
119+
expect(result.orderByClause).not.toBeNull();
120+
121+
// Verify ORDER BY is at the outermost level (after the subquery)
122+
// Should be: SELECT * FROM (...) AS "bq" ORDER BY "name"
123+
const sql = formatted.formattedSql.trim();
124+
expect(sql).toMatch(/\)\s+as\s+"bq"\s+order\s+by\s+"name"/i);
125+
126+
// The binary query itself should no longer have ORDER BY on the right side
127+
const binaryFormatted = formatter.format(binaryQuery);
128+
// Should not contain ORDER BY before UNION
129+
expect(binaryFormatted.formattedSql).not.toMatch(/order\s+by\s+[^)]+\)\s*$/i);
130+
});
131+
132+
test('should handle multiple ORDER BY clauses correctly', () => {
133+
// Test when both queries have ORDER BY - should use right query's ORDER BY
134+
const query1 = SelectQueryParser.parse('SELECT id, name FROM users ORDER BY id DESC').toSimpleQuery();
135+
const query2 = SelectQueryParser.parse('SELECT id, name FROM customers ORDER BY name ASC, id DESC');
136+
const binaryQuery = query1.toUnion(query2);
137+
138+
const result = binaryQuery.toSimpleQuery();
139+
const formatted = formatter.format(result);
140+
141+
// Should have ORDER BY at SimpleQuery level from right query
142+
expect(result.orderByClause).not.toBeNull();
143+
144+
// Verify ORDER BY is moved to outermost level and contains columns from right query
145+
const sql = formatted.formattedSql.trim();
146+
expect(sql).toMatch(/\)\s+as\s+"bq"\s+order\s+by\s+"name"[^,]*,\s*"id"/i);
147+
148+
// Original binary query should have ORDER BY removed from right side
149+
const binaryFormatted = formatter.format(binaryQuery);
150+
// Left query still has its ORDER BY (not removed)
151+
expect(binaryFormatted.formattedSql).toContain('order by "id" desc union');
152+
// But right query's ORDER BY should be removed
153+
expect(binaryFormatted.formattedSql).not.toMatch(/customers.*order\s+by.*$/i);
154+
});
155+
156+
test('should work with nested binary queries', () => {
157+
// Test ORDER BY handling with nested binary operations
158+
const query1 = SelectQueryParser.parse('SELECT id FROM table1').toSimpleQuery();
159+
const query2 = SelectQueryParser.parse('SELECT id FROM table2 ORDER BY id ASC');
160+
const query3 = SelectQueryParser.parse('SELECT id FROM table3');
161+
162+
const binaryQuery1 = query1.toUnion(query2);
163+
const binaryQuery2 = binaryQuery1.toSimpleQuery().toUnion(query3);
164+
165+
const result = binaryQuery2.toSimpleQuery();
166+
const formatted = formatter.format(result);
167+
168+
// Should extract ORDER BY from the rightmost query that has it
169+
expect(result.orderByClause).not.toBeNull();
170+
171+
// ORDER BY should be at the outermost level
172+
const sql = formatted.formattedSql.trim();
173+
expect(sql).toMatch(/\)\s+as\s+"bq"\s+order\s+by\s+"id"/i);
174+
175+
// Verify the nested structure doesn't have ORDER BY in the middle
176+
expect(sql).not.toMatch(/table2.*order.*union/i);
177+
});
178+
179+
test('should handle case with no ORDER BY in any query', () => {
180+
// Test when no queries have ORDER BY
181+
const query1 = SelectQueryParser.parse('SELECT id FROM users').toSimpleQuery();
182+
const query2 = SelectQueryParser.parse('SELECT id FROM customers');
183+
const binaryQuery = query1.toUnion(query2);
184+
185+
const result = binaryQuery.toSimpleQuery();
186+
187+
// Should have no ORDER BY clause
188+
expect(result.orderByClause).toBeNull();
189+
});
190+
191+
test('should verify ORDER BY is actually removed from original query', () => {
192+
// Create queries and verify ORDER BY removal
193+
const query1 = SelectQueryParser.parse('SELECT id FROM users').toSimpleQuery();
194+
const query2 = SelectQueryParser.parse('SELECT id FROM customers ORDER BY id DESC').toSimpleQuery();
195+
196+
// Store original ORDER BY state
197+
expect(query2.orderByClause).not.toBeNull();
198+
const originalOrderBy = query2.orderByClause;
199+
200+
const binaryQuery = query1.toUnion(query2);
201+
const result = binaryQuery.toSimpleQuery();
202+
203+
// Original query should have ORDER BY removed
204+
expect(query2.orderByClause).toBeNull();
205+
206+
// Result should have the ORDER BY
207+
expect(result.orderByClause).not.toBeNull();
208+
expect(result.orderByClause).toBe(originalOrderBy);
209+
});
210+
});
211+
103212
describe('ValuesQuery.toSimpleQuery()', () => {
104213
test('should convert ValuesQuery to SimpleSelectQuery', () => {
105214
// Test conversion of VALUES query

0 commit comments

Comments
 (0)