diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 97bdf3ce..8e73f03f 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -39,7 +39,7 @@ public static function setUpBeforeClass(): void { public function setUp(): void { $this->sqlite = new PDO( 'sqlite::memory:' ); - $this->engine = new WP_SQLite_Driver( $this->sqlite ); + $this->engine = new WP_SQLite_Driver( 'wp', $this->sqlite ); $this->engine->query( "CREATE TABLE _options ( ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, diff --git a/tests/WP_SQLite_Driver_Translation_Tests.php b/tests/WP_SQLite_Driver_Translation_Tests.php index 1da6334b..da9d6751 100644 --- a/tests/WP_SQLite_Driver_Translation_Tests.php +++ b/tests/WP_SQLite_Driver_Translation_Tests.php @@ -24,10 +24,6 @@ public static function setUpBeforeClass(): void { self::$grammar = new WP_Parser_Grammar( include self::GRAMMAR_PATH ); } - public function setUp(): void { - $this->driver = new WP_SQLite_Driver( new PDO( 'sqlite::memory:' ) ); - } - public function testSelect(): void { $this->assertQuery( 'SELECT 1', @@ -201,25 +197,64 @@ public function testDelete(): void { } public function testCreateTable(): void { + // Basic CREATE TABLE. $this->assertQuery( 'CREATE TABLE "t" ( "id" INTEGER )', 'CREATE TABLE t (id INT)' ); + $this->assertExecutedInformationSchemaQueries( + array( + 'INSERT INTO _mysql_information_schema_tables (table_schema, table_name, table_type, engine, row_format, table_collation)' + . " VALUES ('wp', 't', 'BASE TABLE', 'InnoDB', 'Dynamic', 'utf8mb4_general_ci')", + ) + ); + // Multiple columns. $this->assertQuery( 'CREATE TABLE "t" ( "id" INTEGER , "name" TEXT , "score" REAL DEFAULT 0.0 )', 'CREATE TABLE t (id INT, name TEXT, score FLOAT DEFAULT 0.0)' ); + $this->assertExecutedInformationSchemaQueries( + array( + 'INSERT INTO _mysql_information_schema_tables (table_schema, table_name, table_type, engine, row_format, table_collation)' + . " VALUES ('wp', 't', 'BASE TABLE', 'InnoDB', 'Dynamic', 'utf8mb4_general_ci')", + ) + ); + // Basic constraints. $this->assertQuery( 'CREATE TABLE "t" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT )', 'CREATE TABLE t (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT)' ); + $this->assertExecutedInformationSchemaQueries( + array( + 'INSERT INTO _mysql_information_schema_tables (table_schema, table_name, table_type, engine, row_format, table_collation)' + . " VALUES ('wp', 't', 'BASE TABLE', 'InnoDB', 'Dynamic', 'utf8mb4_general_ci')", + ) + ); + + // ENGINE is not supported in SQLite, we save it in "information schema". + $this->assertQuery( + 'CREATE TABLE "t" ( "id" INTEGER )', + 'CREATE TABLE t (id INT) ENGINE=MyISAM' + ); + $this->assertExecutedInformationSchemaQueries( + array( + 'INSERT INTO _mysql_information_schema_tables (table_schema, table_name, table_type, engine, row_format, table_collation)' + . " VALUES ('wp', 't', 'BASE TABLE', 'MyISAM', 'Fixed', 'utf8mb4_general_ci')", + ) + ); - // ENGINE is not supported in SQLite. + // COLLATE is not supported in SQLite, we save it in "information schema". $this->assertQuery( 'CREATE TABLE "t" ( "id" INTEGER )', - 'CREATE TABLE t (id INT) ENGINE=InnoDB' + 'CREATE TABLE t (id INT) COLLATE utf8mb4_czech_ci' + ); + $this->assertExecutedInformationSchemaQueries( + array( + 'INSERT INTO _mysql_information_schema_tables (table_schema, table_name, table_type, engine, row_format, table_collation)' + . " VALUES ('wp', 't', 'BASE TABLE', 'InnoDB', 'Dynamic', 'utf8mb4_czech_ci')", + ) ); /* @@ -446,6 +481,7 @@ public function testSystemVariables(): void { } private function assertQuery( $expected, string $query ): void { + $this->driver = new WP_SQLite_Driver( 'wp', new PDO( 'sqlite::memory:' ) ); $this->driver->query( $query ); // Check for SQLite syntax errors. @@ -465,6 +501,16 @@ private function assertQuery( $expected, string $query ): void { $executed_queries = array_values( array_slice( $executed_queries, 1, -1, true ) ); } + // Remove "information_schema" queries. + $executed_queries = array_values( + array_filter( + $executed_queries, + function ( $query ) { + return ! str_contains( $query, '_mysql_information_schema_' ); + } + ) + ); + // Remove "select changes()" executed after some queries. if ( count( $executed_queries ) > 1 @@ -477,4 +523,32 @@ private function assertQuery( $expected, string $query ): void { } $this->assertSame( $expected, $executed_queries ); } + + private function assertExecutedInformationSchemaQueries( array $expected ): void { + // Collect and normalize "information_schema" queries. + $queries = array(); + foreach ( $this->driver->executed_sqlite_queries as $query ) { + if ( ! str_contains( $query['sql'], '_mysql_information_schema_' ) ) { + continue; + } + + // Normalize whitespace. + $sql = trim( preg_replace( '/\s+/', ' ', $query['sql'] ) ); + + // Inline parameters. + $sql = str_replace( '?', '%s', $sql ); + $queries[] = sprintf( + $sql, + ...array_map( + function ( $param ) { + return is_string( $param ) ? + $this->driver->get_pdo()->quote( $param ) + : $param; + }, + $query['params'] + ) + ); + } + $this->assertSame( $expected, $queries ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 0cb250a2..2a4d9fcd 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -89,11 +89,194 @@ class WP_SQLite_Driver { PRIMARY KEY(`table`, `column_or_index`) );'; + /** + * Tables that emulate MySQL "information_schema". + * + * - TABLES + * - VIEWS + * - COLUMNS + * - STATISTICS (indexes) + * - TABLE_CONSTRAINTS (PK, UNIQUE, FK) + * - CHECK_CONSTRAINTS + * - KEY_COLUMN_USAGE (foreign keys) + * - REFERENTIAL_CONSTRAINTS (foreign keys) + * - TRIGGERS + */ + const CREATE_INFORMATION_SCHEMA_QUERIES = array( + // TABLES + "CREATE TABLE IF NOT EXISTS _mysql_information_schema_tables ( + TABLE_CATALOG TEXT NOT NULL DEFAULT 'def', -- always 'def' + TABLE_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + TABLE_TYPE TEXT NOT NULL, -- BASE TABLE or VIEW + ENGINE TEXT NOT NULL, -- storage engine + VERSION INTEGER NOT NULL DEFAULT 10, -- unused, in MySQL 10 hardcoded as 10 + ROW_FORMAT TEXT NOT NULL, -- TODO + TABLE_ROWS INTEGER NOT NULL DEFAULT 0, -- not implemented + AVG_ROW_LENGTH INTEGER NOT NULL DEFAULT 0, -- not implemented + DATA_LENGTH INTEGER NOT NULL DEFAULT 0, -- not implemented + MAX_DATA_LENGTH INTEGER NOT NULL DEFAULT 0, -- not implemented + INDEX_LENGTH INTEGER NOT NULL DEFAULT 0, -- not implemented + DATA_FREE INTEGER NOT NULL DEFAULT 0, -- not implemented + AUTO_INCREMENT INTEGER, -- not implemented + CREATE_TIME TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATE_TIME TEXT, + CHECK_TIME TEXT, -- not implemented + TABLE_COLLATION TEXT NOT NULL, + CHECKSUM INTEGER, -- not implemented + CREATE_OPTIONS TEXT, -- CREATE TABLE extra options + TABLE_COMMENT TEXT NOT NULL DEFAULT '' + )", + + // COLUMNS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_columns ( + TABLE_CATALOG TEXT NOT NULL, + TABLE_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + COLUMN_NAME TEXT NOT NULL, + ORDINAL_POSITION INTEGER NOT NULL, + COLUMN_DEFAULT TEXT, + IS_NULLABLE TEXT NOT NULL, + DATA_TYPE TEXT NOT NULL, + CHARACTER_MAXIMUM_LENGTH INTEGER, + CHARACTER_OCTET_LENGTH INTEGER, + NUMERIC_PRECISION INTEGER, + NUMERIC_SCALE INTEGER, + DATETIME_PRECISION INTEGER, + CHARACTER_SET_NAME TEXT, + COLLATION_NAME TEXT, + COLUMN_TYPE TEXT NOT NULL, + COLUMN_KEY TEXT NOT NULL, + EXTRA TEXT NOT NULL, + PRIVILEGES TEXT NOT NULL, + COLUMN_COMMENT TEXT NOT NULL, + IS_GENERATED TEXT NOT NULL, + GENERATION_EXPRESSION TEXT + )', + + // VIEWS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_views ( + TABLE_CATALOG TEXT NOT NULL, + TABLE_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + VIEW_DEFINITION TEXT NOT NULL, + CHECK_OPTION TEXT NOT NULL, + IS_UPDATABLE TEXT NOT NULL, + DEFINER TEXT NOT NULL, + SECURITY_TYPE TEXT NOT NULL, + CHARACTER_SET_CLIENT TEXT NOT NULL, + COLLATION_CONNECTION TEXT NOT NULL, + ALGORITHM TEXT NOT NULL + )', + + // STATISTICS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_statistics ( + TABLE_CATALOG TEXT NOT NULL, + TABLE_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + NON_UNIQUE INTEGER NOT NULL, + INDEX_SCHEMA TEXT NOT NULL, + INDEX_NAME TEXT NOT NULL, + SEQ_IN_INDEX INTEGER NOT NULL, + COLUMN_NAME TEXT NOT NULL, + COLLATION TEXT, + CARDINALITY INTEGER, + SUB_PART INTEGER, + PACKED TEXT, + NULLABLE TEXT NOT NULL, + INDEX_TYPE TEXT NOT NULL, + COMMENT TEXT, + INDEX_COMMENT TEXT NOT NULL + )', + + // TABLE_CONSTRAINTS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_table_constraints ( + CONSTRAINT_CATALOG TEXT NOT NULL, + CONSTRAINT_SCHEMA TEXT NOT NULL, + CONSTRAINT_NAME TEXT NOT NULL, + TABLE_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + CONSTRAINT_TYPE TEXT NOT NULL + )', + + // CHECK_CONSTRAINTS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_check_constraints ( + CONSTRAINT_CATALOG TEXT NOT NULL, + CONSTRAINT_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + CONSTRAINT_NAME TEXT NOT NULL, + CHECK_CLAUSE TEXT NOT NULL + )', + + // KEY_COLUMN_USAGE + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_key_column_usage ( + CONSTRAINT_CATALOG TEXT NOT NULL, + CONSTRAINT_SCHEMA TEXT NOT NULL, + CONSTRAINT_NAME TEXT NOT NULL, + TABLE_CATALOG TEXT NOT NULL, + TABLE_SCHEMA TEXT NOT NULL, + TABLE_NAME TEXT NOT NULL, + COLUMN_NAME TEXT NOT NULL, + ORDINAL_POSITION INTEGER NOT NULL, + POSITION_IN_UNIQUE_CONSTRAINT INTEGER, + REFERENCED_TABLE_SCHEMA TEXT, + REFERENCED_TABLE_NAME TEXT, + REFERENCED_COLUMN_NAME TEXT + )', + + // REFERENTIAL_CONSTRAINTS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_referential_constraints ( + CONSTRAINT_CATALOG TEXT NOT NULL, + CONSTRAINT_SCHEMA TEXT NOT NULL, + CONSTRAINT_NAME TEXT NOT NULL, + UNIQUE_CONSTRAINT_CATALOG TEXT NOT NULL, + UNIQUE_CONSTRAINT_SCHEMA TEXT NOT NULL, + UNIQUE_CONSTRAINT_NAME TEXT, + MATCH_OPTION TEXT NOT NULL, + UPDATE_RULE TEXT NOT NULL, + DELETE_RULE TEXT NOT NULL, + REFERENCED_TABLE_NAME TEXT NOT NULL + )', + + // TRIGGERS + 'CREATE TABLE IF NOT EXISTS _mysql_information_schema_triggers ( + TRIGGER_CATALOG TEXT NOT NULL, + TRIGGER_SCHEMA TEXT NOT NULL, + TRIGGER_NAME TEXT NOT NULL, + EVENT_MANIPULATION TEXT NOT NULL, + EVENT_OBJECT_CATALOG TEXT NOT NULL, + EVENT_OBJECT_SCHEMA TEXT NOT NULL, + EVENT_OBJECT_TABLE TEXT NOT NULL, + ACTION_ORDER INTEGER NOT NULL, + ACTION_CONDITION TEXT, + ACTION_STATEMENT TEXT NOT NULL, + ACTION_ORIENTATION TEXT NOT NULL, + ACTION_TIMING TEXT NOT NULL, + ACTION_REFERENCE_OLD_TABLE TEXT, + ACTION_REFERENCE_NEW_TABLE TEXT, + ACTION_REFERENCE_OLD_ROW TEXT NOT NULL, + ACTION_REFERENCE_NEW_ROW TEXT NOT NULL, + CREATED TEXT, + SQL_MODE TEXT NOT NULL, + DEFINER TEXT NOT NULL, + CHARACTER_SET_CLIENT TEXT NOT NULL, + COLLATION_CONNECTION TEXT NOT NULL, + DATABASE_COLLATION TEXT NOT NULL + )', + ); + /** * @var WP_Parser_Grammar */ private static $grammar; + /** + * The database name. In WordPress, the value of DB_NAME. + * + * @var string + */ + private $db_name; + /** * Class variable to reference to the PDO instance. * @@ -254,9 +437,11 @@ class WP_SQLite_Driver { * Don't use parent::__construct() because this class does not only returns * PDO instance but many others jobs. * - * @param PDO $pdo The PDO object. + * @param string $db_name The database name. In WordPress, the value of DB_NAME. + * @param PDO|null $pdo The PDO object. */ - public function __construct( $pdo = null ) { + public function __construct( string $db_name, ?PDO $pdo = null ) { + $this->db_name = $db_name; if ( ! $pdo ) { if ( ! is_file( FQDB ) ) { $this->prepare_directory(); @@ -304,6 +489,10 @@ public function __construct( $pdo = null ) { $pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO $pdo->query( self::CREATE_DATA_TYPES_CACHE_TABLE ); + foreach ( self::CREATE_INFORMATION_SCHEMA_QUERIES as $query ) { + $pdo->query( $query ); + } + /* * A list of system tables lets us emulate information_schema * queries without returning extra tables. @@ -962,6 +1151,39 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { $this->execute_sqlite_query( implode( ' ', $query_parts ) ); $this->set_result_from_affected_rows(); + + // Save information to "information_schema" tables. + $table_name = $this->translate( $node->get_descendant_node( 'tableName' ) ); + $table_name = trim( $table_name, '`"' ); + + $engine = $this->translate( $node->get_descendant_node( 'engineRef' ) ) ?? 'InnoDB'; + $engine = strtoupper( trim( $engine, '`"' ) ); + + $row_format = 'Dynamic'; + if ( 'INNODB' === $engine ) { + $engine = 'InnoDB'; + } elseif ( 'MYISAM' === $engine ) { + $engine = 'MyISAM'; + $row_format = 'Fixed'; + } + + $collate = $this->translate( $node->get_descendant_node( 'collationName' ) ) ?? 'utf8mb4_general_ci'; + $collate = strtolower( trim( $collate, '`"' ) ); + + $this->execute_sqlite_query( + ' + INSERT INTO _mysql_information_schema_tables (table_schema, table_name, table_type, engine, row_format, table_collation) + VALUES (?, ?, ?, ?, ?, ?) + ', + array( + $this->db_name, + $table_name, + 'BASE TABLE', + $engine, + $row_format, + $collate, + ) + ); } private function execute_alter_table_statement( WP_Parser_Node $node ): void { @@ -1149,6 +1371,8 @@ private function translate( $ast ) { return null; } return $this->translate_sequence( $ast->get_children() ); + case 'defaultCollation': + return null; case 'duplicateAsQueryExpression': // @TODO: How to handle IGNORE/REPLACE?