diff --git a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php new file mode 100644 index 00000000..482ac9f4 --- /dev/null +++ b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php @@ -0,0 +1,102 @@ +sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), + 'sqlite_database' + ); + } + + public function handle_query( string $query ): MySQLServerQueryResult { + try { + $rows = $this->sqlite_driver->query( $query ); + if ( $this->sqlite_driver->get_last_column_count() > 0 ) { + $columns = $this->computeColumnInfo(); + return new SelectQueryResult( $columns, $rows ); + } + return new OkayPacketResult( + $this->sqlite_driver->get_last_return_value() ?? 0, + $this->sqlite_driver->get_insert_id() ?? 0 + ); + } catch ( Throwable $e ) { + return new ErrorQueryResult( $e->getMessage() ); + } + } + + public function computeColumnInfo() { + $columns = array(); + + $column_meta = $this->sqlite_driver->get_last_column_meta(); + + $types = array( + 'DECIMAL' => MySQLProtocol::FIELD_TYPE_DECIMAL, + 'TINY' => MySQLProtocol::FIELD_TYPE_TINY, + 'SHORT' => MySQLProtocol::FIELD_TYPE_SHORT, + 'LONG' => MySQLProtocol::FIELD_TYPE_LONG, + 'FLOAT' => MySQLProtocol::FIELD_TYPE_FLOAT, + 'DOUBLE' => MySQLProtocol::FIELD_TYPE_DOUBLE, + 'NULL' => MySQLProtocol::FIELD_TYPE_NULL, + 'TIMESTAMP' => MySQLProtocol::FIELD_TYPE_TIMESTAMP, + 'LONGLONG' => MySQLProtocol::FIELD_TYPE_LONGLONG, + 'INT24' => MySQLProtocol::FIELD_TYPE_INT24, + 'DATE' => MySQLProtocol::FIELD_TYPE_DATE, + 'TIME' => MySQLProtocol::FIELD_TYPE_TIME, + 'DATETIME' => MySQLProtocol::FIELD_TYPE_DATETIME, + 'YEAR' => MySQLProtocol::FIELD_TYPE_YEAR, + 'NEWDATE' => MySQLProtocol::FIELD_TYPE_NEWDATE, + 'VARCHAR' => MySQLProtocol::FIELD_TYPE_VARCHAR, + 'BIT' => MySQLProtocol::FIELD_TYPE_BIT, + 'NEWDECIMAL' => MySQLProtocol::FIELD_TYPE_NEWDECIMAL, + 'ENUM' => MySQLProtocol::FIELD_TYPE_ENUM, + 'SET' => MySQLProtocol::FIELD_TYPE_SET, + 'TINY_BLOB' => MySQLProtocol::FIELD_TYPE_TINY_BLOB, + 'MEDIUM_BLOB' => MySQLProtocol::FIELD_TYPE_MEDIUM_BLOB, + 'LONG_BLOB' => MySQLProtocol::FIELD_TYPE_LONG_BLOB, + 'BLOB' => MySQLProtocol::FIELD_TYPE_BLOB, + 'VAR_STRING' => MySQLProtocol::FIELD_TYPE_VAR_STRING, + 'STRING' => MySQLProtocol::FIELD_TYPE_STRING, + 'GEOMETRY' => MySQLProtocol::FIELD_TYPE_GEOMETRY, + ); + + foreach ( $column_meta as $column ) { + $type = $types[ $column['native_type'] ] ?? null; + if ( null === $type ) { + throw new Exception( 'Unknown column type: ' . $column['native_type'] ); + } + $columns[] = array( + 'name' => $column['name'], + 'length' => $column['len'], + 'type' => $type, + 'flags' => 129, + 'decimals' => $column['precision'], + ); + } + return $columns; + } +} diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php new file mode 100644 index 00000000..b2517033 --- /dev/null +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -0,0 +1,772 @@ + string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] + public $rows; // Array of rows, each an array of values (strings, numbers, or null) + + public function __construct( array $columns = array(), array $rows = array() ) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function to_packets(): string { + return MySQLProtocol::build_result_set_packets( $this ); + } +} + +class OkayPacketResult implements MySQLServerQueryResult { + public $affected_rows; + public $last_insert_id; + + public function __construct( int $affected_rows, int $last_insert_id ) { + $this->affected_rows = $affected_rows; + $this->last_insert_id = $last_insert_id; + } + + public function to_packets(): string { + $ok_packet = MySQLProtocol::build_ok_packet( $this->affected_rows, $this->last_insert_id ); + return MySQLProtocol::encode_int_24( strlen( $ok_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $ok_packet; + } +} + +class ErrorQueryResult implements MySQLServerQueryResult { + public $code; + public $sql_state; + public $message; + + public function __construct( string $message = 'Syntax error or unsupported query', string $sql_state = '42000', int $code = 0x04A7 ) { + $this->code = $code; + $this->sql_state = $sql_state; + $this->message = $message; + } + + public function to_packets(): string { + $err_packet = MySQLProtocol::build_err_packet( $this->code, $this->sql_state, $this->message ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $err_packet; + } +} + +class MySQLProtocol { + // MySQL client/server capability flags (partial list) + const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags + const CLIENT_CONNECT_WITH_DB = 0x00000008; + const CLIENT_PROTOCOL_41 = 0x00000200; + const CLIENT_SECURE_CONNECTION = 0x00008000; + const CLIENT_MULTI_STATEMENTS = 0x00010000; + const CLIENT_MULTI_RESULTS = 0x00020000; + const CLIENT_PS_MULTI_RESULTS = 0x00040000; + const CLIENT_PLUGIN_AUTH = 0x00080000; + const CLIENT_CONNECT_ATTRS = 0x00100000; + const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; + const CLIENT_DEPRECATE_EOF = 0x01000000; + + // MySQL status flags + const SERVER_STATUS_AUTOCOMMIT = 0x0002; + + /** + * MySQL command types + * + * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html + */ + const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ + const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ + const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ + const COM_QUERY = 0x03; /** Tells the server to execute a query. */ + const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ + const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ + const COM_DROP_DB = 0x06; /** Currently refused by the server. */ + const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ + const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ + const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ + const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ + const COM_CONNECT = 0x0B; /** Currently refused by the server. */ + const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ + const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ + const COM_PING = 0x0E; /** Check if the server is alive. */ + const COM_TIME = 0x0F; /** Currently refused by the server. */ + const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ + const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ + const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ + const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ + const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ + const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ + const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ + const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ + const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ + const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ + const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ + const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ + const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ + const COM_DAEMON = 0x1D; /** Currently refused by the server. */ + const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ + const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ + const COM_CLONE = 0x20; /** Tells the server to clone a server. */ + + // Special packet markers + const OK_PACKET = 0x00; + const EOF_PACKET = 0xfe; + const ERR_PACKET = 0xff; + const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) + + // Auth specific markers for caching_sha2_password + const CACHING_SHA2_FAST_AUTH = 3; + const CACHING_SHA2_FULL_AUTH = 4; + const AUTH_PLUGIN_NAME = 'caching_sha2_password'; + + // Field types + const FIELD_TYPE_DECIMAL = 0x00; + const FIELD_TYPE_TINY = 0x01; + const FIELD_TYPE_SHORT = 0x02; + const FIELD_TYPE_LONG = 0x03; + const FIELD_TYPE_FLOAT = 0x04; + const FIELD_TYPE_DOUBLE = 0x05; + const FIELD_TYPE_NULL = 0x06; + const FIELD_TYPE_TIMESTAMP = 0x07; + const FIELD_TYPE_LONGLONG = 0x08; + const FIELD_TYPE_INT24 = 0x09; + const FIELD_TYPE_DATE = 0x0a; + const FIELD_TYPE_TIME = 0x0b; + const FIELD_TYPE_DATETIME = 0x0c; + const FIELD_TYPE_YEAR = 0x0d; + const FIELD_TYPE_NEWDATE = 0x0e; + const FIELD_TYPE_VARCHAR = 0x0f; + const FIELD_TYPE_BIT = 0x10; + const FIELD_TYPE_NEWDECIMAL = 0xf6; + const FIELD_TYPE_ENUM = 0xf7; + const FIELD_TYPE_SET = 0xf8; + const FIELD_TYPE_TINY_BLOB = 0xf9; + const FIELD_TYPE_MEDIUM_BLOB = 0xfa; + const FIELD_TYPE_LONG_BLOB = 0xfb; + const FIELD_TYPE_BLOB = 0xfc; + const FIELD_TYPE_VAR_STRING = 0xfd; + const FIELD_TYPE_STRING = 0xfe; + const FIELD_TYPE_GEOMETRY = 0xff; + + // Field flags + const NOT_NULL_FLAG = 0x1; + const PRI_KEY_FLAG = 0x2; + const UNIQUE_KEY_FLAG = 0x4; + const MULTIPLE_KEY_FLAG = 0x8; + const BLOB_FLAG = 0x10; + const UNSIGNED_FLAG = 0x20; + const ZEROFILL_FLAG = 0x40; + const BINARY_FLAG = 0x80; + const ENUM_FLAG = 0x100; + const AUTO_INCREMENT_FLAG = 0x200; + const TIMESTAMP_FLAG = 0x400; + const SET_FLAG = 0x800; + + // Character set and collation constants (using utf8mb4 general collation) + const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) + + // Max packet length constant + const MAX_PACKET_LENGTH = 0x00ffffff; + + private $current_db = ''; + + // Helper: Packets assembly and parsing + public static function encode_int_8( int $val ): string { + return chr( $val & 0xff ); + } + + public static function encode_int_16( int $val ): string { + return pack( 'v', $val & 0xffff ); + } + + public static function encode_int_24( int $val ): string { + // 3-byte little-endian integer + return substr( pack( 'V', $val & 0xffffff ), 0, 3 ); + } + + public static function encode_int_32( int $val ): string { + return pack( 'V', $val ); + } + + public static function encode_length_encoded_int( int $val ): string { + // Encodes an integer in MySQL's length-encoded format + if ( $val < 0xfb ) { + return chr( $val ); + } elseif ( $val <= 0xffff ) { + return "\xfc" . self::encode_int_16( $val ); + } elseif ( $val <= 0xffffff ) { + return "\xfd" . self::encode_int_24( $val ); + } else { + return "\xfe" . pack( 'P', $val ); // 8-byte little-endian for 64-bit + } + } + + public static function encode_length_encoded_string( string $str ): string { + return self::encode_length_encoded_int( strlen( $str ) ) . $str; + } + + // Hashing for caching_sha2_password (fast auth algorithm) + public static function sha_256_hash( string $password, string $salt ): string { + $stage1 = hash( 'sha256', $password, true ); + $stage2 = hash( 'sha256', $stage1, true ); + $scramble = hash( 'sha256', $stage2 . substr( $salt, 0, 20 ), true ); + // XOR stage1 and scramble to get token + return $stage1 ^ $scramble; + } + + // Build initial handshake packet (server greeting) + public static function build_handshake_packet( int $conn_id, string &$auth_plugin_data ): string { + $protocol_version = 0x0a; // Handshake protocol version (10) + $server_version = '5.7.30-php-mysql-server'; // Fake server version + // Generate random auth plugin data (20-byte salt) + $salt1 = random_bytes( 8 ); + $salt2 = random_bytes( 12 ); // total salt length = 8+12 = 20 bytes (with filler) + $auth_plugin_data = $salt1 . $salt2; + // Lower 2 bytes of capability flags + $cap_flags_lower = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) & 0xffff; + // Upper 2 bytes of capability flags + $cap_flags_upper = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) >> 16; + $charset = self::CHARSET_UTF8MB4; + $status_flags = self::SERVER_STATUS_AUTOCOMMIT; + + // Assemble handshake packet payload + $payload = chr( $protocol_version ); + $payload .= $server_version . "\0"; + $payload .= self::encode_int_32( $conn_id ); + $payload .= $salt1; + $payload .= "\0"; // filler byte + $payload .= self::encode_int_16( $cap_flags_lower ); + $payload .= chr( $charset ); + $payload .= self::encode_int_16( $status_flags ); + $payload .= self::encode_int_16( $cap_flags_upper ); + $payload .= chr( strlen( $auth_plugin_data ) + 1 ); // auth plugin data length (salt + \0) + $payload .= str_repeat( "\0", 10 ); // 10-byte reserved filler + $payload .= $salt2; + $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 + $payload .= self::AUTH_PLUGIN_NAME . "\0"; + return $payload; + } + + // Build OK packet (after successful authentication or query execution) + public static function build_ok_packet( int $affected_rows = 0, int $last_insert_id = 0 ): string { + $payload = chr( self::OK_PACKET ); + $payload .= self::encode_length_encoded_int( $affected_rows ); + $payload .= self::encode_length_encoded_int( $last_insert_id ); + $payload .= self::encode_int_16( self::SERVER_STATUS_AUTOCOMMIT ); // server status + $payload .= self::encode_int_16( 0 ); // no warning count + // No human-readable message for simplicity + return $payload; + } + + // Build ERR packet (for errors) + public static function build_err_packet( int $error_code, string $sql_state, string $message ): string { + $payload = chr( self::ERR_PACKET ); + $payload .= self::encode_int_16( $error_code ); + $payload .= '#' . strtoupper( $sql_state ); + $payload .= $message; + return $payload; + } + + // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) + public static function build_result_set_packets( SelectQueryResult $result ): string { + $sequence_id = 1; // Sequence starts at 1 for resultset (after COM_QUERY) + $packet_stream = ''; + + // 1. Column count packet (length-encoded integer for number of columns) + $col_count = count( $result->columns ); + $col_count_payload = self::encode_length_encoded_int( $col_count ); + $packet_stream .= self::wrap_packet( $col_count_payload, $sequence_id++ ); + + // 2. Column definition packets for each column + foreach ( $result->columns as $col ) { + // Protocol::ColumnDefinition41 format:] + $col_payload = self::encode_length_encoded_string( $col['catalog'] ?? 'sqlite' ); + $col_payload .= self::encode_length_encoded_string( $col['schema'] ?? '' ); + + // Table alias + $col_payload .= self::encode_length_encoded_string( $col['table'] ?? '' ); + + // Original table name + $col_payload .= self::encode_length_encoded_string( $col['orgTable'] ?? '' ); + + // Column alias + $col_payload .= self::encode_length_encoded_string( $col['name'] ); + + // Original column name + $col_payload .= self::encode_length_encoded_string( $col['orgName'] ?? $col['name'] ); + + // Length of the remaining fixed fields. @TODO: What does that mean? + $col_payload .= self::encode_length_encoded_int( $col['fixedLen'] ?? 0x0c ); + $col_payload .= self::encode_int_16( $col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4 ); + $col_payload .= self::encode_int_32( $col['length'] ); + $col_payload .= self::encode_int_8( $col['type'] ); + $col_payload .= self::encode_int_16( $col['flags'] ); + $col_payload .= self::encode_int_8( $col['decimals'] ); + $col_payload .= "\x00"; // filler (1 byte, reserved) + + $packet_stream .= self::wrap_packet( $col_payload, $sequence_id++ ); + } + // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) + $eof_payload = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); + $packet_stream .= self::wrap_packet( $eof_payload, $sequence_id++ ); + + // 4. Row data packets (each row is a series of length-encoded values) + foreach ( $result->rows as $row ) { + $row_payload = ''; + // Iterate through columns in the defined order to match column definitions + foreach ( $result->columns as $col ) { + $column_name = $col['name']; + $val = $row->{$column_name} ?? null; + + if ( null === $val ) { + // NULL is represented by 0xfb (NULL_VALUE) + $row_payload .= "\xfb"; + } else { + $val_str = (string) $val; + $row_payload .= self::encode_length_encoded_string( $val_str ); + } + } + $packet_stream .= self::wrap_packet( $row_payload, $sequence_id++ ); + } + + // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) + $eof_payload_2 = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); + $packet_stream .= self::wrap_packet( $eof_payload_2, $sequence_id++ ); + + return $packet_stream; + } + + // Helper to wrap a payload into a packet with length and sequence id + public static function wrap_packet( string $payload, int $sequence_id ): string { + $length = strlen( $payload ); + $header = self::encode_int_24( $length ) . self::encode_int_8( $sequence_id ); + return $header . $payload; + } +} + +class IncompleteInputException extends MySQLServerException { + public function __construct( string $message = 'Incomplete input data, more bytes needed' ) { + parent::__construct( $message ); + } +} + +class MySQLGateway { + private $query_handler; + private $connection_id; + private $auth_plugin_data; + private $sequence_id; + private $authenticated = false; + private $buffer = ''; + + public function __construct( MySQLQueryHandler $query_handler ) { + $this->query_handler = $query_handler; + $this->connection_id = random_int( 1, 1000 ); + $this->auth_plugin_data = ''; + $this->sequence_id = 0; + } + + /** + * Get the initial handshake packet to send to the client + * + * @return string Binary packet data to send to client + */ + public function get_initial_handshake(): string { + $handshake_payload = MySQLProtocol::build_handshake_packet( $this->connection_id, $this->auth_plugin_data ); + return MySQLProtocol::encode_int_24( strlen( $handshake_payload ) ) . + MySQLProtocol::encode_int_8( $this->sequence_id++ ) . + $handshake_payload; + } + + /** + * Process bytes received from the client + * + * @param string $data Binary data received from client + * @return string|null Response to send back to client, or null if no response needed + * @throws IncompleteInputException When more data is needed to complete a packet + */ + public function receive_bytes( string $data ): ?string { + // Append new data to existing buffer + $this->buffer .= $data; + + // Check if we have enough data for a header + if ( strlen( $this->buffer ) < 4 ) { + throw new IncompleteInputException( 'Incomplete packet header, need more bytes' ); + } + + // Parse packet header + $packet_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; + $received_sequence_id = ord( $this->buffer[3] ); + + // Check if we have the complete packet + $total_packet_length = 4 + $packet_length; + if ( strlen( $this->buffer ) < $total_packet_length ) { + throw new IncompleteInputException( + 'Incomplete packet payload, have ' . strlen( $this->buffer ) . + ' bytes, need ' . $total_packet_length . ' bytes' + ); + } + + // Extract the complete packet + $packet = substr( $this->buffer, 0, $total_packet_length ); + + // Remove the processed packet from the buffer + $this->buffer = substr( $this->buffer, $total_packet_length ); + + // Process the packet + $payload = substr( $packet, 4, $packet_length ); + + // If not authenticated yet, process authentication + if ( ! $this->authenticated ) { + return $this->process_authentication( $payload ); + } + + // Otherwise, process as a command + $command = ord( $payload[0] ); + if ( MySQLProtocol::COM_QUERY === $command ) { + $query = substr( $payload, 1 ); + return $this->process_query( $query ); + } elseif ( MySQLProtocol::COM_INIT_DB === $command ) { + return $this->process_query( 'USE ' . substr( $payload, 1 ) ); + } elseif ( MySQLProtocol::COM_QUIT === $command ) { + return ''; + } else { + // Unsupported command + $err_packet = MySQLProtocol::build_err_packet( 0x04D2, 'HY000', 'Unsupported command' ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . + MySQLProtocol::encode_int_8( 1 ) . + $err_packet; + } + } + + /** + * Process authentication packet from client + * + * @param string $payload Authentication packet payload + * @return string Response packet to send back + */ + private function process_authentication( string $payload ): string { + $offset = 0; + $payload_length = strlen( $payload ); + + $capability_flags = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); + $offset += 4; + + $client_max_packet_size = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); + $offset += 4; + + $client_character_set = 0; + if ( $offset < $payload_length ) { + $client_character_set = ord( $payload[ $offset ] ); + } + $offset += 1; + + // Skip reserved bytes (always zero) + $offset = min( $payload_length, $offset + 23 ); + + $username = $this->read_null_terminated_string( $payload, $offset ); + + $auth_response = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response_length = $this->read_length_encoded_int( $payload, $offset ); + $auth_response = substr( $payload, $offset, $auth_response_length ); + $offset = min( $payload_length, $offset + $auth_response_length ); + } elseif ( $capability_flags & MySQLProtocol::CLIENT_SECURE_CONNECTION ) { + $auth_response_length = 0; + if ( $offset < $payload_length ) { + $auth_response_length = ord( $payload[ $offset ] ); + } + $offset += 1; + $auth_response = substr( $payload, $offset, $auth_response_length ); + $offset = min( $payload_length, $offset + $auth_response_length ); + } else { + $auth_response = $this->read_null_terminated_string( $payload, $offset ); + } + + $database = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_WITH_DB ) { + $database = $this->read_null_terminated_string( $payload, $offset ); + } + + $auth_plugin_name = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_name = $this->read_null_terminated_string( $payload, $offset ); + } + + if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_ATTRS ) { + $attrs_length = $this->read_length_encoded_int( $payload, $offset ); + $offset = min( $payload_length, $offset + $attrs_length ); + } + + $this->authenticated = true; + $this->sequence_id = 2; + + $response_packets = ''; + + if ( MySQLProtocol::AUTH_PLUGIN_NAME === $auth_plugin_name ) { + $fast_auth_payload = chr( MySQLProtocol::AUTH_MORE_DATA ) . chr( MySQLProtocol::CACHING_SHA2_FAST_AUTH ); + $response_packets .= MySQLProtocol::encode_int_24( strlen( $fast_auth_payload ) ); + $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $fast_auth_payload; + } + + $ok_packet = MySQLProtocol::build_ok_packet(); + $response_packets .= MySQLProtocol::encode_int_24( strlen( $ok_packet ) ); + $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $ok_packet; + + return $response_packets; + } + + private function read_unsigned_int_little_endian( string $payload, int $offset, int $length ): int { + $slice = substr( $payload, $offset, $length ); + if ( '' === $slice || $length <= 0 ) { + return 0; + } + + switch ( $length ) { + case 1: + return ord( $slice[0] ); + case 2: + $padded = str_pad( $slice, 2, "\x00", STR_PAD_RIGHT ); + $unpacked = unpack( 'v', $padded ); + return $unpacked[1] ?? 0; + case 3: + case 4: + default: + $padded = str_pad( $slice, 4, "\x00", STR_PAD_RIGHT ); + $unpacked = unpack( 'V', $padded ); + return $unpacked[1] ?? 0; + } + } + + private function read_null_terminated_string( string $payload, int &$offset ): string { + $null_position = strpos( $payload, "\0", $offset ); + if ( false === $null_position ) { + $result = substr( $payload, $offset ); + $offset = strlen( $payload ); + return $result; + } + + $result = substr( $payload, $offset, $null_position - $offset ); + $offset = $null_position + 1; + return $result; + } + + private function read_length_encoded_int( string $payload, int &$offset ): int { + if ( $offset >= strlen( $payload ) ) { + return 0; + } + + $first = ord( $payload[ $offset ] ); + $offset += 1; + + if ( $first < 0xfb ) { + return $first; + } + + if ( 0xfb === $first ) { + return 0; + } + + if ( 0xfc === $first ) { + $value = $this->read_unsigned_int_little_endian( $payload, $offset, 2 ); + $offset += 2; + return $value; + } + + if ( 0xfd === $first ) { + $value = $this->read_unsigned_int_little_endian( $payload, $offset, 3 ); + $offset += 3; + return $value; + } + + // 0xfe indicates an 8-byte integer + $value = 0; + $slice = substr( $payload, $offset, 8 ); + if ( '' !== $slice ) { + $slice = str_pad( $slice, 8, "\x00" ); + $value = unpack( 'P', $slice )[1]; + } + $offset += 8; + return (int) $value; + } + + /** + * Process a query from the client + * + * @param string $query SQL query to process + * @return string Response packet to send back + */ + private function process_query( string $query ): string { + $query = trim( $query ); + + try { + $result = $this->query_handler->handle_query( $query ); + return $result->to_packets(); + } catch ( MySQLServerException $e ) { + $err_packet = MySQLProtocol::build_err_packet( 0x04A7, '42000', 'Syntax error or unsupported query: ' . $e->getMessage() ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . + MySQLProtocol::encode_int_8( 1 ) . + $err_packet; + } + } + + /** + * Reset the server state for a new connection + */ + public function reset(): void { + $this->connection_id = random_int( 1, 1000 ); + $this->auth_plugin_data = ''; + $this->sequence_id = 0; + $this->authenticated = false; + $this->buffer = ''; + } + + /** + * Check if there's any buffered data that hasn't been processed yet + * + * @return bool True if there's data in the buffer + */ + public function has_buffered_data(): bool { + return ! empty( $this->buffer ); + } + + /** + * Get the number of bytes currently in the buffer + * + * @return int Number of bytes in buffer + */ + public function get_buffer_size(): int { + return strlen( $this->buffer ); + } +} + +class MySQLSocketServer { + private $query_handler; + private $socket; + private $port; + private $clients = array(); + private $client_servers = array(); + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $this->socket, '0.0.0.0', $this->port ); + socket_listen( $this->socket ); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + while ( true ) { + // Prepare arrays for socket_select() + $read = array_merge( array( $this->socket ), $this->clients ); + $write = null; + $except = null; + + // Wait for activity on any socket + $select_result = socket_select( $read, $write, $except, null ); + if ( false === $select_result || $select_result <= 0 ) { + continue; + } + + // Check if there's a new connection + if ( in_array( $this->socket, $read, true ) ) { + $client = socket_accept( $this->socket ); + if ( $client ) { + echo "New client connected.\n"; + $this->clients[] = $client; + $client_id = spl_object_id( $client ); + $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); + + // Send initial handshake + echo "Pre handshake\n"; + $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); + echo "Post handshake\n"; + socket_write( $client, $handshake ); + } + // Remove server socket from read array + unset( $read[ array_search( $this->socket, $read, true ) ] ); + } + + // Handle client activity + echo "Waiting for client activity\n"; + foreach ( $read as $client ) { + echo "calling socket_read\n"; + $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + echo "socket_read returned\n"; + $display = ''; + for ( $i = 0; $i < strlen( $data ); $i++ ) { + $byte = ord( $data[ $i ] ); + if ( $byte >= 32 && $byte <= 126 ) { + // Printable ASCII character + $display .= $data[ $i ]; + } else { + // Non-printable, show as hex + $display .= sprintf( '%02x ', $byte ); + } + } + echo rtrim( $display ) . "\n"; + + if ( false === $data || '' === $data ) { + // Client disconnected + echo "Client disconnected.\n"; + $client_id = spl_object_id( $client ); + $this->client_servers[ $client_id ]->reset(); + unset( $this->client_servers[ $client_id ] ); + socket_close( $client ); + unset( $this->clients[ array_search( $client, $this->clients, true ) ] ); + continue; + } + + try { + // Process the data + $client_id = spl_object_id( $client ); + echo "Receiving bytes\n"; + $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); + if ( $response ) { + echo "Writing response\n"; + echo $response; + socket_write( $client, $response ); + } + echo "Response written\n"; + + // Process any buffered data + while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { + echo "Processing buffered data\n"; + try { + $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); + if ( $response ) { + socket_write( $client, $response ); + } + } catch ( IncompleteInputException $e ) { + break; + } + } + echo "After the while loop\n"; + } catch ( IncompleteInputException $e ) { + echo "Incomplete input exception\n"; + continue; + } + } + echo "restarting the while() loop!\n"; + } + } +} diff --git a/packages/wp-mysql-proxy/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php new file mode 100644 index 00000000..1aa5acf3 --- /dev/null +++ b/packages/wp-mysql-proxy/src/run-sqlite-translation.php @@ -0,0 +1,18 @@ +SQLite proxy that parses MySQL queries and transforms them into SQLite operations. + * + * Most queries works, and the upcoming translation driver should bring the parity much + * closer to 100%: https://github.com/WordPress/sqlite-database-integration/pull/157 + */ + +require_once __DIR__ . '/mysql-server.php'; +require_once __DIR__ . '/handler-sqlite-translation.php'; + +define( 'WP_SQLITE_AST_DRIVER', true ); + +$server = new MySQLSocketServer( + new SQLiteTranslationHandler( __DIR__ . '/../database/test.db' ), + array( 'port' => 3306 ) +); +$server->start(); diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index a5c106c6..687d2aad 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2834,7 +2834,7 @@ public function testShowGrantsFor() { $result, array( (object) array( - 'Grants for root@localhost' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', + 'Grants for root@%' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', ), ) ); @@ -4188,19 +4188,29 @@ public function getReservedPrefixTestData(): array { public function testInformationSchemaIsReadonly( string $query ): void { $this->assertQuery( 'CREATE TABLE t1 (id INT)' ); $this->expectException( WP_SQLite_Driver_Exception::class ); - $this->expectExceptionMessage( "Access denied for user 'sqlite'@'%' to database 'information_schema'" ); + $this->expectExceptionMessage( "Access denied for user 'root'@'%' to database 'information_schema'" ); $this->assertQuery( $query ); } public function getInformationSchemaIsReadonlyTestData(): array { return array( array( 'INSERT INTO information_schema.tables (table_name) VALUES ("t")' ), + array( 'REPLACE INTO information_schema.tables (table_name) VALUES ("t")' ), array( 'UPDATE information_schema.tables SET table_name = "new_t" WHERE table_name = "t"' ), + array( 'UPDATE information_schema.tables, information_schema.columns SET table_name = "new_t" WHERE table_name = "t"' ), array( 'DELETE FROM information_schema.tables WHERE table_name = "t"' ), + array( 'DELETE it FROM t, information_schema.tables it WHERE table_name = "t"' ), + array( 'TRUNCATE information_schema.tables' ), array( 'CREATE TABLE information_schema.new_table (id INT)' ), array( 'ALTER TABLE information_schema.tables ADD COLUMN new_column INT' ), array( 'DROP TABLE information_schema.tables' ), - array( 'TRUNCATE information_schema.tables' ), + array( 'LOCK TABLES information_schema.tables READ' ), + array( 'CREATE INDEX idx_name ON information_schema.tables (name)' ), + array( 'DROP INDEX `PRIMARY` ON information_schema.tables' ), + array( 'ANALYZE TABLE information_schema.tables' ), + array( 'CHECK TABLE information_schema.tables' ), + array( 'OPTIMIZE TABLE information_schema.tables' ), + array( 'REPAIR TABLE information_schema.tables' ), ); } @@ -4210,7 +4220,7 @@ public function getInformationSchemaIsReadonlyTestData(): array { public function testInformationSchemaIsReadonlyWithUse( string $query ): void { $this->assertQuery( 'CREATE TABLE t1 (id INT)' ); $this->expectException( WP_SQLite_Driver_Exception::class ); - $this->expectExceptionMessage( "Access denied for user 'sqlite'@'%' to database 'information_schema'" ); + $this->expectExceptionMessage( "Access denied for user 'root'@'%' to database 'information_schema'" ); $this->assertQuery( 'USE information_schema' ); $this->assertQuery( $query ); } @@ -4218,12 +4228,22 @@ public function testInformationSchemaIsReadonlyWithUse( string $query ): void { public function getInformationSchemaIsReadonlyWithUseTestData(): array { return array( array( 'INSERT INTO tables (table_name) VALUES ("t")' ), + array( 'REPLACE INTOtables (table_name) VALUES ("t")' ), array( 'UPDATE tables SET table_name = "new_t" WHERE table_name = "t"' ), + array( 'UPDATE tables, columns SET table_name = "new_t" WHERE table_name = "t"' ), array( 'DELETE FROM tables WHERE table_name = "t"' ), + array( 'DELETE it FROM t, tables it WHERE table_name = "t"' ), + array( 'TRUNCATE tables' ), array( 'CREATE TABLE new_table (id INT)' ), array( 'ALTER TABLE tables ADD COLUMN new_column INT' ), array( 'DROP TABLE tables' ), - array( 'TRUNCATE tables' ), + array( 'LOCK TABLES tables READ' ), + array( 'CREATE INDEX idx_name ON tables (name)' ), + array( 'DROP INDEX `PRIMARY` ON tables' ), + array( 'ANALYZE TABLE tables' ), + array( 'CHECK TABLE tables' ), + array( 'OPTIMIZE TABLE tables' ), + array( 'REPAIR TABLE tables' ), ); } @@ -9439,4 +9459,500 @@ public function testCastExpression(): void { $result ); } + + public function testFullyQualifiedTableName(): void { + // Ensure "information_schema.tables" is empty. + $this->assertQuery( 'DROP TABLE _options, _dates' ); + $result = $this->assertQuery( 'SELECT * FROM information_schema.tables' ); + $this->assertCount( 0, $result ); + + // Switch to the "information_schema" database. + $this->assertQuery( 'USE information_schema' ); + + // CREATE TABLE + $this->assertQuery( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ); + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 1, $result ); + + // INSERT + $this->assertQuery( 'INSERT INTO wp.t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $result ); + + // SELECT + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $result ); + + // UPDATE + $this->assertQuery( 'UPDATE wp.t SET id = 2' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $result ); + + // DELETE + $this->assertQuery( 'DELETE FROM wp.t' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertCount( 0, $result ); + + // TRUNCATE TABLE + $this->assertQuery( 'INSERT INTO wp.t (id) VALUES (1)' ); + $this->assertQuery( 'TRUNCATE TABLE wp.t' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertCount( 0, $result ); + + // SHOW CREATE TABLE + $result = $this->assertQuery( 'SHOW CREATE TABLE wp.t' ); + $this->assertEquals( + array( + (object) array( + 'Create Table' => implode( + "\n", + array( + 'CREATE TABLE `t` (', + ' `id` int NOT NULL,', + ' PRIMARY KEY (`id`)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + ), + ), + $result + ); + + // SHOW COLUMNS + $result = $this->assertQuery( 'SHOW COLUMNS FROM wp.t' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => '', + ), + ), + $result + ); + + // SHOW COLUMNS with both qualified table name and "FROM database" clause. + // In case both are present, the "FROM database" clause takes precedence. + $result = $this->assertQuery( 'SHOW COLUMNS FROM information_schema.t FROM wp' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => '', + ), + ), + $result + ); + + // SHOW INDEXES + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // SHOW INDEXES with both qualified table name and "FROM database" clause. + // In case both are present, the "FROM database" clause takes precedence. + $result = $this->assertQuery( 'SHOW INDEXES FROM information_schema.t FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // DESCRIBE + $result = $this->assertQuery( 'DESCRIBE wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'id', $result[0]->Field ); + $this->assertEquals( 'int', $result[0]->Type ); + $this->assertEquals( 'NO', $result[0]->Null ); + $this->assertEquals( 'PRI', $result[0]->Key ); + $this->assertEquals( null, $result[0]->Default ); + $this->assertEquals( '', $result[0]->Extra ); + + // SHOW TABLES + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 't', $result[0]->Tables_in_wp ); + + // SHOW TABLE STATUS + $result = $this->assertQuery( 'SHOW TABLE STATUS FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 't', $result[0]->Name ); + + // ALTER TABLE + $this->assertQuery( 'ALTER TABLE wp.t ADD COLUMN name VARCHAR(255)' ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM wp.t' ); + $this->assertCount( 2, $result ); + + // CREATE INDEX + $this->assertQuery( 'CREATE INDEX idx_name ON wp.t (name)' ); + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 2, $result ); + $this->assertEquals( 'idx_name', $result[1]->Key_name ); + + // DROP INDEX + $this->assertQuery( 'DROP INDEX idx_name ON wp.t' ); + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // LOCK TABLE + $this->assertQuery( 'LOCK TABLES wp.t READ' ); + + // UNLOCK TABLE + $this->assertQuery( 'UNLOCK TABLES' ); + + // ANALYZE TABLE + $this->assertQuery( 'ANALYZE TABLE wp.t' ); + + // CHECK TABLE + $this->assertQuery( 'CHECK TABLE wp.t' ); + + // OPTIMIZE TABLE + $this->assertQuery( 'OPTIMIZE TABLE wp.t' ); + + // REPAIR TABLE + $this->assertQuery( 'REPAIR TABLE wp.t' ); + + // DROP TABLE + $this->assertQuery( 'DROP TABLE wp.t' ); + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 0, $result ); + } + + public function testWriteWithUsageOfInformationSchemaTables(): void { + // Ensure "information_schema.tables" is empty. + $this->assertQuery( 'DROP TABLE _options, _dates' ); + $result = $this->assertQuery( 'SELECT * FROM information_schema.tables' ); + $this->assertCount( 0, $result ); + + // Create a table. + $this->assertQuery( 'CREATE TABLE t (id INT, value VARCHAR(255))' ); + + // INSERT with SELECT from information schema. + $this->assertQuery( 'INSERT INTO t (id, value) SELECT 1, table_name FROM information_schema.tables' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + ), + $result + ); + + // INSERT with subselect from information schema. + $this->assertQuery( 'INSERT INTO t (id, value) SELECT 2, table_name FROM (SELECT table_name FROM information_schema.tables)' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 2, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + ), + $result + ); + + // INSERT with JOIN on information schema. + $this->assertQuery( + 'INSERT INTO t (id, value) + SELECT 3, it.table_name + FROM information_schema.schemata s + JOIN information_schema.tables it ON s.schema_name = it.table_schema' + ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 3, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + (object) array( + 'id' => '3', + 'value' => 't', + ), + ), + $result + ); + + // TODO: UPDATE with JOIN on information schema is not supported yet. + + // DELETE with JOIN on information schema. + $this->assertQuery( 'UPDATE t SET value = "other" WHERE id > 1' ); + $this->assertQuery( 'DELETE t FROM t JOIN information_schema.tables it ON t.value = it.table_name' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'value' => 'other', + ), + (object) array( + 'id' => '3', + 'value' => 'other', + ), + ), + $result + ); + } + + public function testNonEmptyColumnMeta(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + + // SELECT + $this->assertQuery( 'SELECT * FROM t' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'id', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW COLLATION + $this->assertQuery( 'SHOW COLLATION' ); + $this->assertSame( 7, $this->engine->get_last_column_count() ); + $this->assertSame( 'Collation', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Charset', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Id', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Default', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Compiled', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Sortlen', $this->engine->get_last_column_meta()[5]['name'] ); + $this->assertSame( 'Pad_attribute', $this->engine->get_last_column_meta()[6]['name'] ); + + // SHOW DATABASES + $this->assertQuery( 'SHOW DATABASES' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'Database', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW CREATE TABLE + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertSame( 2, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Create Table', $this->engine->get_last_column_meta()[1]['name'] ); + + // SHOW TABLE STATUS + $this->assertQuery( 'SHOW TABLE STATUS' ); + $this->assertSame( 18, $this->engine->get_last_column_count() ); + $this->assertSame( 'Name', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Engine', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Version', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Row_format', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Rows', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Avg_row_length', $this->engine->get_last_column_meta()[5]['name'] ); + $this->assertSame( 'Data_length', $this->engine->get_last_column_meta()[6]['name'] ); + $this->assertSame( 'Max_data_length', $this->engine->get_last_column_meta()[7]['name'] ); + $this->assertSame( 'Index_length', $this->engine->get_last_column_meta()[8]['name'] ); + $this->assertSame( 'Data_free', $this->engine->get_last_column_meta()[9]['name'] ); + $this->assertSame( 'Auto_increment', $this->engine->get_last_column_meta()[10]['name'] ); + $this->assertSame( 'Create_time', $this->engine->get_last_column_meta()[11]['name'] ); + $this->assertSame( 'Update_time', $this->engine->get_last_column_meta()[12]['name'] ); + $this->assertSame( 'Check_time', $this->engine->get_last_column_meta()[13]['name'] ); + $this->assertSame( 'Collation', $this->engine->get_last_column_meta()[14]['name'] ); + $this->assertSame( 'Checksum', $this->engine->get_last_column_meta()[15]['name'] ); + $this->assertSame( 'Create_options', $this->engine->get_last_column_meta()[16]['name'] ); + $this->assertSame( 'Comment', $this->engine->get_last_column_meta()[17]['name'] ); + + // SHOW TABLES + $this->assertQuery( 'SHOW TABLES' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'Tables_in_wp', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW FULL TABLES + $this->assertQuery( 'SHOW FULL TABLES' ); + $this->assertSame( 2, $this->engine->get_last_column_count() ); + $this->assertSame( 'Tables_in_wp', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Table_type', $this->engine->get_last_column_meta()[1]['name'] ); + + // SHOW COLUMNS + $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertSame( 6, $this->engine->get_last_column_count() ); + $this->assertSame( 'Field', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Type', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Null', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Key', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Default', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Extra', $this->engine->get_last_column_meta()[5]['name'] ); + + // SHOW INDEX + $this->assertQuery( 'SHOW INDEX FROM t' ); + $this->assertSame( 15, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Non_unique', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Key_name', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Seq_in_index', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Column_name', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Collation', $this->engine->get_last_column_meta()[5]['name'] ); + $this->assertSame( 'Cardinality', $this->engine->get_last_column_meta()[6]['name'] ); + $this->assertSame( 'Sub_part', $this->engine->get_last_column_meta()[7]['name'] ); + $this->assertSame( 'Packed', $this->engine->get_last_column_meta()[8]['name'] ); + $this->assertSame( 'Null', $this->engine->get_last_column_meta()[9]['name'] ); + $this->assertSame( 'Index_type', $this->engine->get_last_column_meta()[10]['name'] ); + $this->assertSame( 'Comment', $this->engine->get_last_column_meta()[11]['name'] ); + $this->assertSame( 'Index_comment', $this->engine->get_last_column_meta()[12]['name'] ); + $this->assertSame( 'Visible', $this->engine->get_last_column_meta()[13]['name'] ); + $this->assertSame( 'Expression', $this->engine->get_last_column_meta()[14]['name'] ); + + // SHOW GRANTS + $this->assertQuery( 'SHOW GRANTS' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'Grants for root@%', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW VARIABLES + $this->assertQuery( 'SHOW VARIABLES' ); + $this->assertSame( 2, $this->engine->get_last_column_count() ); + $this->assertSame( 'Variable_name', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Value', $this->engine->get_last_column_meta()[1]['name'] ); + + // DESCRIBE/EXPLAIN + $this->assertQuery( 'DESCRIBE t' ); + $this->assertSame( 6, $this->engine->get_last_column_count() ); + $this->assertSame( 'Field', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Type', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Null', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Key', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Default', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Extra', $this->engine->get_last_column_meta()[5]['name'] ); + + // ANALYZE TABLE + $this->assertQuery( 'ANALYZE TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + + // CHECK TABLE + $this->assertQuery( 'CHECK TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + + // OPTIMIZE TABLE + $this->assertQuery( 'OPTIMIZE TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + + // REPAIR TABLE + $this->assertQuery( 'REPAIR TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + } + + public function testEmptyColumnMeta(): void { + // CREATE TABLE + $this->assertQuery( 'CREATE TABLE t (id INT)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // INSERT + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // REPLACE + $this->assertQuery( 'UPDATE t SET id = 1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // DELETE + $this->assertQuery( 'DELETE FROM t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // TRUNCATE TABLE + $this->assertQuery( 'TRUNCATE TABLE t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // START TRANSACTION + $this->assertQuery( 'START TRANSACTION' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // COMMIT + $this->assertQuery( 'COMMIT' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // ROLLBACK + $this->assertQuery( 'ROLLBACK' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // SAVEPOINT + $this->assertQuery( 'SAVEPOINT s1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // ROLLBACK TO SAVEPOINT + $this->assertQuery( 'ROLLBACK TO SAVEPOINT s1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // RELEASE SAVEPOINT + $this->assertQuery( 'RELEASE SAVEPOINT s1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // LOCK TABLE + $this->assertQuery( 'LOCK TABLES t READ' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // UNLOCK TABLE + $this->assertQuery( 'UNLOCK TABLES' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // ALTER TABLE + $this->assertQuery( 'ALTER TABLE t ADD COLUMN name VARCHAR(255)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // CREATE INDEX + $this->assertQuery( 'CREATE INDEX idx_name ON t (name)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // DROP INDEX + $this->assertQuery( 'DROP INDEX idx_name ON t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // DROP TABLE + $this->assertQuery( 'DROP TABLE t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // USE + $this->assertQuery( 'USE wp' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // SET + $this->assertQuery( 'SET @my_var = 1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 23a45172..87742309 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -1351,9 +1351,13 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node // Check if the table(s) exists. $lock_items = $subnode->get_child_nodes( 'lockItem' ); foreach ( $lock_items as $lock_item ) { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $lock_item->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $lock_item->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + try { /* * Attempt to query the table directly rather than checking @@ -1483,32 +1487,7 @@ private function execute_select_statement( WP_Parser_Node $node ): void { // Store column meta info. This must be done before fetching data, which // seems to erase type information for expressions in the SELECT clause. - $this->last_column_meta = array(); - for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { - /* - * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3. - * See also: https://github.com/php/php-src/pull/5654 - */ - if ( PHP_VERSION_ID < 70300 ) { - try { - $this->last_column_meta[] = $stmt->getColumnMeta( $i ); - } catch ( Throwable $e ) { - $this->last_column_meta[] = array( - 'native_type' => 'null', - 'pdo_type' => PDO::PARAM_NULL, - 'flags' => array(), - 'table' => '', - 'name' => '', - 'len' => -1, - 'precision' => 0, - ); - } - continue; - } - - $this->last_column_meta[] = $stmt->getColumnMeta( $i ); - } - + $this->store_last_column_meta( $stmt ); $this->set_results_from_fetched_data( $stmt->fetchAll( $this->pdo_fetch_mode ) ); @@ -1529,6 +1508,13 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo $parts = array(); foreach ( $node->get_children() as $child ) { + if ( $child instanceof WP_Parser_Node && 'tableRef' === $child->rule_name ) { + $database = $this->get_database_name( $child ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + if ( $child instanceof WP_MySQL_Token && WP_MySQL_Lexer::IGNORE_SYMBOL === $child->id ) { // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE". $parts[] = 'OR IGNORE'; @@ -1603,6 +1589,21 @@ private function execute_update_statement( WP_Parser_Node $node ): void { $node->get_first_child_node( 'tableReferenceList' ) ); + /* + * Deny UPDATE for information schema tables. + * + * This basic approach is rather restrictive, as it blocks the usage + * of information schema tables anywhere in the UPDATE statement. + * + * TODO: Implement support for UPDATE statements like: + * UPDATE t, information_schema.columns c SET t.column = c.column ... + */ + foreach ( $table_alias_map as $alias => $data ) { + if ( 'information_schema' === strtolower( $data['database'] ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + // Determine whether the UPDATE statement modifies multiple tables. $update_list_node = $node->get_first_child_node( 'updateList' ); $update_target = null; @@ -1823,14 +1824,24 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { $alias_map = array(); $table_ref_list = $node->get_first_child_node( 'tableReferenceList' ); foreach ( $table_ref_list->get_descendant_nodes( 'singleTable' ) as $single_table ) { - $alias = $this->unquote_sqlite_identifier( - $this->translate( $single_table->get_first_child_node( 'tableAlias' ) ) - ); - $ref = $this->unquote_sqlite_identifier( - $this->translate( $single_table->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $single_table->get_first_child_node( 'tableRef' ); + $alias_node = $single_table->get_first_child_node( 'tableAlias' ); + if ( $alias_node ) { + $alias = $this->unquote_sqlite_identifier( $this->translate( $alias_node ) ); + } else { + $alias = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + } - $alias_map[ $alias ] = $ref; + // For an information schema table, check if is a DELETE target. + $database = $this->get_database_name( $table_ref ); + if ( + 'information_schema' === strtolower( $database ) + && in_array( $alias, $table_aliases, true ) + ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $alias_map[ $alias ] = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); } // 3. Compose the SELECT query to fetch ROWIDs to delete. @@ -1880,6 +1891,12 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { // @TODO: Translate DELETE with JOIN to use a subquery. + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $query = $this->translate( $node ); $this->execute_sqlite_query( $query ); $this->set_result_from_affected_rows(); @@ -1913,9 +1930,13 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { } // Get table name. - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $subnode->get_first_child_node( 'tableName' ) ) - ); + $table_name_node = $subnode->get_first_child_node( 'tableName' ); + $database = $this->get_database_name( $table_name_node ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_name_node ) ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } // Handle IF NOT EXISTS. if ( $subnode->has_child_node( 'ifNotExists' ) ) { @@ -1956,9 +1977,12 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_alter_table_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_descendant_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_descendant_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -1972,7 +1996,7 @@ private function execute_alter_table_statement( WP_Parser_Node $node ): void { FROM %s WHERE table_schema = ? AND table_name = ?', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // Track column renames and removals. @@ -2041,6 +2065,11 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void { $table_is_temporary = $child_node->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ); $queries = array(); foreach ( $table_refs as $table_ref ) { + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $parts = array(); foreach ( $child_node->get_children() as $child ) { $is_token = $child instanceof WP_MySQL_Token; @@ -2078,9 +2107,12 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_truncate_table_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } $this->execute_sqlite_query( sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) ) @@ -2104,14 +2136,18 @@ private function execute_truncate_table_statement( WP_Parser_Node $node ): void * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_create_index_statement( WP_Parser_Node $node ): void { - $this->information_schema_builder->record_create_index( $node ); - $create_index = $node->get_first_child_node( 'createIndex' ); $target = $create_index->get_first_child_node( 'createIndexTarget' ); + $table_ref = $target->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $this->information_schema_builder->record_create_index( $node ); - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $target->get_first_child_node( 'tableRef' ) ) - ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $create_index->get_first_child_node( 'indexName' ) ) ); @@ -2158,12 +2194,16 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_drop_index_statement( WP_Parser_Node $node ): void { + $drop_index = $node->get_first_child_node( 'dropIndex' ); + $table_ref = $drop_index->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $this->information_schema_builder->record_drop_index( $node ); - $drop_index = $node->get_first_child_node( 'dropIndex' ); - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $drop_index->get_first_child_node( 'tableRef' ) ) - ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $drop_index->get_first_child_node( 'indexRef' ) ) ); @@ -2212,9 +2252,18 @@ private function execute_show_statement( WP_Parser_Node $node ): void { return; case WP_MySQL_Lexer::CREATE_SYMBOL: if ( WP_MySQL_Lexer::TABLE_SYMBOL === $keyword2->id ) { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + // Refuse SHOW CREATE TABLE for information schema tables, + // as we don't have the table definitions at the moment. + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_driver_exception( + sprintf( "SHOW command denied to user 'sqlite'@'%%' for table '%s'", $table_name ), + '42000' + ); + } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2230,25 +2279,54 @@ private function execute_show_statement( WP_Parser_Node $node ): void { ) ); } + + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => '', + 'name' => 'Table', + 'len' => 256, + 'precision' => 31, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => '', + 'name' => 'Create Table', + 'len' => strlen( $sql ), + 'precision' => 31, + ), + ); return; } break; case WP_MySQL_Lexer::INDEX_SYMBOL: case WP_MySQL_Lexer::INDEXES_SYMBOL: case WP_MySQL_Lexer::KEYS_SYMBOL: - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); - $this->execute_show_index_statement( $table_name ); + $this->execute_show_index_statement( $node ); return; case WP_MySQL_Lexer::GRANTS_SYMBOL: $this->set_results_from_fetched_data( array( (object) array( - 'Grants for root@localhost' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', + 'Grants for root@%' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', ), ) ); + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => '', + 'name' => 'Grants for root@%', + 'len' => 4096, + 'precision' => 31, + ), + ); return; case WP_MySQL_Lexer::TABLE_SYMBOL: $this->execute_show_table_status_statement( $node ); @@ -2257,7 +2335,27 @@ private function execute_show_statement( WP_Parser_Node $node ): void { $this->execute_show_tables_statement( $node ); return; case WP_MySQL_Lexer::VARIABLES_SYMBOL: - $this->last_result = true; + $this->last_result = true; + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => 'session_variables', + 'name' => 'Variable_name', + 'len' => 256, + 'precision' => 0, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => 'session_variables', + 'name' => 'Value', + 'len' => 4096, + 'precision' => 0, + ), + ); return; } @@ -2279,21 +2377,22 @@ private function execute_show_collation_statement(): void { // TODO: LIKE and WHERE clauses. - $result = $this->execute_sqlite_query( $definition )->fetchAll( PDO::FETCH_ASSOC ); - - $collations = array(); - foreach ( $result as $row ) { - $collations[] = (object) array( - 'Collation' => $row['COLLATION_NAME'], - 'Charset' => $row['CHARACTER_SET_NAME'], - 'Id' => $row['ID'], - 'Default' => $row['IS_DEFAULT'], - 'Compiled' => $row['IS_COMPILED'], - 'Sortlen' => $row['SORTLEN'], - 'Pad_attribute' => $row['PAD_ATTRIBUTE'], - ); - } - $this->set_results_from_fetched_data( $collations ); + $stmt = $this->execute_sqlite_query( + sprintf( + 'SELECT + COLLATION_NAME AS `Collation`, + CHARACTER_SET_NAME AS `Charset`, + ID AS `Id`, + IS_DEFAULT AS `Default`, + IS_COMPILED AS `Compiled`, + SORTLEN AS `Sortlen`, + PAD_ATTRIBUTE AS `Pad_attribute` + FROM (%s)', + $definition + ) + ); + $this->store_last_column_meta( $stmt ); + $this->set_results_from_fetched_data( $stmt->fetchAll( PDO::FETCH_OBJ ) ); } /** @@ -2310,7 +2409,7 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'schema_name' ); } - $databases = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( 'SELECT SCHEMA_NAME AS Database FROM ( @@ -2323,18 +2422,31 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void $this->get_saved_db_name(), $this->main_db_name, ) - )->fetchAll( PDO::FETCH_OBJ ); + ); + $this->store_last_column_meta( $stmt ); + $databases = $stmt->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $databases ); } /** * Translate and execute a MySQL SHOW INDEX statement in SQLite. * - * @param string $table_name The table name to show indexes for. + * @param WP_Parser_Node $node The "showStatement" AST node. */ - private function execute_show_index_statement( string $table_name ): void { - // TODO: FROM/IN (multiple) + private function execute_show_index_statement( WP_Parser_Node $node ): void { + $table_ref = $node->get_first_child_node( 'tableRef' ); + $in_db = $node->get_first_child_node( 'inDb' ); + + // Get database and table name. + if ( $in_db ) { + // FROM/IN database. + $database = $this->get_database_name( $in_db ); + } else { + $database = $this->get_database_name( $table_ref ); + } + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + // TODO: WHERE $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2358,7 +2470,7 @@ private function execute_show_index_statement( string $table_name ): void { */ $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' ); - $index_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( ' SELECT TABLE_NAME AS `Table`, @@ -2388,9 +2500,11 @@ private function execute_show_index_statement( string $table_name ): void { ROWID, SEQ_IN_INDEX ", - array( $this->get_saved_db_name(), $table_name ) - )->fetchAll( PDO::FETCH_OBJ ); + array( $this->get_saved_db_name( $database ), $table_name ) + ); + $this->store_last_column_meta( $stmt ); + $index_info = $stmt->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $index_info ); } @@ -2422,45 +2536,42 @@ private function execute_show_table_status_statement( WP_Parser_Node $node ): vo false, // SHOW TABLE STATUS lists only non-temporary tables. 'tables' ); - $table_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( - 'SELECT * FROM %s WHERE table_schema = ? %s ORDER BY table_name', + 'SELECT + table_name AS `Name`, + engine AS `Engine`, + version AS `Version`, + row_format AS `Row_format`, + table_rows AS `Rows`, + avg_row_length AS `Avg_row_length`, + data_length AS `Data_length`, + max_data_length AS `Max_data_length`, + index_length AS `Index_length`, + data_free AS `Data_free`, + auto_increment AS `Auto_increment`, + create_time AS `Create_time`, + update_time AS `Update_time`, + check_time AS `Check_time`, + table_collation AS `Collation`, + checksum AS `Checksum`, + create_options AS `Create_options`, + table_comment AS `Comment` + FROM %s + WHERE table_schema = ? %s + ORDER BY table_name', $this->quote_sqlite_identifier( $tables_tables ), $condition ?? '' ), array( $this->get_saved_db_name( $database ) ) - )->fetchAll( PDO::FETCH_ASSOC ); + ); + $this->store_last_column_meta( $stmt ); + $table_info = $stmt->fetchAll( PDO::FETCH_OBJ ); if ( false === $table_info ) { $this->set_results_from_fetched_data( array() ); } - - // Format the results. - $tables = array(); - foreach ( $table_info as $value ) { - $tables[] = (object) array( - 'Name' => $value['TABLE_NAME'], - 'Engine' => $value['ENGINE'], - 'Version' => $value['VERSION'], - 'Row_format' => $value['ROW_FORMAT'], - 'Rows' => $value['TABLE_ROWS'], - 'Avg_row_length' => $value['AVG_ROW_LENGTH'], - 'Data_length' => $value['DATA_LENGTH'], - 'Max_data_length' => $value['MAX_DATA_LENGTH'], - 'Index_length' => $value['INDEX_LENGTH'], - 'Data_free' => $value['DATA_FREE'], - 'Auto_increment' => $value['AUTO_INCREMENT'], - 'Create_time' => $value['CREATE_TIME'], - 'Update_time' => $value['UPDATE_TIME'], - 'Check_time' => $value['CHECK_TIME'], - 'Collation' => $value['TABLE_COLLATION'], - 'Checksum' => $value['CHECKSUM'], - 'Create_options' => $value['CREATE_OPTIONS'], - 'Comment' => $value['TABLE_COMMENT'], - ); - } - - $this->set_results_from_fetched_data( $tables ); + $this->set_results_from_fetched_data( $table_info ); } /** @@ -2486,41 +2597,33 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'table_name' ); } + // Handle the FULL keyword. + $command_type = $node->get_first_child_node( 'showCommandType' ); + $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL ); + // Fetch table information. $table_tables = $this->information_schema_builder->get_table_name( false, // SHOW TABLES lists only non-temporary tables. 'tables' ); - $table_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( - 'SELECT * FROM %s WHERE table_schema = ? %s ORDER BY table_name', + 'SELECT %s FROM %s WHERE table_schema = ? %s ORDER BY table_name', + $is_full + ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database ) + : sprintf( 'table_name AS `Tables_in_%s`', $database ), $this->quote_sqlite_identifier( $table_tables ), $condition ?? '' ), array( $this->get_saved_db_name( $database ) ) - )->fetchAll( PDO::FETCH_ASSOC ); + ); + $this->store_last_column_meta( $stmt ); + $table_info = $stmt->fetchAll( PDO::FETCH_OBJ ); if ( false === $table_info ) { $this->set_results_from_fetched_data( array() ); } - - // Handle the FULL keyword. - $command_type = $node->get_first_child_node( 'showCommandType' ); - $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL ); - - // Format the results. - $tables = array(); - foreach ( $table_info as $value ) { - $table = array( - "Tables_in_$database" => $value['TABLE_NAME'], - ); - if ( true === $is_full ) { - $table['Table_type'] = $value['TABLE_TYPE']; - } - $tables[] = (object) $table; - } - - $this->set_results_from_fetched_data( $tables ); + $this->set_results_from_fetched_data( $table_info ); } /** @@ -2532,20 +2635,17 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { */ private function execute_show_columns_statement( WP_Parser_Node $node ): void { // TODO: EXTENDED, FULL - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $in_db = $node->get_first_child_node( 'inDb' ); - // FROM/IN database. - $in_db = $node->get_first_child_node( 'inDb' ); - if ( null === $in_db ) { - $database = $this->db_name; + // Get database and table name. + if ( $in_db ) { + // FROM/IN database. + $database = $this->get_database_name( $in_db ); } else { - $database = $this->unquote_sqlite_identifier( - $this->translate( $in_db->get_first_child_node( 'identifier' ) ) - ); + $database = $this->get_database_name( $table_ref ); } - + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); // Check if the table exists. @@ -2573,34 +2673,30 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { // Fetch column information. $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); - $column_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( - 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? %s ORDER BY ordinal_position', + 'SELECT + column_name AS `Field`, + column_type AS `Type`, + is_nullable AS `Null`, + column_key AS `Key`, + column_default AS `Default`, + extra AS `Extra` + FROM %s + WHERE table_schema = ? AND table_name = ? %s + ORDER BY ordinal_position', $this->quote_sqlite_identifier( $columns_table ), $condition ?? '' ), array( $this->get_saved_db_name( $database ), $table_name ) - )->fetchAll( PDO::FETCH_ASSOC ); + ); + $this->store_last_column_meta( $stmt ); + $column_info = $stmt->fetchAll( PDO::FETCH_OBJ ); if ( false === $column_info ) { $this->set_results_from_fetched_data( array() ); } - - // Format the results. - $columns = array(); - foreach ( $column_info as $value ) { - $column = array( - 'Field' => $value['COLUMN_NAME'], - 'Type' => $value['COLUMN_TYPE'], - 'Null' => $value['IS_NULLABLE'], - 'Key' => $value['COLUMN_KEY'], - 'Default' => $value['COLUMN_DEFAULT'], - 'Extra' => $value['EXTRA'], - ); - $columns[] = (object) $column; - } - - $this->set_results_from_fetched_data( $columns ); + $this->set_results_from_fetched_data( $column_info ); } /** @@ -2610,14 +2706,14 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_describe_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); - $column_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( ' SELECT column_name AS `Field`, @@ -2631,9 +2727,11 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { AND table_name = ? ORDER BY ordinal_position ', - array( $this->get_saved_db_name(), $table_name ) - )->fetchAll( PDO::FETCH_OBJ ); + array( $this->get_saved_db_name( $database ), $table_name ) + ); + $this->store_last_column_meta( $stmt ); + $column_info = $stmt->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $column_info ); } @@ -2647,10 +2745,9 @@ private function execute_use_statement( WP_Parser_Node $node ): void { $database_name = $this->unquote_sqlite_identifier( $this->translate( $node->get_first_child_node( 'identifier' ) ) ); + $database_name = strtolower( $database_name ); - if ( 'information_schema' === strtolower( $database_name ) ) { - $this->db_name = 'information_schema'; - } elseif ( $this->main_db_name === $database_name ) { + if ( $this->main_db_name === $database_name || 'information_schema' === $database_name ) { $this->db_name = $database_name; } else { throw $this->new_not_supported_exception( @@ -2903,6 +3000,11 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $table_ref_list = $node->get_first_child_node( 'tableRefList' ); $results = array(); foreach ( $table_ref_list->get_child_nodes( 'tableRef' ) as $table_ref ) { + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $quoted_table_name = $this->quote_sqlite_identifier( $table_name ); try { @@ -2965,6 +3067,45 @@ private function execute_administration_statement( WP_Parser_Node $node ): void 'Msg_text' => count( $errors ) > 0 ? 'Operation failed' : 'OK', ); } + + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Table', + 'len' => 512, + 'precision' => 31, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Op', + 'len' => 40, + 'precision' => 31, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Msg_type', + 'len' => 40, + 'precision' => 31, + ), + array( + 'native_type' => 'TEXT', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Msg_text', + 'len' => 1572864, + 'precision' => 31, + ), + ); $this->set_results_from_fetched_data( $results ); } @@ -3442,24 +3583,6 @@ private function translate_qualified_identifier( } } - /* - * Make the 'information_schema' database read-only. - * - * This basic approach is rather restrictive, as it blocks the usage - * of information schema tables in all data-modifying statements. - * - * Some of these statements can be valid, when the schema is only read: - * DELETE t FROM t JOIN information_schema.columns c ON ... - * - * If needed, a more granular approach can be implemented in the future. - */ - if ( true === $is_information_schema && false === $this->is_readonly ) { - throw $this->new_driver_exception( - "Access denied for user 'sqlite'@'%' to database 'information_schema'", - '42000' - ); - } - // Database-level object name (table, view, procedure, trigger, etc.). if ( null !== $object_node ) { $parts[] = $this->translate( $object_node ); @@ -4018,11 +4141,6 @@ public function translate_select_item( WP_Parser_Node $node ): string { * @throws WP_SQLite_Driver_Exception When the translation fails. */ public function translate_table_ref( WP_Parser_Node $node ): string { - // Information schema is currently accessible only in read-only queries. - if ( ! $this->is_readonly ) { - return $this->translate_sequence( $node->get_children() ); - } - // The table reference is in "." or "
" format. $parts = $node->get_descendant_nodes( 'identifier' ); $table = array_pop( $parts ); @@ -4058,6 +4176,10 @@ public function translate_table_ref( WP_Parser_Node $node ): string { array( $sqlite_table_name ) )->fetchAll( PDO::FETCH_COLUMN ); + if ( count( $columns ) === 0 ) { + return $this->translate_sequence( $node->get_children() ); + } + // List all columns in the table, replacing columns targeting database // name columns with the configured database name. static $information_schema_db_column_map = array( @@ -4474,6 +4596,42 @@ private function translate_update_list_in_non_strict_mode( string $table_name, W return $fragment; } + /** + * Store column metadata for the last SQLite statement. + * + * This function stores the original SQLite column metadata as-is, without + * converting it into MySQL column metadata. That is done only when needed. + * + * @param PDOStatement $stmt The PDOStatement object containing the SQLite column metadata. + */ + private function store_last_column_meta( PDOStatement $stmt ): void { + $this->last_column_meta = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + /* + * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3. + * See also: https://github.com/php/php-src/pull/5654 + */ + if ( PHP_VERSION_ID < 70300 ) { + try { + $this->last_column_meta[] = $stmt->getColumnMeta( $i ); + } catch ( Throwable $e ) { + $this->last_column_meta[] = array( + 'native_type' => 'null', + 'pdo_type' => PDO::PARAM_NULL, + 'flags' => array(), + 'table' => '', + 'name' => '', + 'len' => -1, + 'precision' => 0, + ); + } + continue; + } + + $this->last_column_meta[] = $stmt->getColumnMeta( $i ); + } + } + /** * Unnest parenthesized MySQL expression node. * @@ -4652,6 +4810,7 @@ private function create_select_item_disambiguation_map( WP_Parser_Node $select_i * The returned array maps table aliases to table names and additional data: * - key: table alias, or name if no alias is used * - value: an array of table data + * - database: the database name of the table (null for derived tables) * - table_name: the real name of the table (null for derived tables) * - table_expr: the table expression for a derived table (null for regular tables) * - join_expr: the join expression used for the table (null when no join is used) @@ -4691,11 +4850,13 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { if ( 'singleTable' === $child->rule_name ) { // Extract data from the "singleTable" node. - $name = $this->translate( $child->get_first_child_node( 'tableRef' ) ); + $table_ref = $child->get_first_child_node( 'tableRef' ); + $name = $this->translate( $table_ref ); $alias_node = $child->get_first_child_node( 'tableAlias' ); $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array( + 'database' => $this->get_database_name( $table_ref ), 'table_name' => $this->unquote_sqlite_identifier( $name ), 'table_expr' => null, 'join_expr' => $this->translate( $join_expr ), @@ -4707,6 +4868,7 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; $table_map[ $this->unquote_sqlite_identifier( $alias ) ] = array( + 'database' => null, 'table_name' => null, 'table_expr' => $this->translate( $subquery ), 'join_expr' => $this->translate( $join_expr ), @@ -4804,6 +4966,31 @@ private function get_saved_db_name( ?string $db_name = null ): string { : $db_name; } + /** + * Get the database name from one of fully-qualified name AST nodes. + * + * @param WP_Parser_Node $node The AST node. One of "tableName", "tableRef", or "inDb". + * @return string The database name. + */ + private function get_database_name( WP_Parser_Node $node ): string { + if ( 'tableName' === $node->rule_name || 'tableRef' === $node->rule_name ) { + $parts = $node->get_descendant_nodes( 'identifier' ); + if ( count( $parts ) > 1 ) { + return $this->unquote_sqlite_identifier( $this->translate( $parts[0] ) ); + } else { + return $this->db_name; + } + } elseif ( 'inDb' === $node->rule_name ) { + return $this->unquote_sqlite_identifier( + $this->translate( $node->get_first_child_node( 'identifier' ) ) + ); + } + + throw $this->new_driver_exception( + sprintf( 'Could not get database name from node: %s', $node->rule_name ) + ); + } + /** * Generate a SQLite CREATE TABLE statement from information schema data. * @@ -4818,6 +5005,9 @@ private function get_sqlite_create_table_statement( string $table_name, ?string $new_table_name = null ): array { + // This method is always used with the main database. + $database = $this->get_saved_db_name( $this->main_db_name ); + // 1. Get table info. $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' ); $table_info = $this->execute_sqlite_query( @@ -4828,7 +5018,7 @@ private function get_sqlite_create_table_statement( AND table_schema = ? AND table_name = ? ", - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetch( PDO::FETCH_ASSOC ); if ( false === $table_info ) { @@ -4845,7 +5035,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 3. Get index info, grouped by index name. @@ -4868,7 +5058,7 @@ private function get_sqlite_create_table_statement( ", $this->quote_sqlite_identifier( $statistics_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $grouped_constraints = array(); @@ -4886,7 +5076,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name', $this->quote_sqlite_identifier( $referential_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -4898,7 +5088,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL', $this->quote_sqlite_identifier( $key_column_usage_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -4930,7 +5120,7 @@ private function get_sqlite_create_table_statement( $this->quote_sqlite_identifier( $table_constraints_table ), $this->quote_sqlite_identifier( $check_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 6. Generate CREATE TABLE statement columns. @@ -5142,6 +5332,9 @@ function ( $column ) { * @return string The CREATE TABLE statement. */ private function get_mysql_create_table_statement( bool $table_is_temporary, string $table_name ): ?string { + // This method is always used with the main database. + $database = $this->get_saved_db_name( $this->main_db_name ); + // 1. Get table info. $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' ); $table_info = $this->execute_sqlite_query( @@ -5152,7 +5345,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str AND table_schema = ? AND table_name = ? ", - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetch( PDO::FETCH_ASSOC ); if ( false === $table_info ) { @@ -5172,7 +5365,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 3. Get index info, grouped by index name. @@ -5195,7 +5388,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ", $this->quote_sqlite_identifier( $statistics_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $grouped_constraints = array(); @@ -5213,7 +5406,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name', $this->quote_sqlite_identifier( $referential_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -5225,7 +5418,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL', $this->quote_sqlite_identifier( $key_column_usage_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -5257,7 +5450,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str $this->quote_sqlite_identifier( $table_constraints_table ), $this->quote_sqlite_identifier( $check_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 6. Generate CREATE TABLE statement columns. @@ -5646,6 +5839,18 @@ private function new_not_supported_exception( string $cause ): WP_SQLite_Driver_ ); } + /** + * Create a new access denied exception for the information schema database. + * + * @return WP_SQLite_Driver_Exception + */ + private function new_access_denied_to_information_schema_exception(): WP_SQLite_Driver_Exception { + return $this->new_driver_exception( + "Access denied for user 'root'@'%' to database 'information_schema'", + '42000' + ); + } + /** * Convert an information schema exception to a MySQL-like driver exception. * diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php index cf864597..f07bf65f 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php @@ -481,7 +481,8 @@ public function ensure_temporary_information_schema_tables(): void { * @param WP_Parser_Node $node The "createStatement" AST node with "createTable" child. */ public function record_create_table( WP_Parser_Node $node ): void { - $table_name = $this->get_value( $node->get_first_descendant_node( 'tableName' ) ); + $table_name_node = $node->get_first_descendant_node( 'tableName' ); + $table_name = $this->get_table_name_from_node( $table_name_node ); $table_engine = $this->get_table_engine( $node ); $table_row_format = 'MyISAM' === $table_engine ? 'Fixed' : 'Dynamic'; $table_collation = $this->get_table_collation( $node ); @@ -638,7 +639,8 @@ public function record_create_table( WP_Parser_Node $node ): void { * @param WP_Parser_Node $node The "alterStatement" AST node with "alterTable" child. */ public function record_alter_table( WP_Parser_Node $node ): void { - $table_name = $this->get_value( $node->get_first_descendant_node( 'tableRef' ) ); + $table_ref = $node->get_first_descendant_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $actions = $node->get_descendant_nodes( 'alterListItem' ); // Check if a temporary table with the given name exists. @@ -765,7 +767,7 @@ public function record_drop_table( WP_Parser_Node $node ): void { $table_refs = $child_node->get_first_child_node( 'tableRefList' )->get_child_nodes(); foreach ( $table_refs as $table_ref ) { - $table_name = $this->get_value( $table_ref ); + $table_name = $this->get_table_name_from_node( $table_ref ); $table_is_temporary = $has_temporary_keyword || $this->temporary_table_exists( $table_name ); $this->delete_values( @@ -809,7 +811,8 @@ public function record_drop_table( WP_Parser_Node $node ): void { public function record_create_index( WP_Parser_Node $node ): void { $create_index = $node->get_first_child_node( 'createIndex' ); $target = $create_index->get_first_child_node( 'createIndexTarget' ); - $table_name = $this->get_value( $target->get_first_child_node( 'tableRef' ) ); + $table_ref = $target->get_first_child_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $table_is_temporary = $this->temporary_table_exists( $table_name ); $this->record_add_index( $table_is_temporary, $table_name, $create_index ); @@ -822,7 +825,8 @@ public function record_create_index( WP_Parser_Node $node ): void { */ public function record_drop_index( WP_Parser_Node $node ): void { $drop_index = $node->get_first_child_node( 'dropIndex' ); - $table_name = $this->get_value( $drop_index->get_first_child_node( 'tableRef' ) ); + $table_ref = $drop_index->get_first_child_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $index_name = $this->get_value( $drop_index->get_first_child_node( 'indexRef' ) ); $table_is_temporary = $this->temporary_table_exists( $table_name ); $this->record_drop_index_data( $table_is_temporary, $table_name, $index_name ); @@ -1750,9 +1754,8 @@ private function extract_referential_constraint_data( WP_Parser_Node $node, stri } // Referenced table name. - $referenced_table = $references->get_first_child_node( 'tableRef' ); - $referenced_identifiers = $referenced_table->get_descendant_nodes( 'identifier' ); - $referenced_table_name = $this->get_value( end( $referenced_identifiers ) ); + $referenced_table = $references->get_first_child_node( 'tableRef' ); + $referenced_table_name = $this->get_table_name_from_node( $referenced_table ); // Referenced column names. $reference_parts = $references->get_first_child_node( 'identifierListWithParentheses' ) @@ -1843,7 +1846,7 @@ private function extract_key_column_usage_data( $referenced_table_schema = count( $referenced_identifiers ) > 1 ? $this->get_value( $referenced_identifiers[0] ) : self::SAVED_DATABASE_NAME; - $referenced_table_name = $this->get_value( end( $referenced_identifiers ) ); + $referenced_table_name = $this->get_table_name_from_node( $referenced_table ); $referenced_columns = $references->get_first_child_node( 'identifierListWithParentheses' ) ->get_first_child_node( 'identifierList' ) ->get_child_nodes( 'identifier' ); @@ -1964,6 +1967,23 @@ private function sync_column_key_info( bool $table_is_temporary, string $table_n ); } + /** + * Extract table name from one of fully-qualified name AST nodes. + * + * @param WP_Parser_Node $node The AST node. One of "tableName" or "tableRef". + * @return string The table name. + */ + private function get_table_name_from_node( WP_Parser_Node $node ): string { + if ( 'tableRef' === $node->rule_name || 'tableName' === $node->rule_name ) { + $parts = $node->get_descendant_nodes( 'identifier' ); + return $this->get_value( end( $parts ) ); + } + + throw new Exception( + sprintf( 'Could not get table name from node: %s', $node->rule_name ) + ); + } + /** * Extract table engine value from the "createStatement" AST node. *