diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ff7f6178..2340405a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,8 +17,6 @@ tests/ tests/tools - - tests/parser diff --git a/tests/WP_SQLite_Driver_Metadata_Tests.php b/tests/WP_SQLite_Driver_Metadata_Tests.php index 3aba7fb4..7b07a7a6 100644 --- a/tests/WP_SQLite_Driver_Metadata_Tests.php +++ b/tests/WP_SQLite_Driver_Metadata_Tests.php @@ -23,13 +23,10 @@ public function testCountTables() { $this->assertQuery( 'CREATE TABLE t2 (id INT)' ); $result = $this->assertQuery( "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'wp'" ); - $this->assertEquals( array( (object) array( 'COUNT ( * )' => '2' ) ), $result ); + $this->assertEquals( array( (object) array( 'COUNT(*)' => '2' ) ), $result ); $result = $this->assertQuery( "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'other'" ); - $this->assertEquals( array( (object) array( 'COUNT ( * )' => '0' ) ), $result ); - - // @TODO: The result key should be "COUNT(*)" instead of "COUNT ( * )". - // The spacing was probably inserted by the translator. + $this->assertEquals( array( (object) array( 'COUNT(*)' => '0' ) ), $result ); } public function testInformationSchemaTables() { diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index e0fad421..d2bcaf23 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -6020,4 +6020,222 @@ public function testDatabaseNameMismatchWithExistingInformationSchemaTableData() $this->expectExceptionMessage( "Incorrect database name. The database was created with name 'db-one', but 'db-two' is used in the current session." ); new WP_SQLite_Driver( $connection, 'db-two' ); } + + public function testSelectColumnNames(): void { + $this->assertQuery( 'CREATE TABLE t (id INT, name VARCHAR(255))' ); + $this->assertQuery( 'INSERT INTO t (id, name) VALUES (1, "John"), (2, "Jane")' ); + + // Columns (no explicit alias). + $result = $this->assertQuery( 'SELECT id, name FROM t' ); + $this->assertSame( array( 'id', 'name' ), array_keys( (array) $result[0] ) ); + + // Columns with an explicit alias. + $result = $this->assertQuery( 'SELECT id AS alias_id, name AS alias_name FROM t' ); + $this->assertSame( array( 'alias_id', 'alias_name' ), array_keys( (array) $result[0] ) ); + + // Expressions (no explicit alias). + $result = $this->assertQuery( 'SELECT id + 1, (2 + 3) FROM t' ); + $this->assertSame( array( 'id + 1', '(2 + 3)' ), array_keys( (array) $result[0] ) ); + + // Expressions with an explicit alias. + $result = $this->assertQuery( 'SELECT id + 1 AS alias_id, (2 + 3) AS alias_numbers FROM t' ); + $this->assertSame( array( 'alias_id', 'alias_numbers' ), array_keys( (array) $result[0] ) ); + + // Function calls (no explicit alias). + $result = $this->assertQuery( "SELECT CONCAT('a', 'b')" ); + $this->assertSame( array( "CONCAT('a', 'b')" ), array_keys( (array) $result[0] ) ); + + // Function calls with an explicit alias. + $result = $this->assertQuery( "SELECT CONCAT('a', 'b') AS alias_concat" ); + $this->assertSame( array( 'alias_concat' ), array_keys( (array) $result[0] ) ); + } + + public function testSetStatement(): void { + $this->assertQuery( 'SET NAMES utf8mb4' ); + $this->assertQuery( 'SET CHARSET utf8mb4' ); + $this->assertQuery( 'SET CHARACTER SET utf8mb4' ); + } + + public function testSessionSystemVariables(): void { + $this->assertQuery( "SET character_set_client = 'latin1'" ); + $result = $this->assertQuery( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $result[0]->{'@@character_set_client'} ); + + $this->assertQuery( "SET @@character_set_client = 'utf8mb3'" ); + $result = $this->assertQuery( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb3', $result[0]->{'@@character_set_client'} ); + + $this->assertQuery( "SET @@session.character_set_client = 'utf8mb4'" ); + $result = $this->assertQuery( 'SELECT @@session.character_set_client' ); + $this->assertSame( 'utf8mb4', $result[0]->{'@@session.character_set_client'} ); + } + + public function testSystemVariablesWithKeywords(): void { + $this->assertQuery( 'SET default_storage_engine = InnoDB' ); + $result = $this->assertQuery( 'SELECT @@default_storage_engine' ); + $this->assertSame( 'InnoDB', $result[0]->{'@@default_storage_engine'} ); + + $this->assertQuery( 'SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci' ); + $result = $this->assertQuery( 'SELECT @@default_collation_for_utf8mb4' ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $result[0]->{'@@default_collation_for_utf8mb4'} ); + + $this->assertQuery( 'SET resultset_metadata = FULL' ); + $result = $this->assertQuery( 'SELECT @@resultset_metadata' ); + $this->assertSame( 'FULL', $result[0]->{'@@resultset_metadata'} ); + + $this->assertQuery( 'SET session_track_gtids = OWN_GTID' ); + $result = $this->assertQuery( 'SELECT @@session_track_gtids' ); + $this->assertSame( 'OWN_GTID', $result[0]->{'@@session_track_gtids'} ); + + $this->assertQuery( 'SET session_track_transaction_info = STATE' ); + $result = $this->assertQuery( 'SELECT @@session_track_transaction_info' ); + $this->assertSame( 'STATE', $result[0]->{'@@session_track_transaction_info'} ); + + $this->assertQuery( 'SET transaction_isolation = SERIALIZABLE' ); + $result = $this->assertQuery( 'SELECT @@transaction_isolation' ); + $this->assertSame( 'SERIALIZABLE', $result[0]->{'@@transaction_isolation'} ); + + $this->assertQuery( 'SET use_secondary_engine = FORCED' ); + $result = $this->assertQuery( 'SELECT @@use_secondary_engine' ); + $this->assertSame( 'FORCED', $result[0]->{'@@use_secondary_engine'} ); + } + + public function testSystemVariablesWithBooleanValues(): void { + $this->assertQuery( 'SET autocommit = ON, big_tables = OFF' ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( 'SET autocommit = on, big_tables = off' ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( "SET autocommit = 'ON', big_tables = 'OFF'" ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( "SET autocommit = 'on', big_tables = 'off'" ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( 'SET autocommit = TRUE, big_tables = FALSE' ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( 'SET autocommit = true, big_tables = false' ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( 'SET autocommit = 1, big_tables = 0' ); + $result = $this->assertQuery( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + } + + public function testSystemVariablesWithOnOffValues(): void { + $this->assertQuery( 'SET autocommit = ON' ); + $result = $this->assertQuery( 'SELECT @@autocommit' ); + $this->assertSame( '1', $result[0]->{'@@autocommit'} ); + + $this->assertQuery( 'SET big_tables = OFF' ); + $result = $this->assertQuery( 'SELECT @@big_tables' ); + $this->assertSame( '0', $result[0]->{'@@big_tables'} ); + + $this->assertQuery( 'SET end_markers_in_json = ON' ); + $result = $this->assertQuery( 'SELECT @@end_markers_in_json' ); + $this->assertSame( '1', $result[0]->{'@@end_markers_in_json'} ); + + $this->assertQuery( 'SET explicit_defaults_for_timestamp = OFF' ); + $result = $this->assertQuery( 'SELECT @@explicit_defaults_for_timestamp' ); + $this->assertSame( '0', $result[0]->{'@@explicit_defaults_for_timestamp'} ); + + $this->assertQuery( 'SET keep_files_on_create = ON' ); + $result = $this->assertQuery( 'SELECT @@keep_files_on_create' ); + $this->assertSame( '1', $result[0]->{'@@keep_files_on_create'} ); + + $this->assertQuery( 'SET old_alter_table = OFF' ); + $result = $this->assertQuery( 'SELECT @@old_alter_table' ); + $this->assertSame( '0', $result[0]->{'@@old_alter_table'} ); + + $this->assertQuery( 'SET print_identified_with_as_hex = ON' ); + $result = $this->assertQuery( 'SELECT @@print_identified_with_as_hex' ); + $this->assertSame( '1', $result[0]->{'@@print_identified_with_as_hex'} ); + + $this->assertQuery( 'SET require_row_format = OFF' ); + $result = $this->assertQuery( 'SELECT @@require_row_format' ); + $this->assertSame( '0', $result[0]->{'@@require_row_format'} ); + + $this->assertQuery( 'SET select_into_disk_sync = ON' ); + $result = $this->assertQuery( 'SELECT @@select_into_disk_sync' ); + $this->assertSame( '1', $result[0]->{'@@select_into_disk_sync'} ); + + $this->assertQuery( 'SET session_track_gtids = OFF' ); + $result = $this->assertQuery( 'SELECT @@session_track_gtids' ); + // @TODO: For session_track_gtids, the value should be OFF, not 0. + //$this->assertSame( 'OFF', $result[0]->{'@@session_track_gtids'} ); + + $this->assertQuery( 'SET session_track_schema = ON' ); + $result = $this->assertQuery( 'SELECT @@session_track_schema' ); + $this->assertSame( '1', $result[0]->{'@@session_track_schema'} ); + + $this->assertQuery( 'SET session_track_state_change = OFF' ); + $result = $this->assertQuery( 'SELECT @@session_track_state_change' ); + $this->assertSame( '0', $result[0]->{'@@session_track_state_change'} ); + + $this->assertQuery( 'SET session_track_transaction_info = OFF' ); + $result = $this->assertQuery( 'SELECT @@session_track_transaction_info' ); + // @TODO: For session_track_transaction_info, the value should be OFF, not 0. + //$this->assertSame( 'OFF', $result[0]->{'@@session_track_transaction_info'} ); + + $this->assertQuery( 'SET show_create_table_skip_secondary_engine = ON' ); + $result = $this->assertQuery( 'SELECT @@show_create_table_skip_secondary_engine' ); + $this->assertSame( '1', $result[0]->{'@@show_create_table_skip_secondary_engine'} ); + + $this->assertQuery( 'SET show_create_table_verbosity = OFF' ); + $result = $this->assertQuery( 'SELECT @@show_create_table_verbosity' ); + $this->assertSame( '0', $result[0]->{'@@show_create_table_verbosity'} ); + + $this->assertQuery( 'SET sql_auto_is_null = ON' ); + $result = $this->assertQuery( 'SELECT @@sql_auto_is_null' ); + $this->assertSame( '1', $result[0]->{'@@sql_auto_is_null'} ); + + $this->assertQuery( 'SET sql_big_selects = OFF' ); + $result = $this->assertQuery( 'SELECT @@sql_big_selects' ); + $this->assertSame( '0', $result[0]->{'@@sql_big_selects'} ); + + $this->assertQuery( 'SET sql_buffer_result = ON' ); + $result = $this->assertQuery( 'SELECT @@sql_buffer_result' ); + $this->assertSame( '1', $result[0]->{'@@sql_buffer_result'} ); + + $this->assertQuery( 'SET sql_safe_updates = OFF' ); + $result = $this->assertQuery( 'SELECT @@sql_safe_updates' ); + $this->assertSame( '0', $result[0]->{'@@sql_safe_updates'} ); + + $this->assertQuery( 'SET sql_warnings = ON' ); + $result = $this->assertQuery( 'SELECT @@sql_warnings' ); + $this->assertSame( '1', $result[0]->{'@@sql_warnings'} ); + + $this->assertQuery( 'SET transaction_read_only = OFF' ); + $result = $this->assertQuery( 'SELECT @@transaction_read_only' ); + $this->assertSame( '0', $result[0]->{'@@transaction_read_only'} ); + } + + public function testUserVariables(): void { + $this->assertQuery( 'SET @my_var = 1' ); + $result = $this->assertQuery( 'SELECT @my_var' ); + $this->assertEquals( 1, $result[0]->{'@my_var'} ); + + $this->assertQuery( 'SET @my_var = @my_var + 1' ); + $result = $this->assertQuery( 'SELECT @my_var' ); + $this->assertEquals( 2, $result[0]->{'@my_var'} ); + + $this->assertQuery( 'SET @my_var = @my_var + 1' ); + $result = $this->assertQuery( 'SELECT @my_var' ); + $this->assertEquals( 3, $result[0]->{'@my_var'} ); + } } diff --git a/tests/WP_SQLite_Driver_Translation_Tests.php b/tests/WP_SQLite_Driver_Translation_Tests.php index dfea6a50..613c7ca2 100644 --- a/tests/WP_SQLite_Driver_Translation_Tests.php +++ b/tests/WP_SQLite_Driver_Translation_Tests.php @@ -1261,7 +1261,7 @@ public function testSystemVariables(): void { public function testConcatFunction(): void { $this->assertQuery( - "SELECT ('a' || 'b' || 'c')", + "SELECT ('a' || 'b' || 'c') AS `CONCAT(\"a\", \"b\", \"c\")`", 'SELECT CONCAT("a", "b", "c")' ); } diff --git a/tests/parser/WP_Parser_Node_Tests.php b/tests/parser/WP_Parser_Node_Tests.php index 6f01d217..16fa49d3 100644 --- a/tests/parser/WP_Parser_Node_Tests.php +++ b/tests/parser/WP_Parser_Node_Tests.php @@ -35,19 +35,21 @@ public function testEmptyChildren(): void { } public function testNodeTree(): void { + $input = 'SELECT 1 + 2, 2'; + // Prepare nodes and tokens. $root = new WP_Parser_Node( 1, 'root' ); $n_keyword = new WP_Parser_Node( 2, 'keyword' ); $n_expr_a = new WP_Parser_Node( 3, 'expr' ); $n_expr_b = new WP_Parser_Node( 3, 'expr' ); $n_expr_c = new WP_Parser_Node( 3, 'expr' ); - $t_select = new WP_Parser_Token( 100, 'SELECT' ); - $t_comma = new WP_Parser_Token( 200, ',' ); - $t_plus = new WP_Parser_Token( 300, '+' ); - $t_one = new WP_Parser_Token( 400, '1' ); - $t_two_a = new WP_Parser_Token( 400, '2' ); - $t_two_b = new WP_Parser_Token( 400, '2' ); - $t_eof = new WP_Parser_Token( 500, '' ); + $t_select = new WP_Parser_Token( 100, 0, 6, $input ); + $t_comma = new WP_Parser_Token( 200, 12, 1, $input ); + $t_plus = new WP_Parser_Token( 300, 9, 1, $input ); + $t_one = new WP_Parser_Token( 400, 7, 1, $input ); + $t_two_a = new WP_Parser_Token( 400, 11, 1, $input ); + $t_two_b = new WP_Parser_Token( 400, 14, 1, $input ); + $t_eof = new WP_Parser_Token( 500, 15, 0, $input ); // Prepare a tree. // @@ -102,26 +104,24 @@ public function testNodeTree(): void { $this->assertSame( array(), $root->get_child_tokens( 100 ) ); // Test single descendant methods. - // @TODO: Consider breadth-first search vs depth-first search. $this->assertSame( $n_keyword, $root->get_first_descendant_node() ); $this->assertSame( $n_expr_a, $root->get_first_descendant_node( 'expr' ) ); $this->assertSame( null, $root->get_first_descendant_node( 'root' ) ); - $this->assertSame( $t_comma, $root->get_first_descendant_token() ); + $this->assertSame( $t_select, $root->get_first_descendant_token() ); $this->assertSame( $t_one, $root->get_first_descendant_token( 400 ) ); $this->assertSame( null, $root->get_first_descendant_token( 123 ) ); // Test multiple descendant methods. - // @TODO: Consider breadth-first search vs depth-first search. $this->assertSame( - array( $n_keyword, $n_expr_a, $t_comma, $n_expr_b, $t_eof, $t_select, $t_one, $t_plus, $n_expr_c, $t_two_a, $t_two_b ), + array( $n_keyword, $t_select, $n_expr_a, $t_one, $t_plus, $n_expr_c, $t_two_b, $t_comma, $n_expr_b, $t_two_a, $t_eof ), $root->get_descendants() ); $this->assertSame( - array( $n_keyword, $n_expr_a, $n_expr_b, $n_expr_c ), + array( $n_keyword, $n_expr_a, $n_expr_c, $n_expr_b ), $root->get_descendant_nodes() ); $this->assertSame( - array( $n_expr_a, $n_expr_b, $n_expr_c ), + array( $n_expr_a, $n_expr_c, $n_expr_b ), $root->get_descendant_nodes( 'expr' ) ); $this->assertSame( @@ -129,11 +129,11 @@ public function testNodeTree(): void { $root->get_descendant_nodes( 'root' ) ); $this->assertSame( - array( $t_comma, $t_eof, $t_select, $t_one, $t_plus, $t_two_a, $t_two_b ), + array( $t_select, $t_one, $t_plus, $t_two_b, $t_comma, $t_two_a, $t_eof ), $root->get_descendant_tokens() ); $this->assertSame( - array( $t_one, $t_two_a, $t_two_b ), + array( $t_one, $t_two_b, $t_two_a ), $root->get_descendant_tokens( 400 ) ); $this->assertSame( diff --git a/wp-includes/parser/class-wp-parser-node.php b/wp-includes/parser/class-wp-parser-node.php index aa207bfe..e2d67018 100644 --- a/wp-includes/parser/class-wp-parser-node.php +++ b/wp-includes/parser/class-wp-parser-node.php @@ -102,10 +102,21 @@ public function merge_fragment( $node ) { $this->children = array_merge( $this->children, $node->children ); } + /** + * Check if this node has any child nodes or tokens. + * + * @return bool True if this node has any child nodes or tokens, false otherwise. + */ public function has_child(): bool { return count( $this->children ) > 0; } + /** + * Check if this node has any child nodes. + * + * @param string|null $rule_name Optional. A node rule name to check for. + * @return bool True if any child nodes are found, false otherwise. + */ public function has_child_node( ?string $rule_name = null ): bool { foreach ( $this->children as $child ) { if ( @@ -118,6 +129,12 @@ public function has_child_node( ?string $rule_name = null ): bool { return false; } + /** + * Check if this node has any child tokens. + * + * @param int|null $token_id Optional. A token ID to check for. + * @return bool True if any child tokens are found, false otherwise. + */ public function has_child_token( ?int $token_id = null ): bool { foreach ( $this->children as $child ) { if ( @@ -130,11 +147,22 @@ public function has_child_token( ?int $token_id = null ): bool { return false; } - + /** + * Get the first child node or token of this node. + * + * @return WP_Parser_Node|WP_Parser_Token|null The first child node or token; + * null when no children are found. + */ public function get_first_child() { return $this->children[0] ?? null; } + /** + * Get the first child node of this node. + * + * @param string|null $rule_name Optional. A node rule name to check for. + * @return WP_Parser_Node|null The first matching child node; null when no children are found. + */ public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node { foreach ( $this->children as $child ) { if ( @@ -147,6 +175,12 @@ public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_No return null; } + /** + * Get the first child token of this node. + * + * @param int|null $token_id Optional. A token ID to check for. + * @return WP_Parser_Token|null The first matching child token; null when no children are found. + */ public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token { foreach ( $this->children as $child ) { if ( @@ -159,42 +193,73 @@ public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token return null; } + /** + * Get the first descendant node of this node. + * + * The node children are traversed recursively in a depth-first order until + * a matching descendant node is found, or the entire subtree is searched. + * + * @param string|null $rule_name Optional. A node rule name to check for. + * @return WP_Parser_Node|null The first matching descendant node; null when no descendants are found. + */ public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node { - $nodes = array( $this ); - while ( count( $nodes ) ) { - $node = array_shift( $nodes ); - $child = $node->get_first_child_node( $rule_name ); - if ( $child ) { + for ( $i = 0; $i < count( $this->children ); $i++ ) { + $child = $this->children[ $i ]; + if ( ! $child instanceof WP_Parser_Node ) { + continue; + } + if ( null === $rule_name || $child->rule_name === $rule_name ) { return $child; } - $children = $node->get_child_nodes(); - if ( count( $children ) > 0 ) { - array_push( $nodes, ...$children ); + $node = $child->get_first_descendant_node( $rule_name ); + if ( $node ) { + return $node; } } return null; } + /** + * Get the first descendant token of this node. + * + * The node children are traversed recursively in a depth-first order until + * a matching descendant token is found, or the entire subtree is searched. + * + * @param int|null $token_id Optional. A token ID to check for. + * @return WP_Parser_Token|null The first matching descendant token; null when no descendants are found. + */ public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token { - $nodes = array( $this ); - while ( count( $nodes ) ) { - $node = array_shift( $nodes ); - $child = $node->get_first_child_token( $token_id ); - if ( $child ) { - return $child; - } - $children = $node->get_child_nodes(); - if ( count( $children ) > 0 ) { - array_push( $nodes, ...$children ); + for ( $i = 0; $i < count( $this->children ); $i++ ) { + $child = $this->children[ $i ]; + if ( $child instanceof WP_Parser_Token ) { + if ( null === $token_id || $child->id === $token_id ) { + return $child; + } + } else { + $token = $child->get_first_descendant_token( $token_id ); + if ( $token ) { + return $token; + } } } return null; } + /** + * Get all children of this node. + * + * @return array An array of all child nodes and tokens of this node. + */ public function get_children(): array { return $this->children; } + /** + * Get all child nodes of this node. + * + * @param string|null $rule_name Optional. A node rule name to check for. + * @return WP_Parser_Node[] An array of all matching child nodes. + */ public function get_child_nodes( ?string $rule_name = null ): array { $nodes = array(); foreach ( $this->children as $child ) { @@ -208,6 +273,12 @@ public function get_child_nodes( ?string $rule_name = null ): array { return $nodes; } + /** + * Get all child tokens of this node. + * + * @param int|null $token_id Optional. A token ID to check for. + * @return WP_Parser_Token[] An array of all matching child tokens. + */ public function get_child_tokens( ?int $token_id = null ): array { $tokens = array(); foreach ( $this->children as $child ) { @@ -221,71 +292,93 @@ public function get_child_tokens( ?int $token_id = null ): array { return $tokens; } + /** + * Get all descendants of this node. + * + * The descendants are collected using a depth-first pre-order NLR traversal. + * This produces a natural ordering that corresponds to the original input. + * + * @return array An array of all descendant nodes and tokens of this node. + */ public function get_descendants(): array { - $nodes = array( $this ); - $all_descendants = array(); - while ( count( $nodes ) ) { - $node = array_shift( $nodes ); - $all_descendants = array_merge( $all_descendants, $node->get_children() ); - $children = $node->get_child_nodes(); - if ( count( $children ) > 0 ) { - array_push( $nodes, ...$children ); + $descendants = array(); + foreach ( $this->children as $child ) { + if ( $child instanceof WP_Parser_Node ) { + $descendants[] = $child; + $descendants = array_merge( $descendants, $child->get_descendants() ); + } else { + $descendants[] = $child; } } - return $all_descendants; + return $descendants; } + /** + * Get all descendant nodes of this node. + * + * The descendants are collected using a depth-first pre-order NLR traversal. + * This produces a natural ordering that corresponds to the original input. + * All matching nodes are collected during the traversal. + * + * @param string|null $rule_name Optional. A node rule name to check for. + * @return WP_Parser_Node[] An array of all matching descendant nodes. + */ public function get_descendant_nodes( ?string $rule_name = null ): array { - $nodes = array( $this ); - $all_descendants = array(); - while ( count( $nodes ) ) { - $node = array_shift( $nodes ); - $all_descendants = array_merge( $all_descendants, $node->get_child_nodes( $rule_name ) ); - $children = $node->get_child_nodes(); - if ( count( $children ) > 0 ) { - array_push( $nodes, ...$children ); + $nodes = array(); + foreach ( $this->children as $child ) { + if ( ! $child instanceof WP_Parser_Node ) { + continue; + } + if ( null === $rule_name || $child->rule_name === $rule_name ) { + $nodes[] = $child; } + $nodes = array_merge( $nodes, $child->get_descendant_nodes( $rule_name ) ); } - return $all_descendants; + return $nodes; } + /** + * Get all descendant tokens of this node. + * + * The descendants are collected using a depth-first pre-order NLR traversal. + * This produces a natural ordering that corresponds to the original input. + * All matching tokens are collected during the traversal. + * + * @param int|null $token_id Optional. A token ID to check for. + * @return WP_Parser_Token[] An array of all matching descendant tokens. + */ public function get_descendant_tokens( ?int $token_id = null ): array { - $nodes = array( $this ); - $all_descendants = array(); - while ( count( $nodes ) ) { - $node = array_shift( $nodes ); - $all_descendants = array_merge( $all_descendants, $node->get_child_tokens( $token_id ) ); - $children = $node->get_child_nodes(); - if ( count( $children ) > 0 ) { - array_push( $nodes, ...$children ); + $tokens = array(); + foreach ( $this->children as $child ) { + if ( $child instanceof WP_Parser_Token ) { + if ( null === $token_id || $child->id === $token_id ) { + $tokens[] = $child; + } + } else { + $tokens = array_merge( $tokens, $child->get_descendant_tokens( $token_id ) ); } } - return $all_descendants; + return $tokens; } /** - * Get the byte offset in the input SQL string where this node begins. + * Get the byte offset in the input string where this node begins. * - * @return int + * @return int The byte offset in the input string where this node begins. */ public function get_start(): int { return $this->get_first_descendant_token()->start; } /** - * Get the byte length of this node in the input SQL string. + * Get the byte length of this node in the input string. * - * @return int + * @return int The byte length of this node in the input string. */ public function get_length(): int { - $tokens = $this->get_descendant_tokens(); - $last_token = end( $tokens ); - $start = $this->get_start(); - return $last_token->start + $last_token->length - $start; + $tokens = $this->get_descendant_tokens(); + $first_token = $tokens[0]; + $last_token = $tokens[ count( $tokens ) - 1 ]; + return $last_token->start + $last_token->length - $first_token->start; } - - /* - * @TODO: Let's implement a more powerful AST-querying API. - * See: https://github.com/WordPress/sqlite-database-integration/pull/164#discussion_r1855230501 - */ } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 098c8ef4..5b526b6b 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -425,6 +425,31 @@ class WP_SQLite_Driver { 'STRICT_TRANS_TABLES', ); + /** + * A name-to-value map of MySQL system variables for the current session. + * + * MySQL session system variables are session-specific, so we can store them + * in-memory. In SQL queries, they are combined with global system variables. + * + * See: + * https://dev.mysql.com/doc/refman/8.4/en/using-system-variables.html + * + * @var array + */ + private $session_system_variables = array(); + + /** + * A name-to-value map of MySQL user variables. + * + * MySQL user variables are session-specific, so we can store them in-memory. + * + * See: + * https://dev.mysql.com/doc/refman/8.4/en/user-variables.html + * + * @var array + */ + private $user_variables = array(); + /** * Constructor. * @@ -2089,17 +2114,39 @@ private function execute_set_statement( WP_Parser_Node $node ): void { } if ( + $part instanceof WP_MySQL_Token + && WP_MySQL_Lexer::NAMES_SYMBOL === $part->id + ) { + // "SET NAMES ..." is a no-op for now. + // TODO: Validate charset compatibility with UTF-8. + // See: https://github.com/WordPress/sqlite-database-integration/issues/192 + } elseif ( + $part instanceof WP_Parser_Node + && 'charsetClause' === $part->rule_name + ) { + // "SET CHARACTER SET ..." is a no-op for now. + // TODO: Validate charset compatibility with UTF-8. + // See: https://github.com/WordPress/sqlite-database-integration/issues/192 + } elseif ( $part instanceof WP_Parser_Node && ( 'internalVariableName' === $part->rule_name || 'setSystemVariable' === $part->rule_name ) ) { + // Set a system variable. array_shift( $definition ); // Remove the '='. $value = array_shift( $definition ); $this->execute_set_system_variable_statement( $part, $value, $default_type ); + } elseif ( + $part instanceof WP_Parser_Node + && 'userVariable' === $part->rule_name + ) { + // Set a user variable. + array_shift( $definition ); // Remove the '='. + $value = array_shift( $definition ); + $this->execute_set_user_variable_statement( $part, $value ); } else { - // TODO: Support user variables (in-memory or a temporary table). throw $this->new_not_supported_exception( sprintf( 'SET statement: %s', $node->rule_name ) ); @@ -2141,15 +2188,55 @@ private function execute_set_system_variable_statement( $type = $var_ident_type->get_first_child_token()->id; } - // Get the variable value. - $value = $this->translate( $value_node ); - $value = str_replace( "''", "'", $value ); - $value = substr( $value, 1, -1 ); + /* + * Some MySQL system variables values can be set using an unquoted pure + * identifier rather than a string literal. This includes non-reserved + * keywords. This is equivalent to using a corresponding string literal. + * + * For example, the following statement pairs are equivalent: + * + * SET default_storage_engine = InnoDB + * SET default_storage_engine = 'InnoDB' + * + * SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci + * SET default_collation_for_utf8mb4 = 'utf8mb4_0900_ai_ci' + * + * In this cases, we need to use the value directly without attempting + * to evaluate the expression, as that would result in a query error. + * In the grammar, unquoted identifiers are captured by "columnRef". + */ + $identifier = $this->translate( $value_node->get_first_descendant_node( 'columnRef' ) ); + if ( $identifier && $identifier === $this->translate( $value_node ) ) { + $value = $this->unquote_sqlite_identifier( $identifier ); + } elseif ( ! $value_node->has_child_node( 'expr' ) ) { + $value = $this->unquote_sqlite_identifier( $this->translate( $value_node ) ); + } else { + $value = $this->evaluate_expression( $value_node ); + } + + /* + * Handle ON/OFF values. They are accepted as both strings and keywords. + * + * @TODO: This is actually variable-specific and depends on the its type. + * For example: + * SET autocommit = OFF; SELECT @@autocommit; -> 0 + * SET autocommit = false; SELECT @@autocommit; -> 0 + * SET session_track_gtids = OFF; SELECT @@session_track_gtids; -> OFF + * SET session_track_gtids = false; SELECT @@session_track_gtids; -> OFF + * SET updatable_views_with_limit = OFF; ERROR 1231 (42000) + * SET updatable_views_with_limit = false; SELECT @@updatable_views_with_limit; -> NO + */ + $lowercase_value = strtolower( $value ); + if ( 'on' === $lowercase_value || 'off' === $lowercase_value ) { + $value = 'on' === $lowercase_value ? 1 : 0; + } if ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) { if ( 'sql_mode' === $name ) { $modes = explode( ',', strtoupper( $value ) ); $this->active_sql_modes = $modes; + } else { + $this->session_system_variables[ $name ] = $value; } } elseif ( WP_MySQL_Lexer::GLOBAL_SYMBOL === $type ) { throw $this->new_not_supported_exception( "SET statement type: 'GLOBAL'" ); @@ -2162,6 +2249,26 @@ private function execute_set_system_variable_statement( // TODO: Handle GLOBAL, PERSIST, and PERSIST_ONLY types. } + /** + * Translate and execute a MySQL SET statement for user variables. + * + * @param WP_Parser_Node $user_variable The "userVariable" AST node. + * @param WP_Parser_Node $expr The "expr" AST node. + * @throws WP_SQLite_Driver_Exception When the query execution fails. + */ + private function execute_set_user_variable_statement( + WP_Parser_Node $user_variable, + WP_Parser_Node $expr + ): void { + $name = $this->unquote_sqlite_identifier( + $this->translate( $user_variable->get_first_child() ) + ); + $name = strtolower( substr( $name, 1 ) ); // Remove '@', normalize case. + $value = $this->evaluate_expression( $expr ); + + $this->user_variables[ $name ] = $value; + } + /** * Translate and execute a MySQL administration statement in SQLite. * @@ -2244,6 +2351,33 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $this->set_results_from_fetched_data( $results ); } + /** + * Evaluate an expression and return the value, preserving its type. + * + * This is used to support expressions in SET statements for MySQL variables. + * + * @param WP_Parser_Node $node The "expr" AST node. + * @return mixed The value of the expression. + */ + public function evaluate_expression( WP_Parser_Node $node ) { + // To support expressions, we'll use a SQLite query. + $stmt = $this->execute_sqlite_query( + sprintf( 'SELECT %s', $this->translate( $node ) ) + ); + + // MySQL variables are typed, so we need to preserve the value type. + $value = $stmt->fetchColumn(); + $type = $stmt->getColumnMeta( 0 )['native_type']; + if ( 'null' === $type ) { + return null; + } elseif ( 'integer' === $type ) { + return (int) $value; + } elseif ( 'double' === $type ) { + return (float) $value; + } + return $value; + } + /** * Translate a MySQL AST node or token to an SQLite query fragment. * @@ -2365,6 +2499,8 @@ private function translate( $node ): ?string { throw $this->new_not_supported_exception( sprintf( 'data type: %s', $child->get_value() ) ); + case 'selectItem': + return $this->translate_select_item( $node ); case 'fromClause': // FROM DUAL is MySQL-specific syntax that means "FROM no tables" // and it is equivalent to omitting the FROM clause entirely. @@ -2402,28 +2538,35 @@ private function translate( $node ): ?string { $name = strtolower( $original_name ); $type = $type_token ? $type_token->id : WP_MySQL_Lexer::SESSION_SYMBOL; if ( 'sql_mode' === $name ) { - $value = $this->connection->quote( implode( ',', $this->active_sql_modes ) ); + $value = implode( ',', $this->active_sql_modes ); + } elseif ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) { + $value = $this->session_system_variables[ $name ] ?? null; } else { // When we have no value, it's reasonable to use NULL. - $value = 'NULL'; + $value = null; } // @TODO: Emulate more system variables, or use reasonable defaults. // See: https://dev.mysql.com/doc/refman/8.4/en/server-system-variable-reference.html // See: https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html - - // TODO: Original name should come from the original MySQL input, - // exactly as it was written by the user, and not translated. - - // TODO: The '% AS %' syntax is compatible with SELECT lists only. - // We need to translate it differently when used as a value. - return sprintf( - '%s AS %s', - $value, - $this->quote_sqlite_identifier( - '@@' . ( $type_token ? "{$type_token->get_value()}." : '' ) . $original_name - ) - ); + if ( null === $value ) { + return 'NULL'; + } + if ( is_string( $value ) ) { + return $this->connection->quote( $value ); + } + return (string) $value; + case 'userVariable': + $name = $this->unquote_sqlite_identifier( $this->translate( $node->get_first_child() ) ); + $name = strtolower( substr( $name, 1 ) ); // Remove '@', normalize case. + $value = $this->user_variables[ $name ] ?? null; + if ( null === $value ) { + return 'NULL'; + } + if ( is_string( $value ) ) { + return $this->connection->quote( $value ); + } + return (string) $value; case 'castType': // Translate "CAST(... AS BINARY)" to "CAST(... AS BLOB)". if ( $node->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) { @@ -2880,15 +3023,11 @@ private function translate_function_call( WP_Parser_Node $node ): string { case 'CONCAT': return '(' . implode( ' || ', $args ) . ')'; case 'FOUND_ROWS': - // @TODO: The following implementation with an alias assumes - // that the function is used in the SELECT field list. - // For compatibility with more complex use cases, it may - // be better to register it as a custom SQLite function. $found_rows = $this->last_sql_calc_found_rows; if ( null === $found_rows && is_array( $this->last_result ) ) { $found_rows = count( $this->last_result ); } - return sprintf( "(SELECT %d) AS 'FOUND_ROWS()'", $found_rows ); + return $found_rows; default: return $this->translate_sequence( $node->get_children() ); } @@ -2959,6 +3098,72 @@ private function translate_datetime_literal( string $value ): string { return $value; } + /** + * Translate a select item to SQLite. + * + * In some cases, an explicit alias will be added to the select item, so that + * the returned column name is always the same as it would be in MySQL. + * + * @param WP_Parser_Node $node The "selectItem" AST node. + * @return string The translated expression. + */ + public function translate_select_item( WP_Parser_Node $node ): string { + /* + * First, let's translate the select item subtree. + * + * [GRAMMAR] + * selectItem: tableWild | (expr selectAlias?) + */ + $item = $this->translate_sequence( $node->get_children() ); + + // A table wildcard (e.g., "SELECT *, t.*, ...") never has an alias. + if ( $node->has_child_node( 'tableWild' ) ) { + return $item; + } + + // When an explicit alias is provided, we can use it as is. + $alias = $node->get_first_child_node( 'selectAlias' ); + if ( $alias ) { + return $item; + } + + /* + * When the select item contains only a column definition, we need to use + * it without change, so that the returned column name reflects the real + * column name in all cases, including when using a fully qualified name. + * + * For example, for "SELECT t.id", the column name in the result set will + * only be "id", not "t.id", as it may appear based on the original query. + * + * In this case, SQLite uses the same logic as MySQL, so using the value + * as is without adding an explicit alias will produce the correct result. + */ + $column_ref = $node->get_first_descendant_node( 'columnRef' ); + $is_column_ref = $column_ref && $item === $this->translate( $column_ref ); + if ( $is_column_ref ) { + return $item; + } + + /* + * When the select item has no explicit alias, we need to ensure that the + * returned column name is equivalent to what MySQL infers from the input. + * + * For example, if we translate "CONCAT('a', 'b')" to "('a' || 'b')", we + * need to use the original "CONCAT('a', 'b')" string as the column name. + * To achieve this, the select item will be translated as follows: + * + * SELECT CONCAT('a', 'b') -> SELECT ('a' || 'b') AS `CONCAT('a', 'b')` + */ + $raw_alias = substr( $this->last_mysql_query, $node->get_start(), $node->get_length() ); + $alias = $this->quote_sqlite_identifier( $raw_alias ); + if ( $alias === $item || $raw_alias === $item ) { + // For the simple case of selecting only columns ("SELECT id FROM t"), + // let's avoid unnecessary aliases ("SELECT `id` AS `id` FROM t"). + return $item; + } + return sprintf( '%s AS %s', $item, $alias ); + } + /** * Recreate an existing table using data in the information schema. *