Skip to content

Commit f872535

Browse files
committed
Implement disambiguation for unqualified columns in ORDER BY
1 parent f6666fe commit f872535

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

tests/WP_SQLite_Driver_Translation_Tests.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,53 @@ public function testIndexHints(): void {
13281328
);
13291329
}
13301330

1331+
public function testSelectOrderByAmbiguousColumnResolution(): void {
1332+
$this->driver->query( 'CREATE TABLE t1 (id INT, name TEXT)' );
1333+
$this->driver->query( 'CREATE TABLE t2 (id INT, name TEXT)' );
1334+
1335+
// Ambiguous column in ORDER BY clause is disambiguated by the SELECT item list.
1336+
$this->assertQuery(
1337+
'SELECT `t1`.`name` FROM `t1` JOIN `t2` ON `t2`.`id` = `t1`.`id` ORDER BY `t1`.`name`',
1338+
'SELECT t1.name FROM t1 JOIN t2 ON t2.id = t1.id ORDER BY name'
1339+
);
1340+
1341+
// The ORDER BY direction is preserved when a column is disambiguated.
1342+
$this->assertQuery(
1343+
'SELECT `t1`.`name` FROM `t1` JOIN `t2` ON `t2`.`id` = `t1`.`id` ORDER BY `t1`.`name` DESC',
1344+
'SELECT t1.name FROM t1 JOIN t2 ON t2.id = t1.id ORDER BY name DESC'
1345+
);
1346+
1347+
// Multiple ambiguous columns in ORDER BY clause are also disambiguated.
1348+
$this->assertQuery(
1349+
'SELECT `t1`.`id` , `t1`.`name` FROM `t1` JOIN `t2` ON `t2`.`id` = `t1`.`id` ORDER BY `t1`.`id` DESC, `t1`.`name` ASC',
1350+
'SELECT t1.id, t1.name FROM t1 JOIN t2 ON t2.id = t1.id ORDER BY id DESC, name ASC'
1351+
);
1352+
1353+
// The disambiguation works with subqueries.
1354+
$this->assertQuery(
1355+
'SELECT `name` FROM ( SELECT `t1`.`name` FROM `t1` JOIN `t2` ON `t2`.`id` = `t1`.`id` ORDER BY `t1`.`name` ) ORDER BY `name`',
1356+
'SELECT name FROM (SELECT t1.name FROM t1 JOIN t2 ON t2.id = t1.id ORDER BY name) ORDER BY name'
1357+
);
1358+
1359+
// The disambiguation works in both root and subquery contexts at the same time.
1360+
$this->assertQuery(
1361+
'SELECT `ta`.`name` FROM ( SELECT `t2`.`name` FROM `t1` JOIN `t2` ON `t2`.`id` = `t1`.`id` ORDER BY `t2`.`name` ) `ta` ORDER BY `ta`.`name`',
1362+
'SELECT ta.name FROM (SELECT t2.name FROM t1 JOIN t2 ON t2.id = t1.id ORDER BY name) ta ORDER BY name'
1363+
);
1364+
1365+
// When the SELECT list item uses an alias, the column is not disambiguated (like in MySQL).
1366+
$this->assertQuery(
1367+
'SELECT `t1`.`name` AS `t1_name` FROM `t1` JOIN `t2` ON `t2`.`id` = `t1`.`id` ORDER BY `name` DESC',
1368+
'SELECT t1.name AS t1_name FROM t1 JOIN t2 ON t2.id = t1.id ORDER BY name DESC'
1369+
);
1370+
1371+
// When the ORDER BY item uses an alias, there is no ambiguity.
1372+
$this->assertQuery(
1373+
'SELECT `t1`.`name` AS `t1_name` FROM `t1` JOIN `t2` ON `t2`.`t1_id` = `t1`.`id` ORDER BY `t1_name` DESC',
1374+
'SELECT t1.name AS t1_name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY `t1_name` DESC'
1375+
);
1376+
}
1377+
13311378
private function assertQuery( $expected, string $query ): void {
13321379
$error = null;
13331380
try {

wp-includes/sqlite-ast/class-wp-sqlite-driver.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2512,6 +2512,8 @@ private function translate( $node ): ?string {
25122512

25132513
$rule_name = $node->rule_name;
25142514
switch ( $rule_name ) {
2515+
case 'queryExpression':
2516+
return $this->translate_query_expression( $node );
25152517
case 'querySpecification':
25162518
// Translate "HAVING ..." without "GROUP BY ..." to "GROUP BY 1 HAVING ...".
25172519
if ( $node->has_child_node( 'havingClause' ) && ! $node->has_child_node( 'groupByClause' ) ) {
@@ -2905,6 +2907,123 @@ private function translate_qualified_identifier(
29052907
return implode( '.', $parts );
29062908
}
29072909

2910+
/**
2911+
* Translate a MySQL query expression to SQLite.
2912+
*
2913+
* @param WP_Parser_Node $node The "queryExpression" AST node.
2914+
* @return string The translated value.
2915+
* @throws WP_SQLite_Driver_Exception When the translation fails.
2916+
*/
2917+
private function translate_query_expression( WP_Parser_Node $node ): string {
2918+
/*
2919+
* When the ORDER BY clause is present, we need to make sure it doesn't
2920+
* cause an "ambiguous column name" error.
2921+
*
2922+
* In SQLite, all column names that exist in multiple tables used in the
2923+
* query must be fully qualified in the ORDER BY clause. In MySQL, these
2924+
* can be disambiguated in the SELECT item list.
2925+
*
2926+
* For example, with tables "t1" and "t2" both having a "name" column,
2927+
* the following query will cause an "ambiguous column name" error in
2928+
* SQLite, but not in MySQL:
2929+
*
2930+
* SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY name
2931+
*
2932+
* This is because MySQL first considers the "name" column that was used
2933+
* in the SELECT list. If it is unambiguous, it will be used in ORDER BY.
2934+
*
2935+
* To address this, let's look for unqualified column references in the
2936+
* ORDER BY clause and try to qualify them using the SELECT item list.
2937+
* In other words, the above query will be rewritten as follows:
2938+
*
2939+
* SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY t1.name
2940+
*
2941+
* Note that the ORDER BY column was rewritten from "name" to "t1.name".
2942+
*/
2943+
$disambiguated_order_list = array();
2944+
$order_clause = $node->get_first_child_node( 'orderClause' );
2945+
if ( $order_clause ) {
2946+
$order_list = $order_clause->get_first_child_node( 'orderList' );
2947+
$select_item_list = $node->get_first_descendant_node( 'selectItemList' );
2948+
2949+
// Get a list of column references used in the SELECT item list.
2950+
$select_column_refs = array();
2951+
foreach ( $select_item_list->get_child_nodes() as $select_item ) {
2952+
// When the SELECT item uses an alias, the column is not disambiguated.
2953+
if ( $select_item->has_child_node( 'selectAlias' ) ) {
2954+
continue;
2955+
}
2956+
2957+
$select_item_expr = $select_item->get_first_child_node( 'expr' );
2958+
if ( ! $select_item_expr ) {
2959+
continue;
2960+
}
2961+
2962+
$select_column_ref = $select_item->get_first_descendant_node( 'columnRef' );
2963+
if (
2964+
$select_column_ref
2965+
&& $this->translate( $select_item_expr ) === $this->translate( $select_column_ref )
2966+
) {
2967+
$select_column_refs[] = $select_column_ref;
2968+
}
2969+
}
2970+
2971+
// For each ORDER BY item, try to find a corresponding SELECT item.
2972+
foreach ( $order_list->get_child_nodes() as $order_item ) {
2973+
$order_expr = $order_item->get_first_child_node( 'expr' );
2974+
$order_column_ref = $order_expr->get_first_descendant_node( 'columnRef' );
2975+
2976+
$select_item_matches = array();
2977+
if (
2978+
$order_column_ref
2979+
&& $this->translate( $order_column_ref ) === $this->translate( $order_expr )
2980+
&& null === $order_column_ref->get_first_descendant_node( 'dotIdentifier' )
2981+
) {
2982+
// Look for select items that match the column reference.
2983+
foreach ( $select_column_refs as $select_column_ref ) {
2984+
$dot_identifiers = $select_column_ref->get_descendant_nodes( 'dotIdentifier' );
2985+
if ( count( $dot_identifiers ) === 0 ) {
2986+
continue;
2987+
}
2988+
2989+
$last_dot_identifier = end( $dot_identifiers );
2990+
$select_column_name = $this->translate( $last_dot_identifier->get_first_child_node() );
2991+
$order_column_name = $this->translate( $order_column_ref );
2992+
if ( $select_column_name === $order_column_name ) {
2993+
$select_item_matches[] = $this->translate( $select_column_ref );
2994+
}
2995+
}
2996+
}
2997+
2998+
if ( 1 === count( $select_item_matches ) ) {
2999+
$direction = $order_item->get_first_child_node( 'direction' );
3000+
$translated_order_item = sprintf(
3001+
'%s%s',
3002+
$select_item_matches[0],
3003+
null !== $direction ? ( ' ' . $this->translate( $direction ) ) : ''
3004+
);
3005+
} else {
3006+
$translated_order_item = $this->translate( $order_item );
3007+
}
3008+
$disambiguated_order_list[] = $translated_order_item;
3009+
}
3010+
3011+
// Translate the query expression, replacing the ORDER BY list with
3012+
// the one that was constructed using the disambiguation algorithm.
3013+
$parts = array();
3014+
foreach ( $node->get_children() as $child ) {
3015+
if ( $child instanceof WP_Parser_Node && 'orderClause' === $child->rule_name ) {
3016+
$parts[] = 'ORDER BY ' . implode( ', ', $disambiguated_order_list );
3017+
} else {
3018+
$parts[] = $this->translate( $child );
3019+
}
3020+
}
3021+
return implode( ' ', $parts );
3022+
}
3023+
3024+
return $this->translate_sequence( $node->get_children() );
3025+
}
3026+
29083027
/**
29093028
* Translate a MySQL simple expression to SQLite.
29103029
*

0 commit comments

Comments
 (0)