Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions tests/WP_SQLite_Driver_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -9244,4 +9244,109 @@ public function testCheckConstraintNotEnforced(): void {
$result[0]->{'Create Table'}
);
}

public function testDynamicDatabaseName(): void {
// Create a setter for the private property "$db_name".
$set_db_name = Closure::bind(
function ( $name ) {
$this->main_db_name = $name;
},
$this->engine,
WP_SQLite_Driver::class
);

// Default database name.
$result = $this->assertQuery( 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name' );
$this->assertEquals(
array(
(object) array( 'SCHEMA_NAME' => 'information_schema' ),
(object) array( 'SCHEMA_NAME' => 'wp' ),
),
$result
);

// Change the database name.
$set_db_name( 'wp_test_new' );
$result = $this->assertQuery( 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name' );
$this->assertEquals(
array(
(object) array( 'SCHEMA_NAME' => 'information_schema' ),
(object) array( 'SCHEMA_NAME' => 'wp_test_new' ),
),
$result
);
}

public function testDynamicDatabaseNameComplexScenario(): void {
// Create a setter for the private property "$db_name".
$set_db_name = Closure::bind(
function ( $name ) {
$this->main_db_name = $name;
},
$this->engine,
WP_SQLite_Driver::class
);

$this->assertQuery( 'CREATE TABLE t (id INT, db_name TEXT)' );
$this->assertQuery( 'INSERT INTO t (id, db_name) VALUES (1, "wp")' );
$this->assertQuery( 'INSERT INTO t (id, db_name) VALUES (2, "wp_test_new")' );
$this->assertQuery( 'INSERT INTO t (id, db_name) VALUES (3, "other")' );

$set_db_name( 'wp_test_new' );

$result = $this->assertQuery(
"SELECT sq.id, sq.table_schema, sq.table_name, sq.column_name
FROM (
SELECT * FROM information_schema.columns ist
JOIN t ON t.db_name = CONCAT(COALESCE(ist.table_schema, 'default'), '')
WHERE ist.table_name = 't'
) sq
ORDER BY ordinal_position"
);
$this->assertEquals(
array(
(object) array(
'id' => '2',
'TABLE_SCHEMA' => 'wp_test_new',
'TABLE_NAME' => 't',
'COLUMN_NAME' => 'id',
),
(object) array(
'id' => '2',
'TABLE_SCHEMA' => 'wp_test_new',
'TABLE_NAME' => 't',
'COLUMN_NAME' => 'db_name',
),
),
$result
);
}

public function testDynamicDatabaseNameWithWildcards(): void {
// Create a setter for the private property "$db_name".
$set_db_name = Closure::bind(
function ( $name ) {
$this->main_db_name = $name;
},
$this->engine,
WP_SQLite_Driver::class
);

// Default database name.
$result = $this->assertQuery(
'SELECT * FROM information_schema.schemata s'
);
$this->assertEquals( 'information_schema', $result[0]->SCHEMA_NAME );
$this->assertEquals( 'wp', $result[1]->SCHEMA_NAME );

// Default database name.
$result = $this->assertQuery(
'SELECT s.*
FROM information_schema.schemata s
LEFT JOIN information_schema.tables t ON t.table_schema = s.schema_name
ORDER BY s.schema_name'
);
$this->assertEquals( 'information_schema', $result[0]->SCHEMA_NAME );
$this->assertEquals( 'wp', $result[1]->SCHEMA_NAME );
}
}
241 changes: 239 additions & 2 deletions wp-includes/sqlite-ast/class-wp-sqlite-driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3416,7 +3416,16 @@ private function translate_qualified_identifier(

// Object child name (column, index, etc.).
if ( null !== $child_node ) {
$parts[] = $this->translate( $child_node );
$translated = $this->translate( $child_node );
$name = $this->unquote_sqlite_identifier( $translated );
$parts[] = $translated;

// When targeting a database name column from the information schema,
// we need to inject the configured database name.
if ( $this->is_information_schema_db_column( $name ) ) {
$fully_qualified_column = implode( '.', $parts );
return $this->inject_configured_database_name( $fully_qualified_column );
}
}

return implode( '.', $parts );
Expand Down Expand Up @@ -3506,9 +3515,55 @@ private function translate_query_expression( WP_Parser_Node $node ): string {
* @return string|null
*/
private function translate_query_specification( WP_Parser_Node $node ): string {
$from = $node->get_first_child_node( 'fromClause' );
$group_by = $node->get_first_child_node( 'groupByClause' );
$having = $node->get_first_child_node( 'havingClause' );

/*
* Check if the query may possibly read from an information schema table
* using a "*" wildcard, such as "SELECT *", "SELECT t.*", and similar.
* If that's the case, we'll need to expand the wildcard to a list of
* column names and inject the configured database name dynamically.
*/
if ( $from && $from->has_child_node( 'tableReferenceList' ) ) {
$select_item_list = $node->get_first_child_node( 'selectItemList' );
$table_reference_list = $from->get_first_child_node( 'tableReferenceList' );

// Check if the query contains any wildcards.
$has_wildcard = $select_item_list->has_child_token( WP_MySQL_Lexer::MULT_OPERATOR );
if ( ! $has_wildcard ) {
foreach ( $select_item_list->get_child_nodes() as $select_item ) {
if ( $select_item->has_child_node( 'tableWild' ) ) {
$has_wildcard = true;
break;
}
}
}

if ( $has_wildcard ) {
$table_refs = $table_reference_list->get_descendant_nodes( 'tableRef' );

// Check if the query may reference any information schema tables.
// This check is approximate, as it also descends into subqueries.
$references_information_schema = false;
foreach ( $table_refs as $table_ref ) {
$references_information_schema = str_starts_with(
strtolower( $this->translate( $table_ref ) ),
self::RESERVED_PREFIX . 'mysql_information_schema_'
);
if ( $references_information_schema ) {
break;
}
}

// We have both wildcards and information schema tables.
// Let's expand the wildcards to a list of columns.
if ( $references_information_schema ) {
return $this->translate_query_specification_with_information_schema_wildcards( $node );
}
}
}

/*
* When the GROUP BY or HAVING clause is present, we need to disambiguate
* the items to ensure they don't cause an "ambiguous column name" error.
Expand Down Expand Up @@ -3583,6 +3638,83 @@ private function translate_query_specification( WP_Parser_Node $node ): string {
return $this->translate_sequence( $node->get_children() );
}

/**
* Translate a query specification with information schema wildcards to SQLite.
*
* When a SELECT item contains wildcards, such as "SELECT *" or "SELECT t.*",
* and the query references an information schema table, we need to expand the
* wildcards to a list of columns and inject the configured database name.
*
* @param WP_Parser_Node $node The "querySpecification" AST node.
* @return string The translated value.
*/
private function translate_query_specification_with_information_schema_wildcards( WP_Parser_Node $node ): string {
$select_item_list = $node->get_first_child_node( 'selectItemList' );
$from = $node->get_first_child_node( 'fromClause' );
$table_reference_list = $from->get_first_child_node( 'tableReferenceList' );

// Collect all tables used in the query.
$table_alias_map = $this->create_table_reference_map( $table_reference_list );

// Translate the SELECT item list, expanding wildcards that are targeting
// the information schema tables, and replacing the database name with
// the configured database name.
$transformed_list = array();
foreach ( $select_item_list->get_children() as $select_item ) {
if ( $select_item instanceof WP_MySQL_Token ) {
// For a global wildcard ("SELECT *"), we need to expand all tables.
if ( WP_MySQL_Lexer::MULT_OPERATOR === $select_item->id ) {
foreach ( $table_alias_map as $table_alias => $table_data ) {
$transformed_list[] = $this->expand_wildcard( $table_data['table_name'], $table_alias );
}
}
} elseif ( $select_item->has_child_node( 'tableWild' ) ) {
// For a table wildcard ("SELECT t.*"), we expand the given table.
$table_wild = $select_item->get_first_child_node( 'tableWild' );
$identifiers = $table_wild->get_child_nodes( 'identifier' );

// Do not expand the wildcard if the identifier contains a database
// name and targets a different database than "information_schema".
if (
2 === count( $identifiers )
&& '`information_schema`' !== strtolower( $this->translate( $identifiers[0] ) )
) {
$transformed_list[] = $this->translate( $select_item );
continue;
}

// Do not expand the wildcard if the identifier has no database
// name and the current database is not "information_schema".
if (
1 === count( $identifiers )
&& 'information_schema' !== $this->db_name
) {
$transformed_list[] = $this->translate( $select_item );
continue;
}

// Expand the wildcard.
$last_identifier = end( $identifiers );
$alias = $this->unquote_sqlite_identifier( $this->translate( $last_identifier ) );
$table_name = $table_alias_map[ $alias ]['table_name'];
$transformed_list[] = $this->expand_wildcard( $table_name, $alias );
} else {
$transformed_list[] = $this->translate( $select_item );
}
}

// Translate node children, replacing the SELECT list with the transformed one.
$parts = array();
foreach ( $node->get_children() as $child ) {
if ( $child instanceof WP_Parser_Node && 'selectItemList' === $child->rule_name ) {
$parts[] = implode( ', ', $transformed_list );
} else {
$parts[] = $this->translate( $child );
}
}
return implode( ' ', $parts );
}

/**
* Translate a MySQL simple expression to SQLite.
*
Expand Down Expand Up @@ -3916,7 +4048,16 @@ public function translate_select_item( WP_Parser_Node $node ): string {
$column_ref = $node->get_first_descendant_node( 'columnRef' );
$is_column_ref = $column_ref && $item === $this->translate( $column_ref );
if ( $is_column_ref ) {
return $item;
$translated = $this->translate( $column_ref );

// When targeting a database name column from the information schema,
// we need to inject the configured database name and add an alias.
$identifiers = $column_ref->get_descendant_nodes( 'identifier' );
$column_name = $this->unquote_sqlite_identifier( $this->translate( end( $identifiers ) ) );
if ( $this->is_information_schema_db_column( $column_name ) ) {
return sprintf( '%s AS %s', $translated, strtoupper( $column_name ) );
}
return $translated;
}

/*
Expand All @@ -3939,6 +4080,102 @@ public function translate_select_item( WP_Parser_Node $node ): string {
return sprintf( '%s AS %s', $item, $alias );
}

/**
* Check if a column name appears to target an information schema column that
* references the database name ("SCHEMA_NAME", "TABLE_SCHEMA", etc.).
*
* TODO: Fully resolve the column references to ensure that they are really
* referencing the information schema tables.
*
* @param string $column_name The name of the column to check.
* @return bool True if the column is an information schema
* database name column, false otherwise.
*/
private function is_information_schema_db_column( string $column_name ): bool {
static $information_schema_columns = array(
'SCHEMA_NAME' => true,
'TABLE_SCHEMA' => true,
'VIEW_SCHEMA' => true,
'INDEX_SCHEMA' => true,
'CONSTRAINT_SCHEMA' => true,
'UNIQUE_CONSTRAINT_SCHEMA' => true,
'REFERENCED_TABLE_SCHEMA' => true,
'TRIGGER_SCHEMA' => true,
);
return isset( $information_schema_columns[ strtoupper( $column_name ) ] );
}

/**
* Translate a name targeting an information schema database name column
* to an expression that injects the configured database name value.
*
* For example, a reference like "`t`.`table_schema`" will be translated to:
*
* IIF(`t`.`table_schema` = 'information_schema', `t`.`table_schema`, 'database_name')
*
* @param string $column_name The name of the column to translate.
* @return string The translated value.
*/
private function inject_configured_database_name( string $column_name ): string {
return sprintf(
"IIF(%s = 'information_schema', %s, %s)",
$column_name,
$column_name,
$this->connection->quote( $this->main_db_name ),
);
}

/**
* Expand a SELECT wildcard to a list of columns.
*
* This method expands wildcards such as "SELECT *", "SELECT t.*", and similar,
* to an explicit list of all columns in the table. When the wildcard targets
* an information schema table, the configured database name will be injected.
*
* For example, the following query:
*
* SELECT * FROM information_schema.tables t
*
* Will be expanded to:
*
* SELECT t.TABLE_CATALOG, 'database_name' AS TABLE_SCHEMA, t.TABLE_NAME, ...
* FROM information_schema.tables t
*
* @param string $table_name The name of the table to expand the wildcard for.
* @param string $table_alias The alias of the table to expand the wildcard for.
* @return string The expanded and translated list of columns.
*/
private function expand_wildcard( string $table_name, string $table_alias ): string {
// We need to fetch the SQLite column information, because the information
// schema tables don't contain records for the information schema itself.
$result = $this->execute_sqlite_query(
'SELECT name FROM pragma_table_info(?)',
array( $table_name )
);

// List all columns in the table, replacing columns targeting database
// name columns with the configured database name.
$columns = $result->fetchAll( PDO::FETCH_COLUMN );
$expanded_list = array();
foreach ( $columns as $column ) {
$fully_qualified_column = sprintf(
'%s.%s',
$this->quote_sqlite_identifier( $table_alias ),
$this->quote_sqlite_identifier( $column )
);
if ( $this->is_information_schema_db_column( $column ) ) {
$expanded_list[] = sprintf(
'%s AS %s',
$this->inject_configured_database_name( $fully_qualified_column ),
strtoupper( $column ),
);
} else {
$expanded_list[] = $fully_qualified_column;
}
}
return implode( ', ', $expanded_list );
}

/**
* Recreate an existing table using data in the information schema.
*
Expand Down
Loading