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.
*