Last active
May 22, 2025 11:07
-
-
Save adamziel/dd065f946fdd1722897aced4d47d24be to your computer and use it in GitHub Desktop.
PostgreSQL driver for WordPress
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Can you run WordPress on PostgreSQL? | |
| Not yet, but this Gist gets you closer. This is a PostgreSQL driver | |
| for https://github.com/Automattic/sqlite-database-integration. I've | |
| generated it by asking AI to port the SQLite driver to PostgreSQL driver. | |
| It parses a MySQL query and runs a corresponding PostgreSQL query. Some | |
| queries work, some don't, the escaping is a mess, but it's a good first draft. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * PostgreSQL driver for MySQL. | |
| * | |
| * This class emulates a MySQL database server on top of a PostgreSQL database. | |
| * It translates queries written in MySQL SQL dialect to a PostgreSQL SQL dialect, | |
| * maintains necessary metadata, and executes the translated queries in PostgreSQL. | |
| * | |
| * The driver requires PDO with the PostgreSQL driver (pgsql), and the PCRE engine. | |
| */ | |
| class WP_PostgreSQL_Driver { | |
| /** | |
| * The path to the MySQL SQL grammar file. | |
| * NOTE: This is problematic for a single file solution. | |
| * For this conversion, we assume the grammar is loaded by WP_Parser_Grammar stub. | |
| */ | |
| const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; // This path will likely not exist as-is. | |
| /** | |
| * The minimum required version of PostgreSQL. | |
| * Define a reasonable minimum, e.g., for common features like ON CONFLICT. | |
| */ | |
| const MINIMUM_POSTGRESQL_VERSION = '9.5'; // For INSERT ... ON CONFLICT | |
| /** | |
| * The default timeout in seconds for PostgreSQL. (Conceptual, pgsql driver uses connect_timeout) | |
| */ | |
| const DEFAULT_POSTGRESQL_TIMEOUT = 10; // Connection timeout | |
| /** | |
| * An identifier prefix for internal database objects. | |
| */ | |
| const RESERVED_PREFIX = '_wp_pgsql_'; | |
| /** | |
| * A map of MySQL tokens to PostgreSQL data types. | |
| */ | |
| const DATA_TYPE_MAP = array( | |
| // Numeric data types: | |
| WP_MySQL_Lexer::BIT_SYMBOL => 'BIT', // Or BOOLEAN for BIT(1) | |
| WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', | |
| WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', | |
| WP_MySQL_Lexer::TINYINT_SYMBOL => 'SMALLINT', // PostgreSQL SMALLINT is -32768 to +32767 | |
| WP_MySQL_Lexer::SMALLINT_SYMBOL => 'SMALLINT', | |
| WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'INTEGER', | |
| WP_MySQL_Lexer::INT_SYMBOL => 'INTEGER', | |
| WP_MySQL_Lexer::INTEGER_SYMBOL => 'INTEGER', | |
| WP_MySQL_Lexer::BIGINT_SYMBOL => 'BIGINT', | |
| WP_MySQL_Lexer::FLOAT_SYMBOL => 'REAL', | |
| WP_MySQL_Lexer::DOUBLE_SYMBOL => 'DOUBLE PRECISION', | |
| WP_MySQL_Lexer::REAL_SYMBOL => 'DOUBLE PRECISION', // MySQL REAL is DOUBLE | |
| WP_MySQL_Lexer::DECIMAL_SYMBOL => 'NUMERIC', | |
| WP_MySQL_Lexer::DEC_SYMBOL => 'NUMERIC', | |
| WP_MySQL_Lexer::FIXED_SYMBOL => 'NUMERIC', | |
| WP_MySQL_Lexer::NUMERIC_SYMBOL => 'NUMERIC', | |
| // String data types: | |
| WP_MySQL_Lexer::CHAR_SYMBOL => 'CHAR', // Needs length | |
| WP_MySQL_Lexer::VARCHAR_SYMBOL => 'VARCHAR', // Needs length | |
| WP_MySQL_Lexer::NCHAR_SYMBOL => 'CHAR', // Needs length, PG is Unicode aware | |
| WP_MySQL_Lexer::NVARCHAR_SYMBOL => 'VARCHAR', // Needs length, PG is Unicode aware | |
| WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::TEXT_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::ENUM_SYMBOL => 'TEXT', // Or native ENUM TYPE | |
| // Date and time data types: | |
| WP_MySQL_Lexer::DATE_SYMBOL => 'DATE', | |
| WP_MySQL_Lexer::TIME_SYMBOL => 'TIME WITHOUT TIME ZONE', | |
| WP_MySQL_Lexer::DATETIME_SYMBOL => 'TIMESTAMP WITHOUT TIME ZONE', | |
| WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'TIMESTAMP WITH TIME ZONE', // Stores in UTC | |
| WP_MySQL_Lexer::YEAR_SYMBOL => 'SMALLINT', // Store as number | |
| // Binary data types: | |
| WP_MySQL_Lexer::BINARY_SYMBOL => 'BYTEA', // Needs length or different handling | |
| WP_MySQL_Lexer::VARBINARY_SYMBOL => 'BYTEA', | |
| WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BYTEA', | |
| WP_MySQL_Lexer::BLOB_SYMBOL => 'BYTEA', | |
| WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BYTEA', | |
| WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BYTEA', | |
| // Spatial data types (assuming PostGIS is not used, map to TEXT for storage): | |
| WP_MySQL_Lexer::GEOMETRY_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::POINT_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::LINESTRING_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::POLYGON_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::MULTIPOINT_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::MULTILINESTRING_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::MULTIPOLYGON_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::GEOMCOLLECTION_SYMBOL => 'TEXT', | |
| WP_MySQL_Lexer::GEOMETRYCOLLECTION_SYMBOL => 'TEXT', | |
| // SERIAL (handled in translation), SET (TEXT), and JSON (JSONB) types. | |
| WP_MySQL_Lexer::JSON_SYMBOL => 'JSONB', | |
| WP_MySQL_Lexer::SET_SYMBOL => 'TEXT', // Or TEXT[] | |
| ); | |
| /** | |
| * A map of normalized MySQL data types to PostgreSQL data types. | |
| */ | |
| const DATA_TYPE_STRING_MAP = array( | |
| 'bit' => 'BIT', | |
| 'bool' => 'BOOLEAN', | |
| 'boolean' => 'BOOLEAN', | |
| 'tinyint' => 'SMALLINT', | |
| 'smallint' => 'SMALLINT', | |
| 'mediumint' => 'INTEGER', | |
| 'int' => 'INTEGER', | |
| 'integer' => 'INTEGER', | |
| 'bigint' => 'BIGINT', | |
| 'float' => 'REAL', | |
| 'double' => 'DOUBLE PRECISION', | |
| 'real' => 'DOUBLE PRECISION', | |
| 'decimal' => 'NUMERIC', | |
| 'dec' => 'NUMERIC', | |
| 'fixed' => 'NUMERIC', | |
| 'numeric' => 'NUMERIC', | |
| 'char' => 'CHAR', | |
| 'varchar' => 'VARCHAR', | |
| 'nchar' => 'CHAR', | |
| 'nvarchar' => 'VARCHAR', | |
| 'tinytext' => 'TEXT', | |
| 'text' => 'TEXT', | |
| 'mediumtext' => 'TEXT', | |
| 'longtext' => 'TEXT', | |
| 'enum' => 'TEXT', // Or custom ENUM type | |
| 'set' => 'TEXT', // Or TEXT[] | |
| 'json' => 'JSONB', | |
| 'date' => 'DATE', | |
| 'time' => 'TIME WITHOUT TIME ZONE', | |
| 'datetime' => 'TIMESTAMP WITHOUT TIME ZONE', | |
| 'timestamp' => 'TIMESTAMP WITH TIME ZONE', | |
| 'year' => 'SMALLINT', | |
| 'binary' => 'BYTEA', | |
| 'varbinary' => 'BYTEA', | |
| 'tinyblob' => 'BYTEA', | |
| 'blob' => 'BYTEA', | |
| 'mediumblob' => 'BYTEA', | |
| 'longblob' => 'BYTEA', | |
| 'geometry' => 'TEXT', | |
| 'point' => 'TEXT', | |
| 'linestring' => 'TEXT', | |
| 'polygon' => 'TEXT', | |
| 'multipoint' => 'TEXT', | |
| 'multilinestring' => 'TEXT', | |
| 'multipolygon' => 'TEXT', | |
| 'geomcollection' => 'TEXT', | |
| 'geometrycollection' => 'TEXT', | |
| ); | |
| /** | |
| * A map of MySQL to PostgreSQL date format translation for TO_CHAR. | |
| * MySQL: https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_date-format | |
| * PostgreSQL: https://www.postgresql.org/docs/current/functions-formatting.html | |
| */ | |
| const MYSQL_DATE_FORMAT_TO_PG_TO_CHAR_MAP = array( | |
| '%a' => 'Dy', // Abbreviated weekday name (Sun..Sat) | |
| '%b' => 'Mon', // Abbreviated month name (Jan..Dec) | |
| '%c' => 'FMMM', // Month, numeric (0..12) -> PG FM removes leading zeros/spaces. | |
| '%D' => 'FMDDth', // Day of the month with English suffix (0th, 1st, 2nd, 3rd, …) | |
| '%d' => 'DD', // Day of the month, numeric (01..31) | |
| '%e' => 'FMDD', // Day of the month, numeric (0..31) -> PG FM removes leading zero. | |
| '%f' => 'US', // Microseconds (000000..999999) | |
| '%H' => 'HH24',// Hour (00..23) | |
| '%h' => 'HH12',// Hour (01..12) | |
| '%I' => 'HH12',// Hour (01..12) | |
| '%i' => 'MI', // Minutes, numeric (00..59) | |
| '%j' => 'DDD', // Day of year (001..366) | |
| '%k' => 'FMHH24',// Hour (0..23) -> PG FM removes leading zero. | |
| '%l' => 'FMHH12',// Hour (1..12) -> PG FM removes leading zero. | |
| '%M' => 'Month',// Month name (January..December) | |
| '%m' => 'MM', // Month, numeric (01..12) | |
| '%p' => 'AM', // AM or PM (Note: PG AM/PM is locale dependent, may need upper()) | |
| '%r' => 'HH12:MI:SS AM', // Time, 12-hour (hh:mm:ss followed by AM or PM) | |
| '%S' => 'SS', // Seconds (00..59) | |
| '%s' => 'SS', // Seconds (00..59) | |
| '%T' => 'HH24:MI:SS', // Time, 24-hour (hh:mm:ss) | |
| '%U' => 'WW', // Week (00..53), where Sunday is the first day of the week; WEEK for Start Monday | |
| '%u' => 'WW', // Week (00..53), where Monday is the first day of the week | |
| '%V' => 'IW', // Week (01..53), where Sunday is the first day of the week; used with %X | |
| '%v' => 'IW', // Week (01..53), where Monday is the first day of the week; used with %x | |
| '%W' => 'Day', // Weekday name (Sunday..Saturday) | |
| '%w' => 'D', // Day of the week (0=Sunday..6=Saturday) -> PG D is 1 (Sun) to 7 (Sat) | |
| '%X' => 'IYYY',// Year for the week where Sunday is the first day of the week, numeric, four digits; used with %V | |
| '%x' => 'IYYY',// Year for the week, where Monday is the first day of the week, numeric, four digits; used with %v | |
| '%Y' => 'YYYY',// Year, numeric, four digits | |
| '%y' => 'YY', // Year, numeric (two digits) | |
| // '%%' => '%' // A literal '%' character -> PG TO_CHAR doesn't need this, just use literal % | |
| ); | |
| /** | |
| * A map of MySQL data types to implicit default values for non-strict mode. | |
| * PostgreSQL is generally stricter. Emulation of MySQL's non-strict behavior is complex. | |
| * This map defines what MySQL *would* do. PostgreSQL will often error or use its own defaults. | |
| */ | |
| const DATA_TYPE_IMPLICIT_DEFAULT_MAP = array( | |
| // Numeric data types: | |
| 'bit' => '0', // Or B'0' | |
| 'bool' => 'false', | |
| 'boolean' => 'false', | |
| 'tinyint' => '0', | |
| 'smallint' => '0', | |
| 'mediumint' => '0', | |
| 'int' => '0', | |
| 'integer' => '0', | |
| 'bigint' => '0', | |
| 'float' => '0', | |
| 'double' => '0', | |
| 'real' => '0', | |
| 'decimal' => '0', | |
| 'dec' => '0', | |
| 'fixed' => '0', | |
| 'numeric' => '0', | |
| // String data types: | |
| 'char' => '', | |
| 'varchar' => '', | |
| 'nchar' => '', | |
| 'nvarchar' => '', | |
| 'tinytext' => '', | |
| 'text' => '', | |
| 'mediumtext' => '', | |
| 'longtext' => '', | |
| 'enum' => '', // In MySQL, first enum value if not nullable, '' if nullable. PG: error or ENUM default. | |
| 'set' => '', | |
| 'json' => 'null', // JSON 'null' | |
| // Date and time data types: | |
| 'date' => '0000-00-00', // Invalid in PG, will error. | |
| 'time' => '00:00:00', | |
| 'datetime' => '0000-00-00 00:00:00', // Invalid in PG. | |
| 'timestamp' => '0000-00-00 00:00:00', // Invalid in PG. | |
| 'year' => '0000', // Invalid for SMALLINT. | |
| // Binary data types: | |
| 'binary' => '', // \x | |
| 'varbinary' => '', // \x | |
| 'tinyblob' => '', // \x | |
| 'blob' => '', // \x | |
| 'mediumblob' => '', // \x | |
| 'longblob' => '', // \x | |
| // Spatial data types (no implicit defaults in MySQL): | |
| 'geometry' => null, | |
| // ... | |
| ); | |
| /** | |
| * The PostgreSQL server version. | |
| */ | |
| public $client_info; // Actually server_version for PostgreSQL | |
| /** | |
| * A MySQL query parser grammar. | |
| */ | |
| private static $mysql_grammar; | |
| /** | |
| * The main database name (MySQL concept). In PostgreSQL, this is the connected database. | |
| */ | |
| private $main_db_name; | |
| /** | |
| * The name of the current schema in use (PostgreSQL concept, analogous to MySQL USE db). | |
| * Defaults to 'public'. | |
| */ | |
| private $current_schema_name; | |
| /** | |
| * An instance of the PDO object. | |
| */ | |
| public $pdo; | |
| /** | |
| * Service for managing MySQL INFORMATION_SCHEMA tables emulation in PostgreSQL. | |
| */ | |
| private $information_schema_builder; | |
| /** | |
| * Last executed MySQL query. | |
| */ | |
| private $last_mysql_query; | |
| /** | |
| * A list of PostgreSQL queries executed for the last MySQL query. | |
| */ | |
| private $last_pgsql_queries = array(); | |
| /** | |
| * Results of the last emulated query. | |
| */ | |
| private $last_result; | |
| /** | |
| * Return value of the last emulated query. | |
| */ | |
| private $last_return_value; | |
| /** | |
| * Number of rows found by the last SQL_CALC_FOUND_ROW query. | |
| */ | |
| private $last_sql_calc_found_rows = null; | |
| /** | |
| * Whether the current MySQL query is read-only. | |
| */ | |
| private $is_readonly; | |
| /** | |
| * Transaction nesting level. PostgreSQL supports nested transactions via savepoints. | |
| */ | |
| private $transaction_level = 0; | |
| /** | |
| * The PDO fetch mode used for the emulated query. | |
| */ | |
| private $pdo_fetch_mode; | |
| /** | |
| * The currently active MySQL SQL modes (emulated). | |
| * PostgreSQL does not have SQL modes in the same way. This will be for emulation logic. | |
| */ | |
| private $active_sql_modes = array( | |
| // Defaults for MySQL 8.0 | |
| 'ERROR_FOR_DIVISION_BY_ZERO', // PG default behavior if not dividing by zero literal | |
| 'NO_ENGINE_SUBSTITUTION', // N/A for PG | |
| 'NO_ZERO_DATE', // PG always errors on zero dates | |
| 'NO_ZERO_IN_DATE', // PG always errors on zero in dates | |
| 'ONLY_FULL_GROUP_BY', // PG default behavior | |
| 'STRICT_TRANS_TABLES', // PG is generally strict | |
| ); | |
| /** | |
| * Constructor. | |
| * | |
| * Set up a PostgreSQL connection and the MySQL-on-PostgreSQL driver. | |
| * | |
| * @param array $options { | |
| * An array of options. | |
| * @type string $dsn PDO DSN string for PostgreSQL. | |
| * Example: "pgsql:host=localhost;dbname=mydb" | |
| * @type string $username PostgreSQL username. | |
| * @type string $password PostgreSQL password. | |
| * @type array $pdo_options Optional. Additional PDO options. | |
| * @type string $mysql_database_name The name of the emulated MySQL database (used for schema builder). | |
| * @type string|null $default_schema Optional. Default PostgreSQL schema to use (e.g., 'public'). | |
| * } | |
| * | |
| * @throws WP_PostgreSQL_Driver_Exception When the driver initialization fails. | |
| */ | |
| public function __construct( array $options ) { | |
| if ( ! isset( $options['dsn'] ) ) { | |
| throw $this->new_driver_exception( 'Option "dsn" is required for PostgreSQL connection.' ); | |
| } | |
| if ( ! isset( $options['mysql_database_name'] ) || ! is_string( $options['mysql_database_name'] ) ) { | |
| throw $this->new_driver_exception( 'Option "mysql_database_name" (emulated) is required.' ); | |
| } | |
| $this->main_db_name = $options['mysql_database_name']; // This is the *emulated* MySQL DB name. | |
| $this->current_schema_name = $options['default_schema'] ?? 'public'; | |
| try { | |
| $pdo_options = $options['pdo_options'] ?? array(); | |
| // Default PDO options for PostgreSQL | |
| $pdo_options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION; | |
| $pdo_options[PDO::ATTR_DEFAULT_FETCH_MODE] = PDO::FETCH_ASSOC; // Or as needed | |
| // PostgreSQL returns numbers as numbers by default, stringify if MySQL behavior is strictly needed | |
| // $pdo_options[PDO::ATTR_STRINGIFY_FETCHES] = true; | |
| $this->pdo = new PDO( | |
| $options['dsn'], | |
| $options['username'] ?? null, | |
| $options['password'] ?? null, | |
| $pdo_options | |
| ); | |
| } catch ( PDOException $e ) { | |
| $code = $e->getCode(); | |
| throw $this->new_driver_exception( $e->getMessage(), is_int( $code ) ? $code : 0, $e ); | |
| } | |
| // Check the PostgreSQL version. | |
| $pgsql_version = $this->get_postgresql_version(); | |
| $this->client_info = $pgsql_version; // Store server version | |
| // Set default schema path | |
| $this->execute_pgsql_query( 'SET search_path TO ' . $this->quote_pgsql_identifier($this->current_schema_name) . ', public' ); | |
| // Register PostgreSQL specific functions if any were needed (less common than SQLite UDFs from PHP) | |
| // WP_PostgreSQL_PDO_User_Defined_Functions::register_for( $this->pdo ); // Unlikely needed for PG | |
| // Load MySQL grammar (using stubs as per problem description) | |
| if ( null === self::$mysql_grammar ) { | |
| self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH ); | |
| } | |
| // Initialize information schema builder | |
| $this->information_schema_builder = new WP_PostgreSQL_Information_Schema_Builder( | |
| $this->pdo, | |
| $this->main_db_name, // Emulated MySQL DB name | |
| $this->current_schema_name, // Actual PostgreSQL schema | |
| self::RESERVED_PREFIX, | |
| array( $this, 'execute_pgsql_query' ) | |
| ); | |
| $this->information_schema_builder->ensure_information_schema_tables(); | |
| } | |
| public function get_pdo(): PDO { | |
| return $this->pdo; | |
| } | |
| public function get_postgresql_version(): string { | |
| return $this->pdo->query( 'SELECT version()' )->fetchColumn(); | |
| } | |
| public function is_sql_mode_active( string $mode ): bool { | |
| // PostgreSQL doesn't have direct SQL modes like MySQL. Emulation would be complex. | |
| // This function would check against $this->active_sql_modes for emulated behavior. | |
| return in_array( strtoupper( $mode ), $this->active_sql_modes, true ); | |
| } | |
| public function get_last_mysql_query(): ?string { | |
| return $this->last_mysql_query; | |
| } | |
| public function get_last_pgsql_queries(): array { | |
| return $this->last_pgsql_queries; | |
| } | |
| /** | |
| * Get the auto-increment value generated for the last query. | |
| * For PostgreSQL, this often requires specifying the sequence name. | |
| * | |
| * @param string|null $sequence_name The name of the sequence (optional, but often needed for PostgreSQL). | |
| * @return int|string | |
| */ | |
| public function get_insert_id(?string $sequence_name = null) { | |
| // If insert query used RETURNING id, it would be in $this->last_result | |
| // Otherwise, use PDO::lastInsertId with sequence name. | |
| // Sequence name is typically table_column_seq, e.g. users_id_seq | |
| // This needs to be determined or passed. | |
| try { | |
| $last_insert_id = $this->pdo->lastInsertId($sequence_name); | |
| if (is_numeric($last_insert_id)) { | |
| return (int) $last_insert_id; | |
| } | |
| return $last_insert_id; | |
| } catch (PDOException $e) { | |
| // lastInsertId() might fail if sequence name is wrong or not an auto-incrementing insert. | |
| // Or if `RETURNING` was used and this method is called unnecessarily. | |
| // Check $this->last_return_value if it contains the ID from RETURNING. | |
| if (is_array($this->last_return_value) && isset($this->last_return_value[0]['id'])) { // Assuming 'id' is the common name | |
| return (int) $this->last_return_value[0]['id']; | |
| } | |
| return 0; // Or throw error | |
| } | |
| } | |
| public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { | |
| $this->flush(); | |
| $this->pdo_fetch_mode = $fetch_mode; // Note: PDOStatement::fetchAll can override this | |
| $this->last_mysql_query = $query; | |
| try { | |
| // Parse the MySQL query (using stubs, so this part is mostly conceptual here) | |
| $lexer = new WP_MySQL_Lexer( $query ); | |
| $tokens = $lexer->remaining_tokens(); | |
| $parser = new WP_MySQL_Parser( self::$mysql_grammar, $tokens ); | |
| $ast = $parser->parse(); | |
| if ( null === $ast ) { | |
| // If stubs are used, AST will be a dummy. This check would fail for real input. | |
| // For this conversion, assume AST generation is successful for structure. | |
| // throw $this->new_driver_exception( 'Failed to parse the MySQL query.' ); | |
| } | |
| // Handle transaction commands | |
| $child = $ast->get_first_child(); | |
| if ( $child instanceof WP_Parser_Node && 'beginWork' === $child->rule_name ) { | |
| $this->begin_transaction(); | |
| return true; | |
| } | |
| if ( $child instanceof WP_Parser_Node && 'simpleStatement' === $child->rule_name ) { | |
| $subchild = $child->get_first_child_node( 'transactionOrLockingStatement' ); | |
| if ( null !== $subchild ) { | |
| $tokens = $subchild->get_descendant_tokens(); | |
| $token1 = $tokens[0] ?? null; | |
| $token2 = $tokens[1] ?? null; | |
| if ( $token1 && WP_MySQL_Lexer::START_SYMBOL === $token1->id && | |
| $token2 && WP_MySQL_Lexer::TRANSACTION_SYMBOL === $token2->id ) { | |
| $this->begin_transaction(); | |
| return true; | |
| } | |
| if ( $token1 && WP_MySQL_Lexer::BEGIN_SYMBOL === $token1->id ) { | |
| $this->begin_transaction(); | |
| return true; | |
| } | |
| if ( $token1 && WP_MySQL_Lexer::COMMIT_SYMBOL === $token1->id ) { | |
| $this->commit(); | |
| return true; | |
| } | |
| if ( $token1 && WP_MySQL_Lexer::ROLLBACK_SYMBOL === $token1->id ) { | |
| $this->rollback(); | |
| return true; | |
| } | |
| } | |
| } | |
| // Perform all the queries in a nested transaction (PostgreSQL handles this with savepoints) | |
| $this->begin_transaction(); | |
| $this->execute_mysql_query( $ast ); // This will translate and execute | |
| $this->commit(); | |
| return $this->last_return_value; | |
| } catch ( Throwable $e ) { | |
| try { | |
| $this->rollback(); | |
| } catch ( Throwable $rollback_exception ) { | |
| // Ignore rollback errors if the main error is more important | |
| } | |
| $code = $e->getCode(); | |
| throw $this->new_driver_exception( $e->getMessage(), is_string($code) ? $code : 0, $e ); // SQLSTATE can be string | |
| } | |
| } | |
| public function get_query_results() { | |
| return $this->last_result; | |
| } | |
| public function get_last_return_value() { | |
| return $this->last_return_value; | |
| } | |
| public function execute_pgsql_query( string $sql, array $params = array() ): PDOStatement { | |
| $this->last_pgsql_queries[] = array( | |
| 'sql' => $sql, | |
| 'params' => $params, | |
| ); | |
| $stmt = $this->pdo->prepare( $sql ); | |
| $stmt->execute( $params ); | |
| return $stmt; | |
| } | |
| public function begin_transaction(): void { | |
| if ( 0 === $this->transaction_level ) { | |
| $this->pdo->beginTransaction(); | |
| } else { | |
| $this->execute_pgsql_query( 'SAVEPOINT LEVEL' . $this->transaction_level ); | |
| } | |
| ++$this->transaction_level; | |
| } | |
| public function commit(): void { | |
| if ( 0 === $this->transaction_level ) { | |
| return; // Or throw error: no transaction active | |
| } | |
| --$this->transaction_level; | |
| if ( 0 === $this->transaction_level ) { | |
| $this->pdo->commit(); | |
| } else { | |
| $this->execute_pgsql_query( 'RELEASE SAVEPOINT LEVEL' . $this->transaction_level ); | |
| } | |
| } | |
| public function rollback(): void { | |
| if ( 0 === $this->transaction_level ) { | |
| return; // Or throw error: no transaction active | |
| } | |
| --$this->transaction_level; | |
| if ( 0 === $this->transaction_level ) { | |
| if ($this->pdo->inTransaction()) { // Check if transaction is actually active | |
| $this->pdo->rollBack(); | |
| } | |
| } else { | |
| // Only rollback to savepoint if transaction is still active at PDO level | |
| if ($this->pdo->inTransaction()) { | |
| $this->execute_pgsql_query( 'ROLLBACK TO SAVEPOINT LEVEL' . $this->transaction_level ); | |
| } | |
| } | |
| } | |
| private function execute_mysql_query( WP_Parser_Node $node ): void { | |
| // This method structure remains similar, but implementations of execute_..._statement will change. | |
| if ( 'query' !== $node->rule_name ) { // Assuming 'query' is the top-level rule name from parser | |
| throw $this->new_driver_exception( | |
| sprintf( 'Expected "query" node, got: "%s"', $node->rule_name ) | |
| ); | |
| } | |
| $children = $node->get_child_nodes(); | |
| // The stub parser might not produce children correctly. This logic depends on real AST. | |
| if (empty($children)) { // Adaptation for stub | |
| // If AST is a stub, we can't proceed with rule-based dispatch. | |
| // This indicates the limitation of not having the actual parser. | |
| // We might try to guess based on raw query for very simple cases, or just fail. | |
| // For this conversion, we'll assume the structure allows dispatch. | |
| // If $node from stub parser is just a wrapper, get its 'simpleStatement' if that's how stub is made. | |
| $simple_statement_node = $node->get_first_child_node('simpleStatement'); | |
| if (!$simple_statement_node) { | |
| // Attempt to infer from query string for very basic commands if AST is too minimal | |
| // This is a HACK due to parser stub. | |
| if (preg_match('/^\s*SELECT/i', $this->last_mysql_query)) { | |
| $this->is_readonly = true; | |
| // Cannot call execute_select_statement without a proper AST node. | |
| // Fallback: try to translate the whole query string if possible. | |
| $query = $this->translate_raw_query($this->last_mysql_query); | |
| $stmt = $this->execute_pgsql_query($query); | |
| $this->set_results_from_fetched_data($stmt->fetchAll($this->pdo_fetch_mode)); | |
| return; | |
| } | |
| throw $this->new_driver_exception('Cannot process query due to parser stub limitations or invalid AST structure.'); | |
| } | |
| $node_to_process = $simple_statement_node->get_first_child_node(); | |
| } else { | |
| // Original logic assuming real AST | |
| if ( count( $children ) !== 1 ) { // MySQL grammar might differ from SQLite one used before. | |
| // This might need adjustment based on actual MySQL grammar structure. | |
| // For now, assume similar top-level structure. | |
| } | |
| if ( 'simpleStatement' !== $children[0]->rule_name ) { | |
| throw $this->new_driver_exception( | |
| sprintf( 'Expected "simpleStatement" node, got: "%s"', $children[0]->rule_name ) | |
| ); | |
| } | |
| $node_to_process = $children[0]->get_first_child_node(); | |
| } | |
| if (!$node_to_process) { | |
| // Handle cases where the AST structure from the stub might not match expectations | |
| throw $this->new_driver_exception( 'Invalid AST structure for "simpleStatement".' ); | |
| } | |
| switch ( $node_to_process->rule_name ) { | |
| case 'selectStatement': | |
| $this->is_readonly = true; | |
| $this->execute_select_statement( $node_to_process ); | |
| break; | |
| case 'insertStatement': | |
| $this->execute_insert_statement( $node_to_process ); // แยก replace ออก | |
| break; | |
| case 'replaceStatement': // MySQL REPLACE is tricky. PG: INSERT ON CONFLICT or DELETE+INSERT | |
| $this->execute_replace_statement( $node_to_process ); | |
| break; | |
| case 'updateStatement': | |
| $this->execute_update_statement( $node_to_process ); | |
| break; | |
| case 'deleteStatement': | |
| $this->execute_delete_statement( $node_to_process ); | |
| break; | |
| case 'createStatement': | |
| $subtree = $node_to_process->get_first_child_node(); | |
| if (!$subtree) break; // Stub protection | |
| switch ( $subtree->rule_name ) { | |
| case 'createDatabase': | |
| // PG: CREATE DATABASE. But connections are per-DB. This might mean creating a new schema. | |
| // Or, it's a no-op if we consider the current connection the "database". | |
| $this->execute_create_database_statement( $subtree ); | |
| break; | |
| case 'createTable': | |
| $this->execute_create_table_statement( $node_to_process ); // Pass $node_to_process or $subtree | |
| break; | |
| case 'createIndex': | |
| $this->execute_create_index_statement( $subtree ); | |
| break; | |
| default: | |
| throw $this->new_not_supported_exception( | |
| sprintf( 'statement type: "%s" > "%s"', $node_to_process->rule_name, $subtree->rule_name ) | |
| ); | |
| } | |
| break; | |
| case 'alterStatement': | |
| $subtree = $node_to_process->get_first_child_node(); | |
| if (!$subtree) break; // Stub protection | |
| switch ( $subtree->rule_name ) { | |
| case 'alterTable': | |
| $this->execute_alter_table_statement( $node_to_process ); // Pass $node_to_process or $subtree | |
| break; | |
| default: | |
| throw $this->new_not_supported_exception( | |
| sprintf( 'statement type: "%s" > "%s"', $node_to_process->rule_name, $subtree->rule_name ) | |
| ); | |
| } | |
| break; | |
| case 'dropStatement': | |
| $subtree = $node_to_process->get_first_child_node(); | |
| if (!$subtree) break; // Stub protection | |
| switch ( $subtree->rule_name ) { | |
| case 'dropDatabase': | |
| $this->execute_drop_database_statement($subtree); | |
| break; | |
| case 'dropTable': | |
| $this->execute_drop_table_statement( $node_to_process ); // Pass $node_to_process or $subtree | |
| break; | |
| case 'dropIndex': | |
| $this->execute_drop_index_statement($subtree); | |
| break; | |
| default: // Generic fallback (less likely to work perfectly) | |
| $query = $this->translate( $node_to_process ); | |
| $this->execute_pgsql_query( $query ); | |
| $this->set_result_from_affected_rows(); // May not be accurate for all DDL | |
| } | |
| break; | |
| case 'truncateTableStatement': | |
| $this->execute_truncate_table_statement( $node_to_process ); | |
| break; | |
| case 'setStatement': | |
| $this->execute_set_statement( $node_to_process ); | |
| break; | |
| case 'showStatement': | |
| $this->is_readonly = true; | |
| $this->execute_show_statement( $node_to_process ); | |
| break; | |
| case 'utilityStatement': | |
| $subtree = $node_to_process->get_first_child_node(); | |
| if (!$subtree) break; // Stub protection | |
| switch ( $subtree->rule_name ) { | |
| case 'describeStatement': // DESCRIBE table; | |
| $this->is_readonly = true; | |
| $this->execute_describe_statement( $subtree ); | |
| break; | |
| case 'useCommand': // USE database; | |
| $this->execute_use_statement( $subtree ); | |
| break; | |
| default: | |
| throw $this->new_not_supported_exception( | |
| sprintf( 'statement type: "%s" > "%s"', $node_to_process->rule_name, $subtree->rule_name ) | |
| ); | |
| } | |
| break; | |
| case 'tableAdministrationStatement': // ANALYZE, CHECK, OPTIMIZE, REPAIR | |
| $this->execute_administration_statement( $node_to_process ); | |
| break; | |
| default: | |
| throw $this->new_not_supported_exception( | |
| sprintf( 'statement type: "%s"', $node_to_process->rule_name ) | |
| ); | |
| } | |
| } | |
| // Translate a raw query string - basic keyword replacement for use with parser stubs | |
| private function translate_raw_query(string $mysql_query): string { | |
| $pgsql_query = $mysql_query; | |
| // Basic backtick to double quote for identifiers | |
| $pgsql_query = preg_replace_callback('/`([^`]+)`/', function($matches) { | |
| return '"' . str_replace('""', '"', $matches[1]) . '"'; | |
| }, $pgsql_query); | |
| // Add more simple, global replacements here if necessary for stubbed parsing | |
| // e.g., NOW() -> CURRENT_TIMESTAMP (though PG supports NOW()) | |
| return $pgsql_query; | |
| } | |
| private function execute_select_statement( WP_Parser_Node $node ): void { | |
| $query_expression_node = $node->get_first_child_node('queryExpression'); | |
| if (!$query_expression_node) { // Stub protection | |
| // Fallback for stub, try to translate the whole node | |
| $query = $this->translate($node); | |
| $stmt = $this->execute_pgsql_query($query); | |
| $this->set_results_from_fetched_data($stmt->fetchAll($this->pdo_fetch_mode)); | |
| return; | |
| } | |
| $has_sql_calc_found_rows = null !== $node->get_first_descendant_token( | |
| WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL | |
| ); | |
| if ( $has_sql_calc_found_rows ) { | |
| // For PostgreSQL, use a window function COUNT(*) OVER() | |
| // This requires modifying the SELECT list or using a CTE. | |
| // Example: SELECT ... COUNT(*) OVER() AS total_rows ... | |
| // The translation logic needs to inject this. | |
| // For simplicity here, we'll do a separate count query, similar to original fallback. | |
| // Find the main query body without LIMIT to count total rows | |
| $count_expr_node = clone $query_expression_node; // Need a deep clone | |
| // Remove LIMIT clause from $count_expr_node (complex AST manipulation) | |
| // This is highly dependent on the parser's AST structure. | |
| // For now, assume $this->translate can handle removing limit if a flag is set, or do it manually. | |
| // A simpler approach: generate SQL for count and then for limited results. | |
| $count_query_ast_parts = []; | |
| foreach($query_expression_node->get_children() as $child) { | |
| if ($child instanceof WP_Parser_Node && $child->rule_name === 'limitClause') { | |
| continue; | |
| } | |
| $count_query_ast_parts[] = $child; | |
| } | |
| $count_query_sql_fragment = $this->translate_sequence($count_query_ast_parts); | |
| $count_query = 'SELECT COUNT(*) AS cnt FROM (' . $count_query_sql_fragment . ') AS subquery_for_count'; | |
| $result = $this->execute_pgsql_query( $count_query ); | |
| $this->last_sql_calc_found_rows = (int) $result->fetchColumn(); | |
| // Now translate the original query with LIMIT | |
| $query = $this->translate( $query_expression_node ); | |
| } else { | |
| $this->last_sql_calc_found_rows = null; | |
| $query = $this->translate( $query_expression_node ); | |
| } | |
| $stmt = $this->execute_pgsql_query( $query ); | |
| $this->set_results_from_fetched_data( | |
| $stmt->fetchAll( $this->pdo_fetch_mode ) // Fetch mode can be passed here too | |
| ); | |
| } | |
| private function execute_insert_statement( WP_Parser_Node $node ): void { | |
| // MySQL: INSERT [IGNORE] INTO tbl [(cols)] VALUES (...) | SELECT ... [ON DUPLICATE KEY UPDATE ...] | |
| // PG: INSERT INTO tbl [(cols)] VALUES (...) | SELECT ... [ON CONFLICT ... DO NOTHING | DO UPDATE SET ...] | |
| $is_ignore = null !== $node->get_first_descendant_token( WP_MySQL_Lexer::IGNORE_SYMBOL ); | |
| $on_duplicate_key_update_node = $node->get_first_descendant_node('insertUpdateList'); // MySQL's ON DUPLICATE... | |
| $parts = []; | |
| $table_ref_node = $node->get_first_child_node('tableRef'); | |
| if (!$table_ref_node) { /* error or stub issue */ return; } | |
| $parts[] = "INSERT INTO"; | |
| $parts[] = $this->translate($table_ref_node); | |
| $columns_node = $node->get_first_child_node('uidList') ?? $node->get_first_child_node('columns'); // Adjust based on grammar for column list | |
| if ($columns_node) { | |
| $parts[] = $this->translate($columns_node); | |
| } | |
| // VALUES or SELECT part | |
| $insert_source_node = $node->get_first_child_node('insertFromConstructor'); // For VALUES | |
| if (!$insert_source_node) { | |
| $insert_source_node = $node->get_first_child_node('insertQueryExpression'); // For SELECT | |
| } | |
| if ($insert_source_node) { | |
| $parts[] = $this->translate($insert_source_node); | |
| } else { | |
| throw $this->new_driver_exception("Unsupported INSERT statement structure."); | |
| } | |
| if ($is_ignore) { | |
| $parts[] = "ON CONFLICT DO NOTHING"; | |
| } elseif ($on_duplicate_key_update_node) { | |
| // Need to identify conflict target (PRIMARY KEY or UNIQUE index columns) | |
| // This requires schema introspection. | |
| // $conflict_target = "ON CONFLICT (pk_column_or_unique_index_columns)"; // Placeholder | |
| // For simplicity, assume a general conflict if target is not easily determined. | |
| // $parts[] = "ON CONFLICT ON CONSTRAINT constraint_name"; // More robust | |
| // $parts[] = "ON CONFLICT (col1, col2) WHERE index_predicate"; // For partial indexes | |
| // A common case is conflict on primary key. | |
| $table_name_str = $this->unquote_pgsql_identifier($this->translate($table_ref_node)); | |
| $pk_cols = $this->information_schema_builder->get_primary_key_columns($table_name_str); | |
| if (!empty($pk_cols)) { | |
| $conflict_target_cols = array_map([$this, 'quote_pgsql_identifier'], $pk_cols); | |
| $parts[] = "ON CONFLICT (" . implode(', ', $conflict_target_cols) . ") DO UPDATE SET"; | |
| } else { | |
| // Fallback if PK not found or complex unique index. This might fail. | |
| // Consider querying for unique indexes if PK is not the only conflict source. | |
| // For a generic solution, schema introspection is vital. | |
| // Without it, ON CONFLICT (without target) might work if there's only one arbiter index. | |
| // This part is hard to make universally correct without full schema awareness at translation time. | |
| // Let's assume for now we can find PK or a simple unique index for conflict. | |
| // If not, the query might need manual adjustment or this part improved. | |
| $parts[] = "ON CONFLICT DO UPDATE SET"; // This is less specific and relies on PG finding an arbiter. | |
| } | |
| $update_list_node = $on_duplicate_key_update_node->get_first_child_node('updateList'); | |
| if ($update_list_node) { | |
| // MySQL's VALUES(col) becomes EXCLUDED.col in PostgreSQL | |
| $translated_update_list = $this->translate_update_list_for_conflict($update_list_node); | |
| $parts[] = $translated_update_list; | |
| } else { | |
| throw $this->new_driver_exception("ON DUPLICATE KEY UPDATE clause is missing update assignments."); | |
| } | |
| } | |
| // Add RETURNING for lastInsertId if it's an auto-increment table. | |
| // This also requires schema introspection to find the serial/identity column. | |
| // Example: $id_column = $this->information_schema_builder->get_identity_column($table_name_str); | |
| // if ($id_column) { $parts[] = "RETURNING " . $this->quote_pgsql_identifier($id_column); } | |
| $query = implode( ' ', $parts ); | |
| $stmt = $this->execute_pgsql_query( $query ); | |
| // If RETURNING was used, fetch the ID. | |
| // if ($id_column) { $this->last_return_value = $stmt->fetchAll(PDO::FETCH_ASSOC); } | |
| $this->set_result_from_affected_rows( $stmt->rowCount() ); | |
| } | |
| private function execute_replace_statement( WP_Parser_Node $node ): void { | |
| // MySQL REPLACE INTO: | |
| // 1. If row exists (PK/UNIQUE conflict): DELETE old row, INSERT new row. | |
| // 2. If row does not exist: INSERT new row. | |
| // PostgreSQL: Can be emulated with ON CONFLICT (key) DO UPDATE or separate DELETE + INSERT. | |
| // ON CONFLICT DO UPDATE is often preferred if applicable. | |
| // The assignments in DO UPDATE must cover all columns to mimic REPLACE. | |
| $table_ref_node = $node->get_first_child_node('tableRef'); | |
| if (!$table_ref_node) { /* error */ return; } | |
| $table_name_str = $this->unquote_pgsql_identifier($this->translate($table_ref_node)); | |
| $parts = []; | |
| $parts[] = "INSERT INTO"; | |
| $parts[] = $this->translate($table_ref_node); | |
| $columns_node = $node->get_first_child_node('uidList') ?? $node->get_first_child_node('columns'); | |
| $column_names_for_set = []; | |
| if ($columns_node) { | |
| $parts[] = $this->translate($columns_node); | |
| // Extract column names for the SET clause of ON CONFLICT DO UPDATE | |
| foreach($columns_node->get_descendant_nodes('identifier') as $ident_node) { // Assuming 'identifier' is the rule for column names | |
| $column_names_for_set[] = $this->unquote_pgsql_identifier($this->translate($ident_node)); | |
| } | |
| } | |
| $insert_source_node = $node->get_first_child_node('insertFromConstructor'); | |
| if (!$insert_source_node) { | |
| $insert_source_node = $node->get_first_child_node('insertQueryExpression'); | |
| } | |
| if ($insert_source_node) { | |
| $parts[] = $this->translate($insert_source_node); | |
| } else { | |
| throw $this->new_driver_exception("Unsupported REPLACE statement structure."); | |
| } | |
| // Determine conflict target (PK or UNIQUE index columns) | |
| $pk_cols = $this->information_schema_builder->get_primary_key_columns($table_name_str); | |
| // Also consider other unique constraints if necessary | |
| // $unique_constraints = $this->information_schema_builder->get_unique_constraints($table_name_str); | |
| if (empty($pk_cols)) { | |
| // REPLACE needs a unique key to function. If none, it's like INSERT. | |
| // Or throw error as MySQL REPLACE relies on this. | |
| throw $this->new_driver_exception("REPLACE INTO requires a PRIMARY or UNIQUE KEY on table '$table_name_str'."); | |
| } | |
| $conflict_target_cols_str = implode(', ', array_map([$this, 'quote_pgsql_identifier'], $pk_cols)); | |
| $parts[] = "ON CONFLICT (" . $conflict_target_cols_str . ") DO UPDATE SET"; | |
| // Construct SET assignments for DO UPDATE. All columns should be updated. | |
| // If $columns_node was not present, we need to get all table columns from schema. | |
| if (empty($column_names_for_set)) { | |
| $all_table_columns = $this->information_schema_builder->get_table_columns($table_name_str); | |
| // Exclude PK columns from SET if they are not meant to be updated (usually they are part of conflict target) | |
| // However, REPLACE effectively updates all columns from the new row. | |
| $column_names_for_set = $all_table_columns; | |
| } | |
| $set_clauses = []; | |
| foreach($column_names_for_set as $col_name) { | |
| // Don't update PK columns themselves in SET clause if they are part of conflict target (this is implicit) | |
| // However, for REPLACE, all non-key columns are updated to values from the new row. | |
| // if (in_array($col_name, $pk_cols)) continue; // Might be too restrictive for REPLACE | |
| $quoted_col = $this->quote_pgsql_identifier($col_name); | |
| $set_clauses[] = $quoted_col . " = EXCLUDED." . $quoted_col; | |
| } | |
| if (empty($set_clauses)) { | |
| // This case means only PK columns, which doesn't make sense for update. | |
| // If all columns are part of PK, effectively ON CONFLICT DO NOTHING (or error). | |
| // MySQL's REPLACE might behave differently. A true REPLACE might mean DELETE then INSERT. | |
| // For now, if SET is empty, it might be an issue. | |
| // A common pattern for "replace all" is: | |
| // ON CONFLICT (pk_col) DO UPDATE SET col1 = EXCLUDED.col1, col2 = EXCLUDED.col2, ... | |
| // where col1, col2 are all non-PK columns. | |
| // If only PK columns are provided in INSERT, then there's nothing to update. | |
| // MySQL's REPLACE would delete then insert the new row (with defaults for non-specified cols). | |
| // This simple ON CONFLICT DO UPDATE SET might not fully emulate all nuances. | |
| throw $this->new_driver_exception("Cannot form UPDATE SET clause for REPLACE on table '$table_name_str'. All columns might be part of the key."); | |
| } | |
| $parts[] = implode(', ', $set_clauses); | |
| $query = implode( ' ', $parts ); | |
| $stmt = $this->execute_pgsql_query( $query ); | |
| $this->set_result_from_affected_rows( $stmt->rowCount() ); // rowCount for INSERT ON CONFLICT DO UPDATE can be 1 for insert, 1 for update. MySQL REPLACE reports 1 for insert, 2 for replace (delete+insert). | |
| } | |
| private function translate_update_list_for_conflict(WP_Parser_Node $update_list_node): string { | |
| $assignments = []; | |
| foreach ($update_list_node->get_child_nodes('updateElement') as $element_node) { | |
| $column_ref_node = $element_node->get_first_child_node('columnRef'); | |
| $expr_node = $element_node->get_first_child_node('expr'); | |
| if (!$column_ref_node || !$expr_node) continue; | |
| $column_name = $this->translate($column_ref_node); // Already quoted | |
| // Check if expr_node is VALUES(col) | |
| $simple_expr_node = $expr_node->get_first_child_node('simpleExpr'); | |
| $is_values_func = false; | |
| if ($simple_expr_node) { | |
| $func_call_node = $simple_expr_node->get_first_child_node('functionCall'); | |
| if ($func_call_node) { | |
| $func_name_node = $func_call_node->get_first_child_node(); // Assuming function name identifier | |
| if ($func_name_node && strtoupper($this->unquote_pgsql_identifier($this->translate($func_name_node))) === 'VALUES') { | |
| $arg_node = $func_call_node->get_first_child_node('exprList')->get_first_child_node(); // Assuming one arg | |
| $value_column_name = $this->translate($arg_node); // This is the col name inside VALUES() | |
| $assignments[] = $column_name . ' = EXCLUDED.' . $value_column_name; | |
| $is_values_func = true; | |
| } | |
| } | |
| } | |
| if (!$is_values_func) { | |
| $assignments[] = $column_name . ' = ' . $this->translate($expr_node); | |
| } | |
| } | |
| return implode(', ', $assignments); | |
| } | |
| private function execute_update_statement( WP_Parser_Node $node ): void { | |
| // MySQL: UPDATE [IGNORE] tbl_references SET assignments [WHERE ...] [ORDER BY ...] [LIMIT ...] | |
| // PG: UPDATE tbl SET assignments [FROM ...] [WHERE ...] | |
| // PG UPDATE does not support ORDER BY or LIMIT directly. | |
| // For UPDATE with LIMIT, use a subquery with ctid: | |
| // UPDATE tbl SET ... WHERE ctid IN (SELECT ctid FROM tbl WHERE ... ORDER BY ... LIMIT ...); | |
| $has_order = $node->has_child_node( 'orderClause' ); | |
| $has_limit = $node->has_child_node( 'simpleLimitClause' ); // MySQL specific rule name | |
| $parts = []; | |
| $parts[] = "UPDATE"; | |
| // Handle IGNORE - PG has no direct equivalent. May need complex error handling or be unsupported. | |
| if ($node->has_child_token(WP_MySQL_Lexer::IGNORE_SYMBOL)) { | |
| // This is hard to emulate perfectly. It means ignore errors like constraint violations. | |
| // Could try to catch specific exceptions, but that's outside simple translation. | |
| // For now, we can't directly translate IGNORE for UPDATE. | |
| // Perhaps a plpgsql block with exception handling. | |
| throw $this->new_not_supported_exception("UPDATE IGNORE"); | |
| } | |
| $table_refs_node = $node->get_first_child_node('tableReferenceList'); // Or single tableReference | |
| if (!$table_refs_node) { /* error */ return; } | |
| // PG UPDATE syntax for multiple tables is different (uses FROM clause or CTEs) | |
| // MySQL: UPDATE t1, t2 SET t1.c1 = ..., t2.c2 = ... WHERE ... | |
| // This translation will focus on single-table updates first. Multi-table is complex. | |
| $single_table_node = $table_refs_node->get_first_child_node('tableReference'); // Assuming single table for now | |
| if (!$single_table_node) { /* error */ return; } | |
| $table_name_translated = $this->translate($single_table_node); | |
| $parts[] = $table_name_translated; | |
| $parts[] = "SET"; | |
| $update_list_node = $node->get_first_child_node('updateList'); | |
| if (!$update_list_node) { /* error */ return; } | |
| $parts[] = $this->translate($update_list_node); | |
| $where_clause_node = $node->get_first_child_node('whereClause'); | |
| if ($has_limit || $has_order) { | |
| // Use subquery for ctid (or primary key if table has no ctid/is partitioned) | |
| // Assuming ctid is available and table is not partitioned in a way that breaks ctid uniqueness for the operation. | |
| // A more robust way is to use the primary key. | |
| $pk_cols = $this->information_schema_builder->get_primary_key_columns($this->unquote_pgsql_identifier($table_name_translated)); | |
| if (empty($pk_cols)) { | |
| throw $this->new_driver_exception("UPDATE with ORDER BY/LIMIT requires a PRIMARY KEY on table for reliable emulation."); | |
| } | |
| $pk_col_quoted = $this->quote_pgsql_identifier($pk_cols[0]); // Assuming single col PK for simplicity | |
| $sub_select_parts = []; | |
| $sub_select_parts[] = "SELECT " . $pk_col_quoted; // Or ctid | |
| $sub_select_parts[] = "FROM " . $table_name_translated; | |
| if ($where_clause_node) { | |
| $sub_select_parts[] = $this->translate($where_clause_node); | |
| } | |
| if ($has_order) { | |
| $order_clause_node = $node->get_first_child_node('orderClause'); | |
| $sub_select_parts[] = $this->translate($order_clause_node); | |
| } | |
| if ($has_limit) { | |
| $limit_clause_node = $node->get_first_child_node('simpleLimitClause'); // Or 'limitClause' | |
| $sub_select_parts[] = $this->translate($limit_clause_node); | |
| } | |
| $parts[] = "WHERE " . $pk_col_quoted . " IN (" . implode(' ', $sub_select_parts) . ")"; | |
| } else { | |
| if ($where_clause_node) { | |
| $parts[] = $this->translate($where_clause_node); | |
| } | |
| } | |
| $query = implode(' ', $parts); | |
| $stmt = $this->execute_pgsql_query($query); | |
| $this->set_result_from_affected_rows($stmt->rowCount()); | |
| } | |
| private function execute_delete_statement( WP_Parser_Node $node ): void { | |
| // MySQL: DELETE [IGNORE] [alias.*] FROM tbl_references [WHERE ...] [ORDER BY ...] [LIMIT ...] | |
| // PG: DELETE FROM tbl [USING using_list] [WHERE ...] | |
| // PG DELETE does not support ORDER BY or LIMIT directly. Subquery on ctid/PK needed. | |
| // Multi-table delete in MySQL: DELETE t1, t2 FROM t1 JOIN t2 ... | |
| // PG multi-table delete: DELETE FROM t1 USING t2 WHERE t1.id = t2.t1_id ... | |
| $parts = []; | |
| $parts[] = "DELETE FROM"; | |
| if ($node->has_child_token(WP_MySQL_Lexer::IGNORE_SYMBOL)) { | |
| throw $this->new_not_supported_exception("DELETE IGNORE"); | |
| } | |
| $table_alias_ref_list_node = $node->get_first_child_node('tableAliasRefList'); // For DELETE t1, t2 FROM ... | |
| $table_name_node = $node->get_first_child_node('tableName'); // For DELETE FROM tbl | |
| $table_reference_list_node = $node->get_first_child_node('tableReferenceList'); // For DELETE FROM tbl USING ... | |
| $target_table_translated = ""; | |
| if ($table_alias_ref_list_node) { | |
| // Multi-table delete: DELETE t1 FROM t1 JOIN t2 ... | |
| // We need to identify the actual target table from the alias list. | |
| // This is complex. Assuming only one alias for now for simplicity. | |
| $first_alias_node = $table_alias_ref_list_node->get_first_child_node(); // tableAliasRef -> identifier | |
| if (!$first_alias_node) { /* error */ return; } | |
| $target_table_alias = $this->translate($first_alias_node); // This is an alias. | |
| // We need to find the actual table name for this alias from the FROM/USING clause. | |
| // The FROM clause is in tableReferenceList. | |
| if (!$table_reference_list_node) { /* error */ return; } | |
| // Find the table in tableReferenceList that matches target_table_alias | |
| // This requires inspecting the structure of tableReferenceList (joins etc.) | |
| // For now, this is a simplification. | |
| // A full solution needs to map aliases to table names correctly. | |
| // Let's assume the main table associated with the first alias is the delete target. | |
| // This needs more robust alias resolution for complex joins. | |
| $from_clause_str = $this->translate($table_reference_list_node); | |
| // Find the table name corresponding to $target_table_alias | |
| // This is non-trivial. The original code had logic for this with SQLite. | |
| // For PG, if target_table_alias is "t1", and FROM is "real_table_name AS t1 JOIN ...", | |
| // then DELETE FROM real_table_name AS t1 USING ... | |
| // This needs proper AST traversal to map alias to actual table. | |
| // For this translation, we'll assume the first table in the FROM clause is the target if aliased. | |
| $first_table_in_from = $table_reference_list_node->get_first_descendant_node('tableRef'); // highly simplified | |
| if ($first_table_in_from) { | |
| $target_table_translated = $this->translate($first_table_in_from); | |
| $parts[] = $target_table_translated . " AS " . $target_table_alias; // e.g. DELETE FROM real_table AS t1 | |
| $parts[] = "USING " . $from_clause_str; // The rest of the FROM becomes USING, excluding target. This needs to be massaged. | |
| // The USING clause should not redefine the target table alias. | |
| // E.g., if FROM is "t1 JOIN t2", and we delete from t1: DELETE FROM table1 AS t1 USING table2 AS t2 ... | |
| // This is very hard to get right without full AST analysis. | |
| throw $this->new_not_supported_exception("Complex multi-table DELETE translation."); | |
| } else { | |
| throw $this->new_driver_exception("Cannot determine target table for multi-table DELETE."); | |
| } | |
| } elseif ($table_name_node) { // Simple DELETE FROM tbl | |
| $target_table_translated = $this->translate($table_name_node); | |
| $parts[] = $target_table_translated; | |
| } else { | |
| throw $this->new_driver_exception("Unsupported DELETE statement structure: no target table identified."); | |
| } | |
| $where_clause_node = $node->get_first_child_node('whereClause'); | |
| $has_order = $node->has_child_node( 'orderClause' ); | |
| $has_limit = $node->has_child_node( 'limitClause' ); // MySQL rule name | |
| if ($has_limit || $has_order) { | |
| $pk_cols = $this->information_schema_builder->get_primary_key_columns($this->unquote_pgsql_identifier($target_table_translated)); | |
| if (empty($pk_cols)) { | |
| throw $this->new_driver_exception("DELETE with ORDER BY/LIMIT requires a PRIMARY KEY on table for reliable emulation."); | |
| } | |
| $pk_col_quoted = $this->quote_pgsql_identifier($pk_cols[0]); | |
| $sub_select_parts = []; | |
| $sub_select_parts[] = "SELECT " . $pk_col_quoted; | |
| $sub_select_parts[] = "FROM " . $target_table_translated; // If aliased, use alias if subquery refers to main query. | |
| if ($where_clause_node) { | |
| $sub_select_parts[] = $this->translate($where_clause_node); | |
| } | |
| if ($has_order) { | |
| $order_clause_node = $node->get_first_child_node('orderClause'); | |
| $sub_select_parts[] = $this->translate($order_clause_node); | |
| } | |
| if ($has_limit) { | |
| $limit_clause_node = $node->get_first_child_node('limitClause'); | |
| $sub_select_parts[] = $this->translate($limit_clause_node); | |
| } | |
| // If USING clause was added for multi-table delete, the WHERE for ctid needs to integrate that. | |
| // This makes it even more complex. | |
| $parts[] = "WHERE " . $pk_col_quoted . " IN (" . implode(' ', $sub_select_parts) . ")"; | |
| } else { | |
| if ($where_clause_node) { | |
| // If USING is present, the WHERE clause conditions might need to reference tables from both target and USING. | |
| $parts[] = $this->translate($where_clause_node); | |
| } | |
| } | |
| $query = implode(' ', $parts); | |
| $stmt = $this->execute_pgsql_query($query); | |
| $this->set_result_from_affected_rows($stmt->rowCount()); | |
| } | |
| private function execute_create_database_statement(WP_Parser_Node $node): void { | |
| $db_name_node = $node->get_first_child_node('identifier'); | |
| if (!$db_name_node) { /* error */ return; } | |
| $db_name = $this->unquote_pgsql_identifier($this->translate($db_name_node)); | |
| // In PostgreSQL, CREATE DATABASE cannot be run inside a transaction block. | |
| // Also, connections are per-database. "Creating a database" might mean | |
| // creating a new SCHEMA within the current database for emulation. | |
| // Or, if truly creating a new DB, it's an out-of-band operation. | |
| // Current driver model is single PDO connection. | |
| // Let's assume this means CREATE SCHEMA for emulation. | |
| $if_not_exists_node = $node->get_first_child_node('ifNotExists'); | |
| $sql = "CREATE SCHEMA "; | |
| if ($if_not_exists_node) { | |
| $sql .= "IF NOT EXISTS "; | |
| } | |
| $sql .= $this->quote_pgsql_identifier($db_name); | |
| try { | |
| $this->execute_pgsql_query($sql); | |
| $this->set_result_from_affected_rows(1); // Conventionally 1 for success | |
| } catch (PDOException $e) { | |
| // Handle "already exists" if IF NOT EXISTS wasn't used or supported for this version/context | |
| if ($e->getCode() == '42P06' && !$if_not_exists_node) { // 42P06 is duplicate_schema | |
| // MySQL CREATE DATABASE IF NOT EXISTS returns 0 rows affected if exists. | |
| $this->set_result_from_affected_rows(0); | |
| // Optionally, emit a warning like MySQL (1007: Can't create database ...; database exists) | |
| } else { | |
| throw $e; | |
| } | |
| } | |
| } | |
| private function execute_drop_database_statement(WP_Parser_Node $node): void { | |
| $db_name_node = $node->get_first_child_node('identifier'); | |
| if (!$db_name_node) { /* error */ return; } | |
| $db_name = $this->unquote_pgsql_identifier($this->translate($db_name_node)); | |
| // Similar to CREATE, map to DROP SCHEMA. | |
| $if_exists_node = $node->get_first_child_node('ifExists'); | |
| $sql = "DROP SCHEMA "; | |
| if ($if_exists_node) { | |
| $sql .= "IF EXISTS "; | |
| } | |
| $sql .= $this->quote_pgsql_identifier($db_name); | |
| $cascade_node = $node->get_first_child_node('dropBehavior'); // RESTRICT | CASCADE | |
| if ($cascade_node && strtoupper($this->translate($cascade_node)) === 'CASCADE') { | |
| $sql .= " CASCADE"; | |
| } else { | |
| $sql .= " RESTRICT"; // Default for PG | |
| } | |
| try { | |
| $this->execute_pgsql_query($sql); | |
| $this->set_result_from_affected_rows(1); | |
| } catch (PDOException $e) { | |
| if ($e->getCode() == '3F000' && $if_exists_node) { // 3F000 invalid_schema_name (if IF EXISTS used) | |
| $this->set_result_from_affected_rows(0); | |
| } else { | |
| throw $e; | |
| } | |
| } | |
| } | |
| private function execute_create_index_statement(WP_Parser_Node $node): void { | |
| // MySQL: CREATE [UNIQUE|FULLTEXT|SPATIAL] INDEX index_name [index_type] ON tbl_name (columns) [options] | |
| // PG: CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] index_name ON tbl_name [USING method] (columns) [INCLUDE] [WITH storage_params] [TABLESPACE] [WHERE predicate] | |
| $parts = ["CREATE"]; | |
| if ($node->has_child_token(WP_MySQL_Lexer::UNIQUE_SYMBOL)) $parts[] = "UNIQUE"; | |
| // FULLTEXT and SPATIAL indexes require specific PG extensions (e.g., RUM, PostGIS with GIST/GIN) | |
| // and different USING method. This simple translation won't cover advanced index types. | |
| if ($node->has_child_token(WP_MySQL_Lexer::FULLTEXT_SYMBOL)) { | |
| // parts[] = "USING gin"; // or gist, depends on column type and pg_trgm or textsearch | |
| // This needs translating the column to tsvector or using expression index. | |
| throw $this->new_not_supported_exception("FULLTEXT index direct translation. Use PG text search features."); | |
| } | |
| if ($node->has_child_token(WP_MySQL_Lexer::SPATIAL_SYMBOL)) { | |
| // parts[] = "USING gist"; // Typically for PostGIS geometry types | |
| throw $this->new_not_supported_exception("SPATIAL index direct translation. Use PostGIS."); | |
| } | |
| $parts[] = "INDEX"; | |
| $index_name_node = $node->get_first_child_node('indexName'); // Or similar from grammar | |
| if ($index_name_node) $parts[] = $this->translate($index_name_node); | |
| $parts[] = "ON"; | |
| $table_name_node = $node->get_first_child_node('tableName'); // Or tableRef | |
| if ($table_name_node) $parts[] = $this->translate($table_name_node); | |
| $key_list_node = $node->get_first_child_node('keyListVariants') ?? $node->get_first_child_node('keyList'); // Grammar specific | |
| if ($key_list_node) { | |
| // Translate keyList (column names, lengths, ASC/DESC) | |
| // PG supports ASC/DESC, NULLS FIRST/LAST. MySQL sub-part indexing (col(len)) needs expression index or is ignored. | |
| $parts[] = $this->translate_index_columns_for_pg($key_list_node); | |
| } else { | |
| throw $this->new_driver_exception("CREATE INDEX missing column list."); | |
| } | |
| // Index type (USING BTREE, HASH, GIN, GIST) - MySQL index_type hint | |
| $index_type_node = $node->get_first_child_node('indexType'); // grammar rule for USING type | |
| if ($index_type_node) { | |
| $mysql_index_type = strtoupper($this->translate($index_type_node)); | |
| $pg_method = "BTREE"; // Default | |
| if ($mysql_index_type === "HASH") $pg_method = "HASH"; | |
| // GIN/GIST for FULLTEXT/SPATIAL are more complex. | |
| $parts[] = "USING " . $pg_method; | |
| } | |
| $query = implode(' ', $parts); | |
| $this->execute_pgsql_query($query); | |
| // DDL often doesn't affect rows in the same way. Set to 0 or handle based on specific DDL. | |
| $this->set_result_from_affected_rows(0); | |
| } | |
| private function translate_index_columns_for_pg(WP_Parser_Node $key_list_node): string { | |
| $column_defs = []; | |
| // This depends heavily on AST structure of keyList | |
| // Example: keyList -> keyPart+, keyPart -> identifier [ (length) ] [ASC|DESC] | |
| foreach($key_list_node->get_descendant_nodes('keyPart') as $key_part_node) { | |
| $col_name_node = $key_part_node->get_first_child_node('identifier'); | |
| if (!$col_name_node) continue; | |
| $col_def = $this->translate($col_name_node); | |
| // MySQL col(length) prefix indexing: PG doesn't support this directly on standard types. | |
| // Can use expression index on SUBSTRING(col, 1, length) or specific opclasses. | |
| // For simplicity, ignore length for now or throw not_supported. | |
| $length_node = $key_part_node->get_first_child_node('fieldLength'); // or similar | |
| if ($length_node) { | |
| // log warning or throw error: "Prefix indexing col(length) not directly supported, using full column." | |
| } | |
| $direction_node = $key_part_node->get_first_child_token(); // Assuming ASC/DESC token | |
| if ($direction_node) { | |
| if (strtoupper($direction_node->value) === "DESC") $col_def .= " DESC"; | |
| // ASC is default | |
| } | |
| $column_defs[] = $col_def; | |
| } | |
| return "(" . implode(', ', $column_defs) . ")"; | |
| } | |
| private function execute_drop_index_statement(WP_Parser_Node $node): void { | |
| // MySQL: DROP INDEX index_name ON tbl_name | |
| // PG: DROP INDEX [CONCURRENTLY] [IF EXISTS] index_name [CASCADE | RESTRICT] | |
| // Note: PG's DROP INDEX doesn't specify table name if index name is schema-qualified or unique. | |
| // MySQL needs table name. If index name is globally unique in PG (within schema), table not needed. | |
| // However, to be safe and if index names might not be unique across tables: | |
| // We need to find the schema of the index. | |
| $parts = ["DROP INDEX"]; | |
| // $if_exists_node = $node->get_first_child_node('ifExists'); // From grammar if it exists | |
| // if ($if_exists_node) $parts[] = "IF EXISTS"; | |
| $index_name_node = $node->get_first_child_node('indexRef'); // Or indexName | |
| if (!$index_name_node) { /* error */ return; } | |
| $index_name = $this->translate($index_name_node); // This should be the qualified index name if possible | |
| // MySQL's DROP INDEX includes ON tbl_name. PostgreSQL's does not. | |
| // If the index name from MySQL is not schema-qualified, and we store it as table__indexname, | |
| // then it is schema-qualified by current_schema. | |
| // If index names are unique per schema in PG, then just the name is fine. | |
| // For safety, if information_schema_builder mangles index names to be unique (e.g. tbl__idx), use that. | |
| $parts[] = $index_name; // Potentially $this->current_schema_name . "." . $index_name if needed. | |
| // Cascade/Restrict not typically in MySQL DROP INDEX syntax directly, default is like RESTRICT. | |
| // $parts[] = "CASCADE"; // If needed for dependencies. | |
| $query = implode(' ', $parts); | |
| $this->execute_pgsql_query($query); | |
| $this->set_result_from_affected_rows(0); | |
| } | |
| private function execute_create_table_statement( WP_Parser_Node $node ): void { | |
| $subnode = $node->get_first_child_node(); // This should be 'createTable' node | |
| $table_is_temporary = $subnode->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ); | |
| $table_name_node = $subnode->get_first_child_node( 'tableName' ); | |
| if (!$table_name_node) { /* error */ return; } | |
| $table_name = $this->unquote_pgsql_identifier( $this->translate( $table_name_node ) ); | |
| // Handle IF NOT EXISTS | |
| if ( $subnode->has_child_node( 'ifNotExists' ) ) { | |
| // Use information_schema_builder to check. Or rely on PG's IF NOT EXISTS. | |
| $table_exists = $this->information_schema_builder->table_exists_in_emulated_schema($table_name, $table_is_temporary); | |
| if ( $table_exists ) { | |
| $this->set_result_from_affected_rows( 0 ); // MySQL behavior: 0 rows, possible warning. | |
| // Optionally, issue a notice/warning similar to MySQL. | |
| return; | |
| } | |
| } | |
| // Record in our emulated MySQL information schema | |
| $this->information_schema_builder->record_create_table( $node ); | |
| // Generate PostgreSQL CREATE TABLE statement from our emulated schema info | |
| $queries = $this->get_pgsql_create_table_statement( $table_is_temporary, $table_name ); | |
| $create_table_query = $queries[0]; // Main CREATE TABLE | |
| $constraint_and_index_queries = array_slice( $queries, 1 ); // Separate ALTER TABLE for constraints, CREATE INDEX | |
| $this->execute_pgsql_query( $create_table_query ); | |
| foreach ( $constraint_and_index_queries as $query ) { | |
| $this->execute_pgsql_query( $query ); | |
| } | |
| $this->set_result_from_affected_rows(0); // DDL | |
| } | |
| private function execute_alter_table_statement( WP_Parser_Node $node ): void { | |
| $alter_table_node = $node->get_first_child_node(); // This should be 'alterTable' | |
| $table_ref_node = $alter_table_node->get_first_descendant_node( 'tableRef' ); | |
| if (!$table_ref_node) { /* error */ return; } | |
| $table_name = $this->unquote_pgsql_identifier( $this->translate( $table_ref_node ) ); | |
| $table_is_temporary = $this->information_schema_builder->temporary_table_exists_in_emulated_schema( $table_name ); | |
| // PostgreSQL's ALTER TABLE is powerful but syntax differs from MySQL. | |
| // Each alter spec (ADD COLUMN, DROP COLUMN, CHANGE COLUMN, etc.) needs translation. | |
| // This is a very complex part. | |
| // MySQK: ALTER TABLE tbl_name ADD COLUMN ..., CHANGE COLUMN ..., MODIFY COLUMN ..., DROP COLUMN ..., ADD INDEX ... | |
| // PG: ALTER TABLE tbl_name ADD COLUMN ..., DROP COLUMN ..., ALTER COLUMN ... TYPE ..., RENAME COLUMN ... TO ..., ADD CONSTRAINT ..., CREATE INDEX ... | |
| // First, record changes in our emulated schema | |
| // This is crucial because the recreation logic relies on the emulated schema. | |
| $column_map_for_recreate = $this->information_schema_builder->record_alter_table( $node ); | |
| // Simple ALTER TABLE actions might be translated directly. | |
| // Complex ones (multiple changes, reordering, changing type that requires data rewrite) | |
| // might force a table recreate, similar to SQLite driver's approach. | |
| // PostgreSQL ALTER TABLE is more capable, so recreation is less often needed. | |
| // However, to perfectly emulate MySQL behavior or if translation is too complex, recreation is fallback. | |
| // Attempt direct translation of ALTER specs first. | |
| // If a spec is too complex or requires full table rewrite not supported by single ALTER in PG, | |
| // then fall back to recreate. | |
| $alter_specs = $alter_table_node->get_descendant_nodes('alterListItem'); | |
| $pg_alter_clauses = []; | |
| $needs_recreate = false; | |
| foreach ($alter_specs as $spec_node) { | |
| $translated_spec = $this->translate_alter_table_spec_for_pg($spec_node, $table_name); | |
| if ($translated_spec === null) { // Indicates spec requires recreate or is unsupported | |
| $needs_recreate = true; | |
| break; | |
| } | |
| if (is_array($translated_spec)) { // Some specs might become multiple PG clauses or separate statements | |
| foreach ($translated_spec as $clause) { | |
| if (str_starts_with(strtoupper(trim($clause)), "CREATE INDEX")) { | |
| // Handle CREATE INDEX separately after ALTER TABLE | |
| $this->execute_pgsql_query($clause); | |
| } else { | |
| $pg_alter_clauses[] = $clause; | |
| } | |
| } | |
| } else { | |
| $pg_alter_clauses[] = $translated_spec; | |
| } | |
| } | |
| if (!$needs_recreate && !empty($pg_alter_clauses)) { | |
| $alter_query = "ALTER TABLE " . $this->quote_pgsql_identifier($table_name) . " " . implode(', ', $pg_alter_clauses); | |
| $this->execute_pgsql_query($alter_query); | |
| } elseif ($needs_recreate) { | |
| // Fallback to recreate table (similar to SQLite driver but using PG specific recreation) | |
| // $this->recreate_table_from_information_schema($table_is_temporary, $table_name, $column_map_for_recreate); | |
| throw $this->new_not_supported_exception("Complex ALTER TABLE requires table recreation (not fully implemented for PG yet)."); | |
| } | |
| $this->set_result_from_affected_rows(0); // DDL | |
| } | |
| private function translate_alter_table_spec_for_pg(WP_Parser_Node $spec_node, string $table_name): string|array|null { | |
| // This is a massive undertaking. Here are a few examples: | |
| $first_token = $spec_node->get_first_child_token(); | |
| if (!$first_token) return null; | |
| switch ($first_token->id) { | |
| case WP_MySQL_Lexer::ADD_SYMBOL: | |
| $column_def_node = $spec_node->get_first_descendant_node('columnDefinition'); | |
| if ($column_def_node) { // ADD COLUMN col_def | |
| $col_name_node = $column_def_node->get_first_child_node('fieldIdentifier'); | |
| $col_type_node = $column_def_node->get_first_child_node('dataType'); | |
| // ... extract other attributes (NOT NULL, DEFAULT) | |
| if (!$col_name_node || !$col_type_node) return null; | |
| $col_name = $this->translate($col_name_node); | |
| $col_type = $this->translate_datatype_for_pg($col_type_node); // Special type translation | |
| $clause = "ADD COLUMN " . $col_name . " " . $col_type; | |
| // Add NOT NULL, DEFAULT etc. from $column_def_node | |
| // ... | |
| return $clause; | |
| } | |
| $constraint_def_node = $spec_node->get_first_descendant_node('tableConstraintDef'); | |
| if ($constraint_def_node) { // ADD CONSTRAINT / ADD INDEX | |
| // If it's an index (KEY, INDEX), translate to CREATE INDEX (separate statement) | |
| // If it's PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK, translate to ADD CONSTRAINT | |
| $keyword_node = $constraint_def_node->get_first_child_token(); // PRIMARY, UNIQUE, FOREIGN, KEY etc | |
| if ($keyword_node && ($keyword_node->id == WP_MySQL_Lexer::KEY_SYMBOL || $keyword_node->id == WP_MySQL_Lexer::INDEX_SYMBOL)) { | |
| // Build CREATE INDEX statement | |
| $idx_name_node = $constraint_def_node->get_first_child_node('indexName'); | |
| $idx_name = $idx_name_node ? $this->translate($idx_name_node) : $this->quote_pgsql_identifier($table_name . "_" . uniqid("idx_")); | |
| $idx_cols_node = $constraint_def_node->get_first_child_node('keyListVariants'); // Or keyList | |
| if (!$idx_cols_node) return null; | |
| $idx_cols_str = $this->translate_index_columns_for_pg($idx_cols_node); | |
| return "CREATE INDEX " . $idx_name . " ON " . $this->quote_pgsql_identifier($table_name) . " " . $idx_cols_str; | |
| } | |
| // ... other ADD CONSTRAINT cases | |
| } | |
| return null; // Unsupported ADD variant | |
| case WP_MySQL_Lexer::DROP_SYMBOL: | |
| $column_ref_node = $spec_node->get_first_child_node('fieldIdentifier'); | |
| if ($column_ref_node) { // DROP COLUMN col | |
| return "DROP COLUMN " . $this->translate($column_ref_node); | |
| } | |
| $index_ref_node = $spec_node->get_first_child_node('indexRef'); // For DROP INDEX | |
| if ($index_ref_node) { | |
| // PG: DROP INDEX index_name. MySQL: ALTER TABLE ... DROP INDEX index_name. | |
| // This should be a separate DROP INDEX statement. | |
| return "DROP INDEX " . $this->translate($index_ref_node); // Assuming index name is unique or schema qualified | |
| } | |
| // DROP PRIMARY KEY, DROP FOREIGN KEY ... | |
| return null; | |
| case WP_MySQL_Lexer::CHANGE_SYMBOL: // CHANGE [COLUMN] old_col new_col col_def | |
| $old_col_node = $spec_node->get_first_child_node('fieldIdentifier'); | |
| $new_col_name_node = $spec_node->get_first_child_node('identifier'); // This is new name | |
| $col_def_node = $spec_node->get_first_descendant_node('fieldDefinition'); | |
| if (!$old_col_node || !$new_col_name_node || !$col_def_node) return null; | |
| $clauses = []; | |
| $old_col_name = $this->translate($old_col_node); | |
| $new_col_name = $this->translate($new_col_name_node); | |
| // 1. RENAME | |
| if ($old_col_name !== $new_col_name) { | |
| $clauses[] = "RENAME COLUMN " . $old_col_name . " TO " . $new_col_name; | |
| } | |
| // 2. ALTER TYPE (if changed) | |
| $new_type_node = $col_def_node->get_first_child_node('dataType'); | |
| if ($new_type_node) { | |
| $new_type_str = $this->translate_datatype_for_pg($new_type_node); | |
| // Compare with old type from schema. If different, add ALTER COLUMN ... TYPE. | |
| // This might need USING clause for data conversion. | |
| // $old_type_from_schema = $this->information_schema_builder->get_column_type(...); | |
| // For simplicity, assume it can be changed or will be handled by recreation. | |
| $clauses[] = "ALTER COLUMN " . $new_col_name . " TYPE " . $new_type_str; // Needs USING for non-castable | |
| } | |
| // 3. SET/DROP NOT NULL | |
| // 4. SET/DROP DEFAULT | |
| // ... | |
| return $clauses; | |
| case WP_MySQL_Lexer::MODIFY_SYMBOL: // MODIFY [COLUMN] col col_def | |
| // Similar to CHANGE but no rename. | |
| // Translate to ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT | |
| return null; // Placeholder | |
| } | |
| return null; // Fallback for unsupported spec | |
| } | |
| private function translate_datatype_for_pg(WP_Parser_Node $datatype_node): string { | |
| // This needs to look at $datatype_node structure (type name, length, precision, etc.) | |
| // and map to a full PostgreSQL type string. | |
| // Example: MySQL VARCHAR(255) -> PG VARCHAR(255) | |
| // MySQL INT UNSIGNED -> PG INTEGER (PG INTEGER is signed, for UNSIGNED need CHECK constraint or BIGINT if range exceeds) | |
| // MySQL SERIAL -> PG SERIAL | |
| $type_token_node = $datatype_node->get_first_child_token(); // Highly simplified | |
| if (!$type_token_node) return "TEXT"; // fallback | |
| $mysql_base_type_token_id = $type_token_node->id; | |
| $pg_base_type = self::DATA_TYPE_MAP[$mysql_base_type_token_id] ?? 'TEXT'; // Default to TEXT | |
| if ($mysql_base_type_token_id === WP_MySQL_Lexer::SERIAL_SYMBOL) return "SERIAL"; // or BIGSERIAL | |
| // Handle length/precision from AST (e.g., fieldLength, precision nodes) | |
| $length_node = $datatype_node->get_first_descendant_node('fieldLength'); | |
| $precision_node = $datatype_node->get_first_descendant_node('precision'); | |
| $length_str = ""; | |
| if ($length_node) { | |
| $length_str = $this->translate($length_node); // e.g. (255) or (10,2) | |
| } elseif ($precision_node) { | |
| $length_str = $this->translate($precision_node); | |
| } | |
| // Combine base type with length/precision | |
| // E.g. if $pg_base_type is VARCHAR and $length_str is (255) -> VARCHAR(255) | |
| if (in_array($pg_base_type, ['CHAR', 'VARCHAR', 'BIT', 'VARBIT']) && $length_str) { | |
| return $pg_base_type . $length_str; | |
| } | |
| if (in_array($pg_base_type, ['NUMERIC', 'DECIMAL']) && $length_str) { | |
| return $pg_base_type . $length_str; | |
| } | |
| // For types like INT, TEXT, DATE, length is not appended or handled differently. | |
| // Handle UNSIGNED for integer types - PG has no direct unsigned. | |
| // Typically map to next larger signed type or add a CHECK constraint. | |
| // For simplicity in translation, often ignored or mapped to standard signed type. | |
| if ($datatype_node->has_child_token(WP_MySQL_Lexer::UNSIGNED_SYMBOL)) { | |
| if ($pg_base_type === 'SMALLINT' || $pg_base_type === 'INTEGER') { | |
| // Could return BIGINT if MySQL unsigned int might exceed PG signed int max. | |
| // Or add CHECK (col >= 0) | |
| } | |
| } | |
| return $pg_base_type . $length_str; // May need refinement | |
| } | |
| private function execute_drop_table_statement( WP_Parser_Node $node ): void { | |
| // MySQL: DROP [TEMPORARY] TABLE [IF EXISTS] tbl_name [, tbl_name ...] [RESTRICT | CASCADE] | |
| // PG: DROP TABLE [IF EXISTS] tbl_name [, ...] [CASCADE | RESTRICT] | |
| $drop_table_node = $node->get_first_child_node(); // the 'dropTable' rule node | |
| $is_temporary = $drop_table_node->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ); | |
| $if_exists = $drop_table_node->has_child_node( 'ifExists' ); | |
| // Record in our emulated schema first | |
| $this->information_schema_builder->record_drop_table( $node ); | |
| $table_refs_list_node = $drop_table_node->get_first_child_node('tableRefList'); | |
| if (!$table_refs_list_node) { /* error */ return; } | |
| $table_names_to_drop = []; | |
| foreach ($table_refs_list_node->get_child_nodes('tableRef') as $table_ref_node) { | |
| $table_names_to_drop[] = $this->translate($table_ref_node); // Already quoted | |
| } | |
| if (empty($table_names_to_drop)) return; | |
| $sql = "DROP TABLE "; | |
| if ($is_temporary) $sql = "DROP TABLE "; // PG TEMP tables are dropped automatically, or DROP TABLE temp_tbl_name | |
| if ($if_exists) $sql .= "IF EXISTS "; | |
| $sql .= implode(', ', $table_names_to_drop); | |
| $behavior_node = $drop_table_node->get_first_child_node('dropBehavior'); // For RESTRICT/CASCADE | |
| if ($behavior_node) { | |
| $behavior = strtoupper($this->translate($behavior_node)); | |
| if ($behavior === "CASCADE") $sql .= " CASCADE"; | |
| // RESTRICT is default in PG | |
| } | |
| $this->execute_pgsql_query($sql); | |
| $this->set_result_from_affected_rows(0); // DDL | |
| } | |
| private function execute_truncate_table_statement( WP_Parser_Node $node ): void { | |
| // MySQL: TRUNCATE [TABLE] tbl_name | |
| // PG: TRUNCATE [TABLE] [ONLY] tbl_name [*] [, ...] [RESTART IDENTITY | CONTINUE IDENTITY] [CASCADE | RESTRICT] | |
| $table_ref_node = $node->get_first_child_node( 'tableRef' ); | |
| if (!$table_ref_node) { /* error */ return; } | |
| $table_name = $this->translate( $table_ref_node ); // Already quoted | |
| // MySQL TRUNCATE implicitly resets auto_increment. PG needs RESTART IDENTITY. | |
| $sql = "TRUNCATE TABLE " . $table_name . " RESTART IDENTITY"; | |
| // Add CASCADE if dependencies should be truncated (e.g. foreign keys) | |
| // MySQL TRUNCATE doesn't activate triggers. PG TRUNCATE does by default. | |
| // To avoid triggers: ALTER TABLE tbl DISABLE TRIGGER USER; TRUNCATE...; ALTER TABLE tbl ENABLE TRIGGER USER; | |
| // This is more complex than direct translation. | |
| $this->execute_pgsql_query( $sql ); | |
| $this->set_result_from_affected_rows(0); // DDL | |
| } | |
| private function execute_set_statement( WP_Parser_Node $node ): void { | |
| // MySQL SET var = value, SESSION var = value, GLOBAL var = value | |
| // PG SET var TO value, SET SESSION var TO value, SET LOCAL var TO value | |
| // GLOBAL settings in PG are usually via postgresql.conf or ALTER SYSTEM. | |
| // $this->active_sql_modes emulation would be handled here. | |
| // This is highly dependent on the variables being set. | |
| // For sql_mode: | |
| // Iterate through assignments like in original. | |
| // If var_name is 'sql_mode' and scope is SESSION (default for MySQL SET without scope): | |
| // $value = ... (parsed value of modes) | |
| // $this->active_sql_modes = explode(',', strtoupper($value)); | |
| // $this->last_result = 0; // Or true | |
| // return; | |
| // For other variables like 'names', 'character_set_client', etc. | |
| // SET client_encoding TO 'UTF8'; (PG equiv of SET NAMES 'utf8') | |
| // This requires specific translation for each MySQL variable to its PG counterpart or behavior. | |
| // For now, mostly a NO-OP or only handle sql_mode for emulation layer. | |
| // Simplified: Only update emulated SQL modes. | |
| // Complex SET statements (multiple vars, complex expressions) need full parsing from AST. | |
| // This is a placeholder due to parser stub complexity. | |
| $assignment_nodes = $node->get_descendant_nodes('optionValue'); // Or similar rule | |
| foreach($assignment_nodes as $assign_node) { | |
| $var_name_node = $assign_node->get_first_child_node('internalVariableName') ?? $assign_node->get_first_child_node('setSystemVariable'); | |
| $value_node = $assign_node->get_first_child_node('setExprOrDefault'); | |
| if ($var_name_node && $value_node) { | |
| $var_name = strtolower($this->unquote_pgsql_identifier($this->translate($var_name_node->get_first_child_node('textOrIdentifier') ?? $var_name_node))); // Extract actual name string | |
| $var_value_str = $this->translate($value_node); // This will be a string literal 'value' | |
| $var_value_str = trim($var_value_str, "'"); // Remove quotes | |
| if ($var_name === 'sql_mode') { | |
| $scope_token = $var_name_node->get_first_child_node('setVarIdentType'); // GLOBAL/SESSION | |
| $is_global = $scope_token && strtoupper($this->translate($scope_token)) === 'GLOBAL'; | |
| if (!$is_global) { // Assume SESSION if not GLOBAL | |
| $this->active_sql_modes = explode(',', strtoupper($var_value_str)); | |
| } else { | |
| // GLOBAL sql_mode not directly translatable to live PG session. | |
| // Would require ALTER SYSTEM or config change. | |
| throw $this->new_not_supported_exception("SET GLOBAL sql_mode"); | |
| } | |
| } elseif (str_starts_with($var_name, 'character_set_') || $var_name === 'names') { | |
| // Example: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; | |
| // PG: SET client_encoding TO 'UTF8'; (Collation is per-column/db, not session var like this) | |
| if ($var_name === 'names' || $var_name === 'character_set_client' || $var_name === 'character_set_results' || $var_name === 'character_set_connection') { | |
| $pg_encoding = $this->map_mysql_charset_to_pg_encoding($var_value_str); | |
| $this->execute_pgsql_query("SET client_encoding TO " . $this->pdo->quote($pg_encoding)); | |
| } | |
| } else { | |
| // Other session variables might have PG equivalents. | |
| // SET time_zone = '...'; -> SET TIME ZONE '...'; | |
| if ($var_name === 'time_zone') { | |
| $this->execute_pgsql_query("SET TIME ZONE " . $this->pdo->quote($var_value_str)); | |
| } | |
| } | |
| } | |
| } | |
| $this->last_result = 0; // Or true depending on MySQL behavior | |
| } | |
| private function map_mysql_charset_to_pg_encoding(string $mysql_charset): string { | |
| $mysql_charset = strtolower($mysql_charset); | |
| $map = [ | |
| 'utf8' => 'UTF8', | |
| 'utf8mb4' => 'UTF8', | |
| 'latin1' => 'LATIN1', | |
| // Add more common mappings | |
| ]; | |
| return $map[$mysql_charset] ?? 'UTF8'; // Default to UTF8 | |
| } | |
| private function execute_show_statement( WP_Parser_Node $node ): void { | |
| $tokens = $node->get_child_tokens(); // Assuming direct children are tokens for SHOW type | |
| if (empty($tokens) || count($tokens) < 2) { | |
| // Fallback if AST is stubbed / different | |
| $raw_show_query = $this->last_mysql_query; // The original MySQL query | |
| if (preg_match('/SHOW\s+TABLES/i', $raw_show_query)) { | |
| $this->execute_show_tables_statement($node); return; | |
| } | |
| if (preg_match('/SHOW\s+COLUMNS\s+FROM\s+(`?\w+`?)/i', $raw_show_query, $m)) { | |
| // Create a fake node structure for execute_show_columns_statement | |
| // This is a hack for stubbed parser. | |
| $fake_table_ref_node = new WP_Parser_Node(0, 'tableRef'); | |
| $fake_table_ref_node->append_child(new WP_Parser_Node(0,'identifier')); // Add identifier child to tableRef | |
| $fake_table_ref_node->get_first_child_node()->append_child(new WP_MySQL_Token(WP_MySQL_Lexer::IDENTIFIER, $m[1])); | |
| $fake_show_columns_node = new WP_Parser_Node(0,'showStatement'); // Dummy node | |
| $fake_show_columns_node->append_child($fake_table_ref_node); // Attach the faked tableRef | |
| $this->execute_show_columns_statement($fake_show_columns_node); return; | |
| } | |
| throw $this->new_not_supported_exception("Complex SHOW statement with stubbed parser"); | |
| } | |
| $keyword1 = $tokens[1]; // e.g. TABLES, COLUMNS, DATABASES, VARIABLES, STATUS, INDEX, CREATE | |
| $keyword2 = $tokens[2] ?? null; // e.g. TABLE for SHOW CREATE TABLE | |
| switch ( $keyword1->id ) { | |
| case WP_MySQL_Lexer::COLUMNS_SYMBOL: | |
| case WP_MySQL_Lexer::FIELDS_SYMBOL: | |
| $this->execute_show_columns_statement( $node ); | |
| break; | |
| case WP_MySQL_Lexer::CREATE_SYMBOL: | |
| if ($keyword2 && WP_MySQL_Lexer::TABLE_SYMBOL === $keyword2->id) { | |
| $this->execute_show_create_table_statement($node); | |
| } elseif ($keyword2 && $keyword2->id == WP_MySQL_Lexer::DATABASE_SYMBOL) { // SHOW CREATE DATABASE | |
| // $this->execute_show_create_database_statement($node); | |
| throw $this->new_not_supported_exception("SHOW CREATE DATABASE"); | |
| } else { | |
| throw $this->new_not_supported_exception("Unsupported SHOW CREATE variant."); | |
| } | |
| break; | |
| case WP_MySQL_Lexer::INDEX_SYMBOL: // SHOW INDEX | INDEXES | KEYS | |
| case WP_MySQL_Lexer::INDEXES_SYMBOL: | |
| case WP_MySQL_Lexer::KEYS_SYMBOL: | |
| $this->execute_show_index_statement( $node ); | |
| break; | |
| case WP_MySQL_Lexer::DATABASES_SYMBOL: // SHOW DATABASES | |
| // case WP_MySQL_Lexer::SCHEMAS_SYMBOL: // MySQL alias for DATABASES | |
| $this->execute_show_databases_statement($node); | |
| break; | |
| case WP_MySQL_Lexer::GRANTS_SYMBOL: // SHOW GRANTS | |
| // This is complex, depends on users. Emulate with fixed response or query pg_roles/pg_authid. | |
| $this->set_results_from_fetched_data([ | |
| (object)['Grants for user' => 'GRANT ALL PRIVILEGES ON DATABASE ...'] // Simplified | |
| ]); | |
| break; | |
| case WP_MySQL_Lexer::TABLE_SYMBOL: // SHOW TABLE STATUS | |
| if ($keyword2 && strtoupper($keyword2->value) === 'STATUS') { // Check AST node name if available | |
| $this->execute_show_table_status_statement( $node ); | |
| } else { | |
| throw $this->new_not_supported_exception("Unsupported SHOW TABLE variant."); | |
| } | |
| break; | |
| case WP_MySQL_Lexer::TABLES_SYMBOL: // SHOW TABLES | |
| $this->execute_show_tables_statement( $node ); | |
| break; | |
| case WP_MySQL_Lexer::VARIABLES_SYMBOL: // SHOW VARIABLES | |
| $this->execute_show_variables_statement($node); | |
| break; | |
| // case WP_MySQL_Lexer::STATUS_SYMBOL: // SHOW STATUS (global/session status vars) | |
| // $this->execute_show_status_statement($node); | |
| // break; | |
| default: | |
| throw $this->new_not_supported_exception( | |
| sprintf('SHOW statement type: "%s"', $keyword1->value) | |
| ); | |
| } | |
| } | |
| private function execute_show_tables_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW [FULL] TABLES [FROM db_name] [LIKE 'pattern' | WHERE expr] | |
| // PG: SELECT tablename FROM pg_tables WHERE schemaname = '...' | |
| // OR: SELECT table_name FROM information_schema.tables WHERE table_schema = '...' AND table_type = 'BASE TABLE' | |
| $db_name_node = $node->get_first_child_node('inDb'); // Or fromDb | |
| $schema_name = $this->current_schema_name; | |
| if ($db_name_node) { | |
| $ident_node = $db_name_node->get_first_child_node('identifier'); | |
| $schema_name = $this->unquote_pgsql_identifier($this->translate($ident_node)); | |
| if (strtolower($schema_name) === 'information_schema') { | |
| // Allow querying PG's own information_schema directly | |
| } else if ($schema_name !== $this->main_db_name && strtolower($schema_name) !== strtolower($this->current_schema_name)) { | |
| // If emulating multiple MySQL DBs as PG schemas, this could be valid. | |
| // For now, assume current_schema_name is the target unless it's specifically information_schema. | |
| // $this->current_schema_name = $schema_name; // This would change context for future queries. | |
| // Or just use it for this query. | |
| } | |
| } | |
| $sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = :schema_name AND table_type = 'BASE TABLE'"; | |
| $params = [':schema_name' => $schema_name]; | |
| $like_or_where_node = $node->get_first_child_node('likeOrWhere'); | |
| if ($like_or_where_node) { | |
| $like_clause_node = $like_or_where_node->get_first_child_node('likeClause'); | |
| if ($like_clause_node) { | |
| $pattern_node = $like_clause_node->get_first_child_node('textStringLiteral'); | |
| $pattern = $this->unquote_string_literal($this->translate($pattern_node)); // unquote 'pattern' | |
| $sql .= " AND table_name LIKE :pattern"; | |
| $params[':pattern'] = $pattern; | |
| } else { | |
| $where_expr_node = $like_or_where_node->get_first_child_node('whereClause')->get_first_child_node('expr'); | |
| // Translating arbitrary WHERE clause is complex. Assume it refers to `table_name`. | |
| // This needs to be parsed and field names potentially remapped. | |
| // Simplified: `AND ` . $this->translate($where_expr_node) (risky if expr is complex) | |
| // For now, only support simple LIKE. | |
| throw $this->new_not_supported_exception("SHOW TABLES WHERE (complex expr)"); | |
| } | |
| } | |
| $is_full = $node->get_first_descendant_token(WP_MySQL_Lexer::FULL_SYMBOL) !== null; | |
| $key_column_name = "Tables_in_" . $this->main_db_name; // Emulated MySQL DB name | |
| $stmt = $this->execute_pgsql_query($sql, $params); | |
| $results = []; | |
| while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { | |
| $obj = new stdClass(); | |
| $obj->{$key_column_name} = $row['table_name']; | |
| if ($is_full) { | |
| // MySQL 'Table_type' can be BASE TABLE, VIEW, SYSTEM VIEW, TEMPORARY | |
| // PG information_schema.tables.table_type is BASE TABLE or VIEW | |
| $obj->Table_type = 'BASE TABLE'; // Or query and map for views | |
| } | |
| $results[] = $obj; | |
| } | |
| $this->set_results_from_fetched_data($results); | |
| } | |
| private function execute_show_columns_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW [FULL] COLUMNS FROM tbl [FROM db] [LIKE 'pattern' | WHERE expr] | |
| // PG: Query information_schema.columns | |
| $table_ref_node = $node->get_first_child_node('tableRef'); | |
| if (!$table_ref_node) { /* error */ return; } | |
| $table_name = $this->unquote_pgsql_identifier($this->translate($table_ref_node->get_first_child_node('identifier'))); // Simplified | |
| $db_name_node = $node->get_first_child_node('inDb'); | |
| $schema_name = $this->current_schema_name; | |
| if ($db_name_node) { | |
| // Handle schema context if provided | |
| $schema_name = $this->unquote_pgsql_identifier($this->translate($db_name_node->get_first_child_node('identifier'))); | |
| } | |
| $sql = "SELECT | |
| column_name AS \"Field\", | |
| COALESCE(domain_name, udt_name) AS \"Type_pg\", -- PG specific type | |
| data_type AS \"Type_udt\", -- udt_name is more precise | |
| is_nullable AS \"Null\", | |
| column_default AS \"Default\", | |
| '' AS \"Key\", -- Needs to be derived from constraints (pg_constraint, pg_index) | |
| '' AS \"Extra\" -- e.g. auto_increment (is_identity, or sequence default) | |
| FROM information_schema.columns | |
| WHERE table_schema = :schema_name AND table_name = :table_name"; | |
| $params = [':schema_name' => $schema_name, ':table_name' => $table_name]; | |
| $like_or_where_node = $node->get_first_child_node('likeOrWhere'); | |
| if ($like_or_where_node) { | |
| // Similar to SHOW TABLES, handle LIKE or simple WHERE on column_name | |
| $like_clause_node = $like_or_where_node->get_first_child_node('likeClause'); | |
| if ($like_clause_node) { | |
| $pattern_node = $like_clause_node->get_first_child_node('textStringLiteral'); | |
| $pattern = $this->unquote_string_literal($this->translate($pattern_node)); | |
| $sql .= " AND column_name LIKE :pattern"; | |
| $params[':pattern'] = $pattern; | |
| } // Complex WHERE not supported here for brevity | |
| } | |
| $sql .= " ORDER BY ordinal_position"; | |
| $stmt = $this->execute_pgsql_query($sql, $params); | |
| $raw_results = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| // Post-process to match MySQL SHOW COLUMNS output format | |
| $results = []; | |
| foreach($raw_results as $row) { | |
| $obj = new stdClass(); | |
| $obj->Field = $row['Field']; | |
| // Reconstruct MySQL-like type string (e.g., varchar(255), int(11) unsigned) | |
| // This requires mapping PG types back to MySQL types and format. | |
| // $obj->Type = $this->map_pg_type_to_mysql_type_string($row['Type_pg'], $row['Type_udt'], ...); | |
| // For now, use a simplified version. | |
| $obj->Type = strtolower($row['Type_udt']); | |
| if ($row['character_maximum_length'] ?? null) { | |
| $obj->Type .= '(' . $row['character_maximum_length'] . ')'; | |
| } elseif (($row['numeric_precision'] ?? null) && ($row['numeric_scale'] ?? null)) { | |
| $obj->Type .= '(' . $row['numeric_precision'] . ',' . $row['numeric_scale'] . ')'; | |
| } elseif ($row['numeric_precision'] ?? null) { | |
| $obj->Type .= '(' . $row['numeric_precision'] . ')'; | |
| } | |
| // 'unsigned' part is harder, PG has no direct unsigned. | |
| $obj->Null = ($row['Null'] === 'YES' ? 'YES' : 'NO'); | |
| $obj->Key = $row['Key']; // Needs to be populated by querying pg_constraint/pg_index | |
| $obj->Default = $row['Default']; | |
| $obj->Extra = $row['Extra']; // Needs to be populated (e.g. for identity columns) | |
| // Populate Key and Extra by querying pg_catalog, this is complex | |
| // Example for Key: Check if column is part of PK or UNIQUE constraint. | |
| // Example for Extra: Check if column is_identity or has default nextval('sequence'). | |
| $column_key_extra = $this->information_schema_builder->get_column_key_and_extra_for_show($schema_name, $table_name, $row['Field']); | |
| $obj->Key = $column_key_extra['key']; | |
| $obj->Extra = $column_key_extra['extra']; | |
| $results[] = $obj; | |
| } | |
| $this->set_results_from_fetched_data($results); | |
| } | |
| private function execute_show_index_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW INDEX[ES] | KEYS FROM tbl [FROM db] [WHERE expr] | |
| // PG: Query pg_indexes or construct from pg_class, pg_index, pg_attribute | |
| $table_ref_node = $node->get_first_child_node('tableRef'); // Could also be tableName | |
| if (!$table_ref_node) { /* error */ return; } | |
| $table_name = $this->unquote_pgsql_identifier($this->translate($table_ref_node->get_first_child_node('identifier'))); | |
| $db_name_node = $node->get_first_child_node('inDb'); | |
| $schema_name = $this->current_schema_name; | |
| if ($db_name_node) { | |
| $schema_name = $this->unquote_pgsql_identifier($this->translate($db_name_node->get_first_child_node('identifier'))); | |
| } | |
| // Query pg_indexes for basic info | |
| $sql = "SELECT | |
| tablename AS \"Table\", | |
| NOT indexdef ~* 'UNIQUE' AS \"Non_unique\", -- Approximation: 0 if unique, 1 if not | |
| indexname AS \"Key_name\", | |
| 0 AS \"Seq_in_index\", -- Needs to be derived by parsing indexdef or querying pg_attribute/pg_index | |
| '' AS \"Column_name\", -- Needs to be derived | |
| '' AS \"Collation\", -- 'A' for Asc, 'D' for Desc. Needs parsing indexdef | |
| 0 AS \"Cardinality\", -- pg_stats.n_distinct, or estimate. MySQL cardinality is often estimate. | |
| NULL AS \"Sub_part\", -- MySQL specific prefix indexing | |
| NULL AS \"Packed\", -- MySQL specific | |
| '' AS \"Null\", -- 'YES' if col can be null, '' if not. Needs column info. | |
| CASE | |
| WHEN indexdef ~* 'USING btree' THEN 'BTREE' | |
| WHEN indexdef ~* 'USING hash' THEN 'HASH' | |
| WHEN indexdef ~* 'USING gist' THEN 'GIST' | |
| WHEN indexdef ~* 'USING gin' THEN 'GIN' | |
| ELSE 'BTREE' -- Default | |
| END AS \"Index_type\", | |
| '' AS \"Comment\", | |
| '' AS \"Index_comment\", | |
| 'YES' AS \"Visible\" -- PG indexes are visible | |
| FROM pg_catalog.pg_indexes | |
| WHERE schemaname = :schema_name AND tablename = :table_name"; | |
| $params = [':schema_name' => $schema_name, ':table_name' => $table_name]; | |
| // WHERE clause from MySQL SHOW INDEX (e.g., WHERE Key_name = 'PRIMARY') | |
| // This requires translating the WHERE clause against the above column names. | |
| // $where_expr_node = $node->get_first_child_node('whereClause'); | |
| // if ($where_expr_node) { ... complex translation ... } | |
| $stmt = $this->execute_pgsql_query($sql, $params); | |
| $raw_results = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| // Post-process to fill Seq_in_index, Column_name, Collation, Null | |
| // This is very complex as it requires parsing `indexdef` or joining with other pg_catalog tables. | |
| // For this conversion, this part will be incomplete. | |
| $results = []; | |
| foreach ($raw_results as $raw_row) { | |
| // Defer to information_schema_builder for detailed index info from emulated schema | |
| $detailed_indexes = $this->information_schema_builder->get_detailed_index_info_for_show( | |
| $schema_name, $table_name, $raw_row['Key_name'] | |
| ); | |
| if (!empty($detailed_indexes)) { | |
| foreach($detailed_indexes as $idx_part) { | |
| $results[] = (object) $idx_part; // Assuming builder returns objects in correct format | |
| } | |
| } else { | |
| // Fallback with raw data if builder doesn't have it or fails | |
| $obj = new stdClass(); | |
| $obj->Table = $raw_row['Table']; | |
| $obj->Non_unique = (int)$raw_row['Non_unique']; // Cast to int | |
| $obj->Key_name = $raw_row['Key_name']; | |
| // ... (other fields are hard to populate accurately without more logic) | |
| $obj->Seq_in_index = 1; // Placeholder | |
| $obj->Column_name = 'N/A'; // Placeholder | |
| // ... | |
| $results[] = $obj; | |
| } | |
| } | |
| $this->set_results_from_fetched_data($results); | |
| } | |
| private function execute_show_databases_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW DATABASES [LIKE 'pattern' | WHERE expr] | |
| // PG: SELECT datname FROM pg_database WHERE datistemplate = false; | |
| // Or, if emulating MySQL DBs as PG schemas: SELECT schema_name FROM information_schema.schemata | |
| // Assuming we emulate MySQL DBs as PG schemas. | |
| $sql = "SELECT schema_name AS \"Database\" FROM information_schema.schemata | |
| WHERE schema_name NOT IN ('pg_catalog', 'information_schema') | |
| AND schema_name NOT LIKE 'pg_toast%' AND schema_name NOT LIKE 'pg_temp_%'"; | |
| $like_or_where_node = $node->get_first_child_node('likeOrWhere'); | |
| if ($like_or_where_node) { | |
| // Handle LIKE or simple WHERE on schema_name | |
| $like_clause_node = $like_or_where_node->get_first_child_node('likeClause'); | |
| if ($like_clause_node) { | |
| $pattern_node = $like_clause_node->get_first_child_node('textStringLiteral'); | |
| $pattern = $this->unquote_string_literal($this->translate($pattern_node)); | |
| $sql .= " AND schema_name LIKE " . $this->pdo->quote($pattern); | |
| } | |
| } | |
| $stmt = $this->execute_pgsql_query($sql); | |
| $this->set_results_from_fetched_data($stmt->fetchAll(PDO::FETCH_OBJ)); | |
| } | |
| private function execute_show_variables_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW [GLOBAL | SESSION] VARIABLES [LIKE 'pattern' | WHERE expr] | |
| // PG: SELECT name, setting, short_desc, context FROM pg_settings | |
| // Or: SHOW variable_name; SHOW ALL; | |
| $is_global = $node->get_first_child_token(WP_MySQL_Lexer::GLOBAL_SYMBOL) !== null; | |
| // PG `pg_settings` context shows if it's session, user, database, superuser etc. | |
| // `SHOW <var>` uses current (session) setting. `SHOW ALL` shows all. | |
| // Emulating GLOBAL/SESSION distinction for all MySQL vars is hard. | |
| $sql = "SELECT name, setting AS \"Value\" FROM pg_settings"; // 'Value' is the MySQL column name | |
| $params = []; | |
| $like_or_where_node = $node->get_first_child_node('likeOrWhere'); | |
| if ($like_or_where_node) { | |
| $like_clause_node = $like_or_where_node->get_first_child_node('likeClause'); | |
| if ($like_clause_node) { | |
| $pattern_node = $like_clause_node->get_first_child_node('textStringLiteral'); | |
| $pattern = $this->unquote_string_literal($this->translate($pattern_node)); | |
| $sql .= " WHERE name LIKE :pattern"; | |
| $params[':pattern'] = $pattern; | |
| } // WHERE expr is more complex | |
| } | |
| $stmt = $this->execute_pgsql_query($sql, $params); | |
| $raw_results = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| // MySQL output is 'Variable_name', 'Value'. | |
| $results = []; | |
| foreach($raw_results as $row) { | |
| $obj = new stdClass(); | |
| $obj->Variable_name = $row['name']; | |
| $obj->Value = $row['Value']; | |
| $results[] = $obj; | |
| } | |
| $this->set_results_from_fetched_data($results); | |
| } | |
| private function execute_show_table_status_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW TABLE STATUS [FROM db_name] [LIKE 'pattern' | WHERE expr] | |
| // PG: No direct equivalent. Info needs to be pieced together from: | |
| // information_schema.tables (name, type) | |
| // pg_class (reltuples for approx row count, relpages for size estimation) | |
| // pg_total_relation_size() for size. | |
| // This is complex to fully emulate. | |
| // For now, provide names and basic types from information_schema.tables. | |
| $db_name_node = $node->get_first_child_node('inDb'); | |
| $schema_name = $this->current_schema_name; | |
| if ($db_name_node) { | |
| $schema_name = $this->unquote_pgsql_identifier($this->translate($db_name_node->get_first_child_node('identifier'))); | |
| } | |
| $sql = "SELECT | |
| table_name AS \"Name\", | |
| 'PostgreSQL' AS \"Engine\", -- PG doesn't have engines like MySQL | |
| table_type AS \"Row_format_temp\", -- Temp placeholder, MySQL Row_format is different | |
| 0 AS \"Rows\", -- Placeholder, get from pg_class.reltuples | |
| 0 AS \"Avg_row_length\", -- Placeholder | |
| 0 AS \"Data_length\", -- Placeholder, pg_table_size() | |
| 0 AS \"Max_data_length\", -- N/A | |
| 0 AS \"Index_length\", -- Placeholder, pg_indexes_size() | |
| 0 AS \"Data_free\", -- N/A | |
| NULL AS \"Auto_increment\", -- Placeholder, from sequence | |
| NULL AS \"Create_time\", -- PG doesn't store this directly in info schema. From file system or logs. | |
| NULL AS \"Update_time\", -- PG doesn't store this. | |
| NULL AS \"Check_time\", -- N/A | |
| NULL AS \"Collation\", -- From pg_database/pg_collation for default, or per-column. | |
| NULL AS \"Checksum\", -- N/A | |
| '' AS \"Create_options\", -- N/A | |
| '' AS \"Comment\" -- From COMMENT ON TABLE ... | |
| FROM information_schema.tables | |
| WHERE table_schema = :schema_name"; | |
| $params = [':schema_name' => $schema_name]; | |
| $like_or_where_node = $node->get_first_child_node('likeOrWhere'); | |
| if ($like_or_where_node) { | |
| // Handle LIKE on table_name | |
| } | |
| $stmt = $this->execute_pgsql_query($sql, $params); | |
| $results = $stmt->fetchAll(PDO::FETCH_OBJ); // Fetch as objects directly if format matches | |
| // Further enrich with data from pg_class etc. (complex) | |
| foreach ($results as $row) { | |
| // Example: Get actual row count and size | |
| // $stats_sql = "SELECT reltuples::bigint, pg_total_relation_size(c.oid) as total_size, pg_table_size(c.oid) as data_size, pg_indexes_size(c.oid) as index_size | |
| // FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace | |
| // WHERE n.nspname = :schema_name AND c.relname = :table_name AND c.relkind IN ('r', 'p', 'm') "; // r=table, p=partitioned table, m=materialized view | |
| // $stats_stmt = $this->execute_pgsql_query($stats_sql, [':schema_name' => $schema_name, ':table_name' => $row->Name]); | |
| // $table_stats = $stats_stmt->fetch(PDO::FETCH_ASSOC); | |
| // if ($table_stats) { | |
| // $row->Rows = $table_stats['reltuples']; | |
| // $row->Data_length = $table_stats['data_size']; | |
| // $row->Index_length = $table_stats['index_size']; | |
| // // Avg_row_length can be estimated if Data_length and Rows are known | |
| // if ($row->Rows > 0) $row->Avg_row_length = (int)($row->Data_length / $row->Rows); | |
| // } | |
| // This is simplified for now. | |
| } | |
| $this->set_results_from_fetched_data($results); | |
| } | |
| private function execute_show_create_table_statement(WP_Parser_Node $node): void { | |
| // MySQL: SHOW CREATE TABLE tbl_name | |
| // PG: No direct single command. Needs to be reconstructed. | |
| // pg_get_functiondef, pg_get_viewdef, pg_get_triggerdef, etc. | |
| // pg_dump -s -t tablename can show DDL. | |
| // information_schema_builder should have the original MySQL DDL or enough info to reconstruct it. | |
| $table_ref_node = $node->get_first_child_node('tableRef'); | |
| if (!$table_ref_node) { /* error */ return; } | |
| $table_name = $this->unquote_pgsql_identifier($this->translate($table_ref_node->get_first_child_node('identifier'))); | |
| $is_temporary = $this->information_schema_builder->temporary_table_exists_in_emulated_schema($table_name); | |
| $mysql_create_sql = $this->information_schema_builder->get_mysql_create_table_statement($is_temporary, $table_name); | |
| if (null === $mysql_create_sql) { | |
| $this->set_results_from_fetched_data(array()); | |
| } else { | |
| $this->set_results_from_fetched_data( | |
| array( | |
| (object) array( | |
| 'Table' => $table_name, // MySQL output format | |
| 'Create Table' => $mysql_create_sql, | |
| ), | |
| ) | |
| ); | |
| } | |
| } | |
| private function execute_describe_statement( WP_Parser_Node $node ): void { | |
| // DESCRIBE is alias for SHOW COLUMNS FROM in MySQL. | |
| // Re-route to execute_show_columns_statement by creating a compatible node structure if needed, | |
| // or directly call with parameters. | |
| // Original code passed $subtree which is 'describeStatement' node. | |
| // It needs a 'tableRef' child. | |
| $this->execute_show_columns_statement($node); // Assuming $node structure is compatible | |
| } | |
| private function execute_use_statement( WP_Parser_Node $node ): void { | |
| // MySQL: USE db_name | |
| // PG: SET search_path TO schema_name; OR new connection. | |
| // This driver assumes one connection, so map to SET search_path. | |
| $db_name_node = $node->get_first_child_node( 'identifier' ); | |
| if (!$db_name_node) { /* error */ return; } | |
| $schema_name = $this->unquote_pgsql_identifier( $this->translate( $db_name_node ) ); | |
| if ( strtolower( $schema_name ) === 'information_schema' ) { | |
| // Special handling for information_schema. PG's is always accessible. | |
| // We might want to make our emulated one the primary if db_name is 'information_schema'. | |
| // This part of the logic needs to be clear about which information_schema is being targeted. | |
| // For now, this might mean setting search_path to PG's information_schema, | |
| // or adjusting internal flags if we have our own set of tables for MySQL's IS. | |
| // $this->current_schema_name = 'information_schema'; // Could be problematic. | |
| // The original driver has a $this->db_name that can be 'information_schema'. | |
| // Let's map MySQL's $this->db_name to $this->current_schema_name for PG. | |
| $this->current_schema_name = $schema_name; // For internal logic/schema builder target. | |
| } elseif ( $schema_name === $this->main_db_name || $schema_name === $this->current_schema_name) { // main_db_name is emulated MySQL DB. current_schema_name is actual PG schema. | |
| $this->current_schema_name = $schema_name; | |
| $this->execute_pgsql_query( 'SET search_path TO ' . $this->quote_pgsql_identifier($schema_name) . ', public' ); | |
| } else { | |
| // Check if this schema exists, then set it. | |
| // SELECT 1 FROM information_schema.schemata WHERE schema_name = ? | |
| // For now, similar to original, restrict to main schema or information_schema. | |
| throw $this->new_not_supported_exception( | |
| sprintf( | |
| "USE command for schema '%s' not fully supported. Current schema: '%s'. Main emulated DB: '%s'.", | |
| $schema_name, | |
| $this->current_schema_name, | |
| $this->main_db_name | |
| ) | |
| ); | |
| } | |
| $this->set_result_from_affected_rows(0); // USE doesn't affect rows | |
| } | |
| private function execute_administration_statement( WP_Parser_Node $node ): void { | |
| // MySQL: ANALYZE, CHECK, OPTIMIZE, REPAIR TABLE | |
| // PG: ANALYZE tbl; VACUUM [FULL] [ANALYZE] tbl; REINDEX TABLE/DATABASE/SCHEMA tbl; | |
| // CHECK TABLE is not directly available. VACUUM VERBOSE or other tools. | |
| // REPAIR TABLE not applicable. PG is ACID compliant, corruption is rare and needs pg_repack/pg_dump restore. | |
| $first_token = $node->get_first_child_token(); | |
| $table_ref_list_node = $node->get_first_child_node( 'tableRefList' ); | |
| if (!$first_token || !$table_ref_list_node) { /* error */ return; } | |
| $results = array(); | |
| foreach ( $table_ref_list_node->get_child_nodes( 'tableRef' ) as $table_ref ) { | |
| $table_name = $this->unquote_pgsql_identifier( $this->translate( $table_ref ) ); | |
| $quoted_table_name = $this->quote_pgsql_identifier( $table_name ); | |
| $operation = strtolower( $first_token->value ); | |
| $msg_type = 'status'; | |
| $msg_text = 'OK'; | |
| try { | |
| switch ( $first_token->id ) { | |
| case WP_MySQL_Lexer::ANALYZE_SYMBOL: | |
| $this->execute_pgsql_query( "ANALYZE " . $quoted_table_name ); | |
| break; | |
| case WP_MySQL_Lexer::OPTIMIZE_SYMBOL: | |
| // OPTIMIZE TABLE in MySQL (InnoDB) often rebuilds table + analyze. | |
| // PG: VACUUM FULL tbl; ANALYZE tbl; REINDEX TABLE tbl; | |
| // VACUUM FULL requires exclusive lock, can be slow. | |
| // Simpler: VACUUM ANALYZE tbl; | |
| $this->execute_pgsql_query( "VACUUM ANALYZE " . $quoted_table_name ); | |
| // For full rebuild effect like OPTIMIZE: | |
| // $this->execute_pgsql_query( "VACUUM FULL " . $quoted_table_name ); | |
| // $this->execute_pgsql_query( "REINDEX TABLE " . $quoted_table_name ); | |
| break; | |
| case WP_MySQL_Lexer::CHECK_SYMBOL: | |
| // No direct CHECK TABLE. Can try to select count(*) to see if readable. | |
| // For more thorough check, AMCHECK extension or similar. | |
| // $this->execute_pgsql_query("SELECT COUNT(*) FROM " . $quoted_table_name); // Simple check | |
| $msg_text = 'CHECK TABLE not directly supported, basic read check performed.'; | |
| break; | |
| case WP_MySQL_Lexer::REPAIR_SYMBOL: | |
| // REPAIR TABLE not applicable/supported in PostgreSQL. | |
| $msg_text = 'REPAIR TABLE is not applicable to PostgreSQL.'; | |
| $msg_type = 'note'; | |
| break; | |
| default: | |
| throw $this->new_not_supported_exception( | |
| sprintf('administration statement type: "%s"', $operation) | |
| ); | |
| } | |
| } catch ( PDOException $e ) { | |
| $msg_type = 'Error'; | |
| $msg_text = $e->getMessage(); | |
| if (str_contains(strtolower($e->getMessage()), "does not exist")) { // PG errors for non-existent table | |
| $msg_text = "Table '$table_name' doesn't exist"; | |
| } | |
| } | |
| $results[] = (object) array( | |
| 'Table' => $this->current_schema_name . '.' . $table_name, // Use actual schema | |
| 'Op' => $operation, | |
| 'Msg_type' => $msg_type, | |
| 'Msg_text' => $msg_text, | |
| ); | |
| } | |
| $this->set_results_from_fetched_data( $results ); | |
| } | |
| private function translate( $node ): ?string { | |
| if ( null === $node ) return null; | |
| if ( $node instanceof WP_MySQL_Token ) { | |
| return $this->translate_token( $node ); | |
| } | |
| if ( ! $node instanceof WP_Parser_Node ) { | |
| throw $this->new_driver_exception(sprintf('Expected WP_Parser_Node or WP_MySQL_Token, got: %s', gettype($node))); | |
| } | |
| // Due to parser stubs, $node->rule_name might be unreliable. | |
| // This entire section is highly dependent on a working parser and well-defined grammar rules. | |
| // The translations below are conceptual based on typical MySQL AST structures. | |
| $rule_name = $node->rule_name; | |
| switch ( $rule_name ) { | |
| case 'querySpecification': // Core of SELECT | |
| // MySQL: SELECT [options] select_list FROM table_references [WHERE] [GROUP BY] [HAVING] [ORDER BY] [LIMIT] | |
| // PG: Similar structure. | |
| // Handle MySQL HAVING without GROUP BY (PG requires GROUP BY or aggregate context for HAVING) | |
| if ( $node->has_child_node( 'havingClause' ) && ! $node->has_child_node( 'groupByClause' ) ) { | |
| $parts = []; | |
| foreach ( $node->get_children() as $child ) { | |
| if ( $child instanceof WP_Parser_Node && 'havingClause' === $child->rule_name ) { | |
| // This is problematic. If there are no aggregate functions in SELECT list, | |
| // and no GROUP BY, HAVING on non-aggregate columns is like WHERE. | |
| // If HAVING contains aggregates, it needs a GROUP BY. | |
| // A simple "GROUP BY 1" might not always be correct or efficient. | |
| // MySQL allows non-aggregated columns in SELECT list if not in GROUP BY (if ONLY_FULL_GROUP_BY is off). | |
| // PG requires them to be in GROUP BY or aggregates. | |
| // $parts[] = 'GROUP BY ???'; // Hard to determine dummy group. | |
| // For now, let the havingClause translate and hope it's valid or PG errors. | |
| } | |
| $part = $this->translate( $child ); | |
| if ( null !== $part ) $parts[] = $part; | |
| } | |
| return implode( ' ', $parts ); | |
| } | |
| return $this->translate_sequence( $node->get_children() ); | |
| // Identifier translations | |
| case 'qualifiedIdentifier': // db.table or table.column or db.table.column (less common) | |
| case 'tableRefWithWildcard': // db.table.* or table.* | |
| case 'fieldIdentifier': // db.table.col or table.col or col | |
| case 'simpleIdentifier': // col (when unambiguous) | |
| case 'pureIdentifier': // Raw identifier token (quoted or unquoted) | |
| case 'dotIdentifier': // `a`.`b` | |
| case 'identifierKeyword': // keyword used as identifier, e.g. `COUNT` | |
| return $this->translate_identifier_node_to_pgsql($node); | |
| case 'textStringLiteral': // 'string' or "string" | |
| return $this->translate_string_literal_for_pgsql( $node ); | |
| case 'dataType': | |
| case 'nchar': // This rule from SQLite driver seems specific. MySQL grammar might differ. | |
| // Translate MySQL data type definition to PostgreSQL. | |
| return $this->translate_datatype_definition_for_pgsql($node); | |
| case 'fromClause': | |
| if ( $node->has_child_token( WP_MySQL_Lexer::DUAL_SYMBOL ) ) { | |
| return null; // FROM DUAL means no table, omit in PG for simple SELECT expression | |
| } | |
| return $this->translate_sequence( $node->get_children() ); | |
| case 'insertUpdateList': // For ON DUPLICATE KEY UPDATE part | |
| // This is specific to MySQL INSERT ... ON DUPLICATE KEY UPDATE | |
| // Translated to ON CONFLICT (...) DO UPDATE SET ... in execute_insert_statement | |
| // Here, we translate the SET assignments part. | |
| // VALUES(col) becomes EXCLUDED.col | |
| $update_list_node = $node->get_first_child_node('updateList'); | |
| if ($update_list_node) { | |
| return $this->translate_update_list_for_conflict($update_list_node); | |
| } | |
| return ""; // Should not be called directly for full clause translation. | |
| case 'simpleExpr': | |
| // Handle MySQL VALUES(col) function if it appears in other contexts (less common) | |
| $token = $node->get_first_child_token(); | |
| if ( $token && WP_MySQL_Lexer::VALUES_SYMBOL === $token->id ) { | |
| // This syntax (VALUES(col) outside ON DUPLICATE KEY) is specific and needs context. | |
| // In PG, for INSERT ... ON CONFLICT, it's EXCLUDED.col. | |
| // If used elsewhere, it's likely an error or needs different translation. | |
| $col_ident_node = $node->get_first_child_node('simpleIdentifier'); // Or fieldIdentifier | |
| if ($col_ident_node) { | |
| return 'EXCLUDED.' . $this->translate($col_ident_node); | |
| } | |
| throw $this->new_not_supported_exception("VALUES() function outside of ON DUPLICATE KEY context."); | |
| } | |
| return $this->translate_sequence( $node->get_children() ); | |
| case 'predicateOperations': // LIKE, REGEXP, BETWEEN, IN, IS NULL, etc. | |
| $first_op_token = $node->get_first_child_token(); | |
| if ($first_op_token) { | |
| if (WP_MySQL_Lexer::LIKE_SYMBOL === $first_op_token->id) { | |
| // MySQL LIKE vs PG LIKE: case sensitivity depends on collation in MySQL. | |
| // PG LIKE is case-sensitive by default. ILIKE is case-insensitive. | |
| // MySQL backslash escaping: '\%' is literal '%'. PG: E'\\%' or standard_conforming_strings=off. | |
| // For simplicity, assume standard PG LIKE. May need ILIKE for case-insensitivity. | |
| // Translate `expr LIKE pattern ESCAPE '\'` (MySQL default) | |
| return $this->translate_sequence($node->get_children()) . " ESCAPE '\\'"; | |
| } elseif (WP_MySQL_Lexer::REGEXP_SYMBOL === $first_op_token->id) { | |
| // MySQL REGEXP/RLIKE uses C library regex. PG uses POSIX EREs. | |
| // expr REGEXP pattern -> expr ~ pattern (case-sensitive) or expr ~* pattern (case-insensitive) | |
| // MySQL REGEXP is case-insensitive by default unless used with BINARY. | |
| $expr_node = $node->get_child_nodes()[0]; // Before REGEXP token | |
| $pattern_node = $node->get_child_nodes()[2]; // After REGEXP token | |
| $operator = $node->has_child_token(WP_MySQL_Lexer::BINARY_SYMBOL) ? " ~ " : " ~* "; | |
| return $this->translate($expr_node) . $operator . $this->translate($pattern_node); | |
| } | |
| } | |
| return $this->translate_sequence( $node->get_children() ); | |
| case 'runtimeFunctionCall': // NOW(), CURRENT_TIMESTAMP(), DATE_ADD(), etc. | |
| return $this->translate_runtime_function_call_for_pgsql( $node ); | |
| case 'functionCall': // User-defined or other standard functions | |
| return $this->translate_function_call_for_pgsql( $node ); | |
| case 'systemVariable': // @@varname | |
| return $this->translate_system_variable_for_pgsql($node); | |
| case 'castType': // For CAST(... AS type) | |
| // MySQL CAST(... AS BINARY) -> PG CAST(... AS BYTEA) | |
| if ( $node->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) { | |
| // If it's CAST(string AS BINARY(N)), it's about fixed length binary string. | |
| // CAST(string AS BINARY) is like VARBINARY. Both map to BYTEA in PG. | |
| // Length modifier for BINARY type needs careful handling. | |
| $length_node = $node->get_first_child_node('fieldLength'); | |
| $length = ""; | |
| if ($length_node) { | |
| // PG BYTEA doesn't take length. If fixed length is critical, needs custom type or padding. | |
| // For now, ignore length for BYTEA cast. | |
| } | |
| return 'BYTEA'; | |
| } | |
| // Other types: UNSIGNED, SIGNED for integers | |
| if ($node->has_child_token(WP_MySQL_Lexer::UNSIGNED_SYMBOL)) return 'BIGINT'; // Or INTEGER, depends on source | |
| if ($node->has_child_token(WP_MySQL_Lexer::SIGNED_SYMBOL)) return 'BIGINT'; // Or INTEGER | |
| // For standard types like CHAR, VARCHAR, DECIMAL, translate the data type node. | |
| $data_type_node = $node->get_first_child_node('dataType'); | |
| if ($data_type_node) { | |
| return $this->translate_datatype_definition_for_pgsql($data_type_node); | |
| } | |
| return $this->translate_sequence( $node->get_children() ); | |
| // MySQL specific clauses that might not have direct PG equivalents or need removal | |
| case 'defaultCollation': // COLLATE clause for table/db default (PG has per-column/db) | |
| case 'indexHint': // USE INDEX, IGNORE INDEX, FORCE INDEX (PG has no direct hints like this) | |
| case 'indexHintList': | |
| return null; // Remove/ignore these hints | |
| case 'duplicateAsQueryExpression': // For CREATE TABLE ... AS SELECT ... ON DUPLICATE KEY ... | |
| // This structure is MySQL specific. PG CREATE TABLE ... AS has no ON DUPLICATE. | |
| // This would be handled by post-CREATE INDEX and then INSERT ... ON CONFLICT. | |
| // Here, just translate the AS SELECT part. | |
| return 'AS ' . $this->translate( $node->get_first_child_node() ); // Assuming first child is query expr | |
| default: | |
| return $this->translate_sequence( $node->get_children() ); | |
| } | |
| } | |
| private function translate_token( WP_MySQL_Token $token ): ?string { | |
| switch ( $token->id ) { | |
| case WP_MySQL_Lexer::EOF: | |
| return null; | |
| case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL: | |
| // This is a column attribute. In PG, SERIAL/IDENTITY handles this. | |
| // If it appears elsewhere, it's context-dependent. | |
| // For CREATE TABLE column def, it implies SERIAL. | |
| // For now, return null as it's handled by datatype translation. | |
| return null; | |
| case WP_MySQL_Lexer::BINARY_SYMBOL: | |
| // Keyword `BINARY` before a string literal forces binary collation/comparison. | |
| // e.g. SELECT BINARY 'a' = 'A'; (false) | |
| // In PG, this is achieved by casting to BYTEA or specific collations. | |
| // `COLLATE "C"` or `COLLATE "POSIX"` often gives byte-wise comparison for text. | |
| // `_binary` keyword in MySQL can also mean `VARBINARY` type. | |
| // Its translation is highly contextual. If it's a prefix to a string, it affects comparison. | |
| // If it's a type, handled by datatype translation. | |
| // For now, if it's a standalone token, remove it and handle contextually. | |
| return null; | |
| case WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL: | |
| // Handled in execute_select_statement. Remove from direct translation. | |
| return null; | |
| default: | |
| // Default behavior for most keywords and operators if their names are same/similar. | |
| // e.g. SELECT, FROM, WHERE, AND, OR, TRUE, FALSE, NULL, AS, etc. | |
| // Quoting style for identifiers is handled by translate_identifier_node_to_pgsql. | |
| return $token->value; | |
| } | |
| } | |
| private function translate_sequence( array $nodes, string $separator = ' ' ): ?string { | |
| $parts = array(); | |
| foreach ( $nodes as $node ) { | |
| if ( null === $node ) continue; | |
| $translated = $this->translate( $node ); | |
| if ( null === $translated || $translated === '' ) continue; // Skip empty translations | |
| $parts[] = $translated; | |
| } | |
| if ( empty( $parts ) ) return null; | |
| return implode( $separator, $parts ); | |
| } | |
| private function translate_identifier_node_to_pgsql(WP_Parser_Node $node): string { | |
| // Takes various identifier-related AST nodes and quotes them for PostgreSQL. | |
| // MySQL `ident` -> PG "ident" | |
| // MySQL `db`.`table` -> PG "db"."table" (if db is schema) or "table" (if db is current_database and implicit) | |
| $raw_identifier_parts = []; | |
| // This needs to correctly extract parts of a qualified name, e.g., schema.table.column | |
| // The logic from original driver was specific to its `qualifiedIdentifier` etc. rules. | |
| // For a generic approach with stubs: | |
| foreach ($node->get_descendant_tokens() as $token) { | |
| // Filter out dots, backticks if they are separate tokens in AST. | |
| // This depends on how the (stubbed) parser creates identifier nodes. | |
| if ($token->id === WP_MySQL_Lexer::IDENTIFIER || | |
| $token->id === WP_MySQL_Lexer::BACK_TICK_QUOTED_ID || | |
| $token->id === WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT // MySQL can use " for ids if not in ANSI mode | |
| ) { | |
| $raw_identifier_parts[] = $this->unquote_mysql_identifier_token($token); | |
| } | |
| } | |
| if (empty($raw_identifier_parts)) { | |
| // If no specific tokens, try to get value of node (less reliable with stubs) | |
| // This is a fallback | |
| $str_val = ""; | |
| foreach($node->get_children() as $child) { // Get raw text from children | |
| if ($child instanceof WP_MySQL_Token) $str_val .= $child->value; | |
| elseif ($child instanceof WP_Parser_Node) $str_val .= $this->translate_identifier_node_to_pgsql($child); // Recurse carefully | |
| } | |
| $str_val = str_replace('`', '', $str_val); // Remove backticks | |
| $parts = explode('.', $str_val); | |
| $raw_identifier_parts = array_map('trim', $parts); | |
| } | |
| $translated_parts = []; | |
| $num_parts = count($raw_identifier_parts); | |
| if ($num_parts === 0) return ""; | |
| // Handle schema.table.column or schema.table or table.column or table | |
| $is_information_schema_target = false; | |
| $first_part_lower = strtolower($raw_identifier_parts[0]); | |
| if ($num_parts > 1 && ($first_part_lower === $this->main_db_name || $first_part_lower === $this->current_schema_name)) { | |
| // If first part is current emulated DB or current PG schema, it's redundant if search_path includes it. | |
| // Keep it for explicit qualification. | |
| $translated_parts[] = $this->quote_pgsql_identifier($raw_identifier_parts[0]); // This will be schema | |
| array_shift($raw_identifier_parts); // Consume first part | |
| } elseif ($num_parts > 1 && $first_part_lower === 'information_schema') { | |
| // Targeting PostgreSQL's information_schema. | |
| $translated_parts[] = $this->quote_pgsql_identifier('information_schema'); | |
| array_shift($raw_identifier_parts); | |
| $is_information_schema_target = true; | |
| } | |
| // Remaining parts are table/column | |
| foreach($raw_identifier_parts as $part) { | |
| $translated_parts[] = $this->quote_pgsql_identifier($part); | |
| } | |
| $final_identifier = implode('.', $translated_parts); | |
| // Check for reserved prefix if not information_schema | |
| if (!$is_information_schema_target && str_contains($final_identifier, self::RESERVED_PREFIX)) { | |
| // This check needs to be careful. If final_identifier is "schema"."_wp_pgsql_table", it's fine. | |
| // If it's "_wp_pgsql_schema"."table", also fine. | |
| // The original check was on unquoted parts. | |
| foreach ($raw_identifier_parts as $part) { // Check original unquoted parts | |
| if (str_starts_with($part, self::RESERVED_PREFIX)) { | |
| throw $this->new_driver_exception( | |
| sprintf("Invalid identifier component %s, prefix '%s' is reserved", $part, self::RESERVED_PREFIX) | |
| ); | |
| } | |
| } | |
| } | |
| return $final_identifier; | |
| } | |
| private function unquote_mysql_identifier_token(WP_MySQL_Token $token): string { | |
| $value = $token->value; | |
| if ($token->id === WP_MySQL_Lexer::BACK_TICK_QUOTED_ID) { | |
| return str_replace('``', '`', substr($value, 1, -1)); | |
| } | |
| if ($token->id === WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT) { // If MySQL uses " for identifiers | |
| return str_replace('""', '"', substr($value, 1, -1)); | |
| } | |
| return $value; // Already unquoted (IDENTIFIER token) | |
| } | |
| private function translate_string_literal_for_pgsql(WP_Parser_Node $node): string { | |
| $token = $node->get_first_child_token(); // textStringLiteral usually has one token child | |
| if (!$token) return "''"; // Empty string if node is malformed | |
| $value = $token->value; // e.g. 'foo \' bar' or "foo \" bar" | |
| // 1. Determine quote type and unquote MySQL way | |
| $mysql_quote_char = $value[0]; | |
| $inner_value = substr($value, 1, -1); | |
| if ($mysql_quote_char === "'") { | |
| // Unescape MySQL specific: \' -> ', '' -> ' (already handled if parser gives ' as value for '') | |
| // Standard SQL escaping \' is not a thing, '' is ' | |
| $inner_value = str_replace("\\'", "'", $inner_value); | |
| $inner_value = str_replace("\\\"", "\"", $inner_value); // other escapes if any | |
| } elseif ($mysql_quote_char === '"') { | |
| // Unescape MySQL specific: \" -> ", "" -> " | |
| $inner_value = str_replace("\\\"", "\"", $inner_value); | |
| $inner_value = str_replace("\\'", "'", $inner_value); | |
| } | |
| // Unescape standard C-style escapes like \n, \t, \r, \\, \0 | |
| // MySQL processes these. PostgreSQL also processes them in E'' strings. | |
| // If standard_conforming_strings = on (default), backslash has no special meaning in PG normal strings. | |
| // Use E'' for safety to ensure backslashes are interpreted. | |
| $inner_value = stripcslashes($inner_value); // This handles \n, \t, etc. and \\ -> \ | |
| // Re-quote for PostgreSQL using standard single quotes, escaping internal single quotes. | |
| // $pgsql_escaped_value = str_replace("'", "''", $inner_value); | |
| // return "'" . $pgsql_escaped_value . "'"; | |
| // Using E'' syntax for PostgreSQL to handle C-style escapes if standard_conforming_strings is on. | |
| // Need to escape backslashes and single quotes for E'' | |
| $pgsql_escaped_value = str_replace("\\", "\\\\", $inner_value); // Escape backslashes first | |
| $pgsql_escaped_value = str_replace("'", "''", $pgsql_escaped_value); // Escape single quotes | |
| // MySQL's "zero date" handling: | |
| // The original code had translate_datetime_literal. PG errors on '0000-00-00'. | |
| // If $inner_value matches a zero date, we might need to translate it to NULL or throw error. | |
| if ($inner_value === '0000-00-00' || $inner_value === '0000-00-00 00:00:00') { | |
| if ($this->is_sql_mode_active('NO_ZERO_DATE') || $this->is_sql_mode_active('NO_ZERO_IN_DATE')) { | |
| // This mode is for MySQL behavior. PG is always like this. | |
| // Depending on context (e.g. INSERT vs SELECT literal), this could be NULL or error. | |
| // For a literal, it might be better to let PG handle it (which will likely be an error if used as date/ts). | |
| // Or translate to NULL explicitly if that's desired emulation. | |
| // return 'NULL'; // Option: translate invalid dates to NULL | |
| } | |
| } | |
| // @TODO: Proper quoting | |
| return "E'" . $pgsql_escaped_value . "'"; | |
| } | |
| // Helper to unquote string literals for use in LIKE patterns, etc. | |
| private function unquote_string_literal(string $quoted_literal): string { | |
| if (str_starts_with(strtoupper($quoted_literal), "E'")) { | |
| $val = substr($quoted_literal, 2, -1); | |
| // Unescape for E'' literals: '' -> ', \\ -> \ | |
| $val = str_replace("''", "'", $val); | |
| $val = str_replace("\\\\", "\\", $val); // Basic unescaping for E strings. More might be needed. | |
| return $val; | |
| } elseif (str_starts_with($quoted_literal, "'")) { | |
| $val = substr($quoted_literal, 1, -1); | |
| return str_replace("''", "'", $val); // Standard SQL unquoting | |
| } | |
| return $quoted_literal; // Not a recognized quoted string | |
| } | |
| private function translate_datatype_definition_for_pgsql(WP_Parser_Node $node): string { | |
| // This is for CREATE TABLE / ALTER TABLE column type definitions. | |
| // Needs to parse the MySQL type from AST and map to PG equivalent. | |
| // Example MySQL: VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci | |
| // Example MySQL: INT(11) UNSIGNED ZEROFILL | |
| // PG: VARCHAR(100) (charset/collation is db/column specific, not usually in type def like this) | |
| // PG: INTEGER (unsigned/zerofill are not direct PG features, need CHECK constraints or display handling) | |
| // Simplified translation based on self::DATA_TYPE_MAP and potential length. | |
| // Full translation needs to handle (M), (M,D), UNSIGNED, ZEROFILL, CHARACTER SET, COLLATE. | |
| $data_type_token = $node->get_first_child_token(); // First token usually is the base type | |
| $mysql_type_name_token_id = null; | |
| if ($data_type_token) { | |
| $mysql_type_name_token_id = $data_type_token->id; | |
| } else { | |
| // Fallback: try to get name from node if it's a wrapper like 'nchar' rule. | |
| $child_node = $node->get_first_child_node(); | |
| if ($child_node) { | |
| $data_type_token = $child_node->get_first_child_token(); | |
| if ($data_type_token) $mysql_type_name_token_id = $data_type_token->id; | |
| } | |
| } | |
| if (!$mysql_type_name_token_id) { | |
| throw $this->new_driver_exception("Could not determine base data type from AST node."); | |
| } | |
| $pg_type = self::DATA_TYPE_MAP[$mysql_type_name_token_id] ?? 'TEXT'; // Default | |
| // Handle SERIAL alias specifically for CREATE TABLE | |
| if ($mysql_type_name_token_id === WP_MySQL_Lexer::SERIAL_SYMBOL) { | |
| // SERIAL in MySQL is BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE | |
| return 'BIGSERIAL'; // PG BIGSERIAL is BIGINT NOT NULL with sequence. | |
| } | |
| $length_str = ""; | |
| // Extract (M) or (M,D) from fieldLength / precision nodes if they exist in $node's children | |
| $field_length_node = $node->get_first_descendant_node('fieldLength'); | |
| if ($field_length_node) { | |
| $length_str = $this->translate($field_length_node); // Gets e.g. "(255)" | |
| } else { | |
| $precision_node = $node->get_first_descendant_node('precision'); | |
| if ($precision_node) { | |
| $length_str = $this->translate($precision_node); // Gets e.g. "(10,2)" | |
| } | |
| } | |
| $full_pg_type = $pg_type; | |
| if (!empty($length_str) && ($pg_type === 'CHAR' || $pg_type === 'VARCHAR' || $pg_type === 'BIT' || $pg_type === 'NUMERIC' || $pg_type === 'DECIMAL')) { | |
| $full_pg_type .= $length_str; | |
| } | |
| // UNSIGNED attribute: PG has no direct unsigned types. | |
| // Typically map to a larger signed type or add a CHECK constraint. | |
| // e.g., INT UNSIGNED (max 4 billion) -> BIGINT (max 9 quintillion) + CHECK (col >= 0) | |
| if ($node->has_child_token(WP_MySQL_Lexer::UNSIGNED_SYMBOL)) { | |
| if ($pg_type === 'SMALLINT') $full_pg_type = 'INTEGER'; // TINYINT UNSIGNED, SMALLINT UNSIGNED | |
| else if ($pg_type === 'INTEGER') $full_pg_type = 'BIGINT'; // MEDIUMINT UNSIGNED, INT UNSIGNED | |
| // BIGINT UNSIGNED is problematic as PG BIGINT is max. | |
| // Add " CHECK (colname >= 0)" later if needed. | |
| } | |
| // ZEROFILL attribute: Display formatting, not a storage characteristic in PG. Ignored. | |
| // CHARACTER SET / COLLATE: In PG, these are usually db-level or explicit COLLATE clause per column. | |
| // The information_schema_builder should store these MySQL attributes, but for PG type def, they are often omitted. | |
| // Can add `COLLATE "C"` for binary-like sorting if MySQL type implies it. | |
| return $full_pg_type; | |
| } | |
| private function translate_runtime_function_call_for_pgsql(WP_Parser_Node $node): string { | |
| $first_child = $node->get_first_child(); // Can be token (CURRENT_TIMESTAMP) or node (functionName) | |
| $func_name_token = null; | |
| if ($first_child instanceof WP_MySQL_Token) { | |
| $func_name_token = $first_child; | |
| } elseif ($first_child instanceof WP_Parser_Node) { // If it's a functionName node | |
| $func_name_token = $first_child->get_first_child_token(); | |
| } | |
| if (!$func_name_token) throw $this->new_driver_exception("Unknown runtime function structure."); | |
| $mysql_func_name_upper = strtoupper($func_name_token->value); | |
| $args_node = $node->get_first_child_node('exprList'); // Or similar for arguments | |
| $translated_args = []; | |
| if ($args_node) { | |
| foreach($args_node->get_child_nodes() as $arg_expr_node) { | |
| $translated_args[] = $this->translate($arg_expr_node); | |
| } | |
| } | |
| switch ($mysql_func_name_upper) { | |
| case 'CURRENT_TIMESTAMP': | |
| case 'NOW': | |
| return 'CURRENT_TIMESTAMP'; // PG CURRENT_TIMESTAMP includes timezone info based on session. | |
| case 'DATE_ADD': // DATE_ADD(date, INTERVAL expr unit) | |
| case 'ADDDATE': // Synonym | |
| // PG: date + INTERVAL 'value unit' OR date + (value * INTERVAL '1 unit') | |
| // MySQL unit: MICROSECOND, SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, QUARTER, YEAR | |
| // PG unit: microseconds, seconds, minutes, hours, days, weeks, months, years | |
| if (count($translated_args) === 2) { // date, interval_expr_node | |
| $date_str = $translated_args[0]; | |
| // Argument 2 needs special parsing for INTERVAL expr unit | |
| $interval_node = $args_node->get_child_nodes()[1]; // The expr node for interval | |
| // Need to find 'INTERVAL_SYMBOL', 'expr', 'unit' from $interval_node. This is complex. | |
| // Assuming interval_node is "INTERVAL 1 DAY" -> translate to PG "'1 day'::interval" | |
| // This simplified translation below assumes direct mapping of value and unit. | |
| // A full solution would parse the MySQL INTERVAL expression. | |
| // $interval_value_str = ... | |
| // $interval_unit_str = ... | |
| // For now, let's assume $translated_args[1] is a string like '1 DAY' from some pre-translation. | |
| // This is a placeholder for proper INTERVAL translation. | |
| // return $date_str . " + " . $this->translate_mysql_interval_to_pg($translated_args[1]); | |
| throw $this->new_not_supported_exception("DATE_ADD/DATE_SUB interval translation requires deeper AST parsing."); | |
| } | |
| break; | |
| case 'DATE_SUB': // DATE_SUB(date, INTERVAL expr unit) | |
| case 'SUBDATE': | |
| if (count($translated_args) === 2) { | |
| // return $translated_args[0] . " - " . $this->translate_mysql_interval_to_pg($translated_args[1]); | |
| throw $this->new_not_supported_exception("DATE_ADD/DATE_SUB interval translation requires deeper AST parsing."); | |
| } | |
| break; | |
| case 'LEFT': // LEFT(str, len) | |
| if (count($translated_args) === 2) return "LEFT(" . $translated_args[0] . ", " . $translated_args[1] . ")"; | |
| break; | |
| // Add more runtime functions here | |
| default: // Fallback to use same name and translated args | |
| return $mysql_func_name_upper . "(" . implode(", ", $translated_args) . ")"; | |
| } | |
| throw $this->new_not_supported_exception("Runtime function: " . $mysql_func_name_upper); | |
| } | |
| private function translate_function_call_for_pgsql(WP_Parser_Node $node): string { | |
| $func_name_node = $node->get_first_child_node(); // Assuming functionName node | |
| if (!$func_name_node) throw $this->new_driver_exception("Invalid function call AST."); | |
| $mysql_func_name_upper = strtoupper($this->unquote_pgsql_identifier($this->translate($func_name_node))); | |
| $args_list_node = $node->get_first_child_node('udfExprList') ?? $node->get_first_child_node('exprList'); | |
| $translated_args = []; | |
| if ($args_list_node) { | |
| foreach ($args_list_node->get_child_nodes() as $arg_node) { | |
| $translated_args[] = $this->translate($arg_node); | |
| } | |
| } | |
| switch ($mysql_func_name_upper) { | |
| case 'DATABASE': return 'CURRENT_DATABASE()'; | |
| case 'SCHEMA': return 'CURRENT_SCHEMA()'; // MySQL SCHEMA() is synonym for DATABASE() | |
| case 'USER': return 'CURRENT_USER'; | |
| case 'SESSION_USER': return 'SESSION_USER'; | |
| case 'SYSTEM_USER': return 'SESSION_USER'; // Closest PG equivalent | |
| case 'VERSION': return 'VERSION()'; // PG version() gives full string including platform | |
| case 'CONCAT': // CONCAT(str1, str2, ...) | |
| // PG CONCAT function ignores NULLs. MySQL CONCAT returns NULL if any arg is NULL. | |
| // PG || operator propagates NULLs. | |
| // To match MySQL, if any arg could be NULL, need COALESCE or ensure args are not NULL. | |
| // Or, if strict NULL propagation is MySQL behavior: | |
| // return "(" . implode(" || ", $translated_args) . ")"; | |
| return "CONCAT(" . implode(", ", $translated_args) . ")"; // PG CONCAT is often fine. | |
| case 'CONCAT_WS': // CONCAT_WS(separator, str1, str2, ...) | |
| // PG CONCAT_WS is similar. | |
| if (count($translated_args) >= 1) { | |
| return "CONCAT_WS(" . implode(", ", $translated_args) . ")"; | |
| } | |
| break; | |
| case 'IFNULL': // IFNULL(expr1, expr2) | |
| case 'NVL': // Oracle synonym sometimes used | |
| if (count($translated_args) === 2) return "COALESCE(" . $translated_args[0] . ", " . $translated_args[1] . ")"; | |
| break; | |
| case 'IF': // IF(condition, val_if_true, val_if_false) | |
| if (count($translated_args) === 3) { | |
| return "CASE WHEN " . $translated_args[0] . " THEN " . $translated_args[1] . " ELSE " . $translated_args[2] . " END"; | |
| } | |
| break; | |
| case 'RAND': // RAND() or RAND(N) | |
| // PG RANDOM() is 0.0 <= x < 1.0. MySQL RAND() is same. | |
| // MySQL RAND(N) seed behavior: PG setseed() affects subsequent RANDOM() in same session. | |
| if (!empty($translated_args)) { // RAND(N) | |
| // $this->execute_pgsql_query("SELECT setseed(" . $translated_args[0] . ")"); // Set seed | |
| // return "RANDOM()"; | |
| // This session-wide seed setting might be undesirable. | |
| // For reproducible sequence with seed, need custom plpgsql function or different approach. | |
| throw $this->new_not_supported_exception("RAND(N) with seed. Use PG setseed() + RANDOM() carefully."); | |
| } | |
| return "RANDOM()"; | |
| case 'DATE_FORMAT': // DATE_FORMAT(date, format_str) | |
| if (count($translated_args) === 2) { | |
| $date_expr = $translated_args[0]; | |
| $mysql_format_literal = $translated_args[1]; // Assume it's a literal string '...' | |
| $mysql_format_str = $this->unquote_string_literal($mysql_format_literal); | |
| $pg_format_str = ""; | |
| // Convert MySQL format specifiers to PG TO_CHAR specifiers | |
| // This is complex due to differences. | |
| // Example: %Y-%m-%d -> YYYY-MM-DD | |
| $pg_format_str = strtr($mysql_format_str, self::MYSQL_DATE_FORMAT_TO_PG_TO_CHAR_MAP); | |
| // Handle %% -> % literal if any | |
| $pg_format_str = str_replace('%%', '%', $pg_format_str); | |
| return "TO_CHAR(" . $date_expr . "::timestamp, " . $this->pdo->quote($pg_format_str) . ")"; | |
| } | |
| break; | |
| case 'STR_TO_DATE': // STR_TO_DATE(str, format_str) | |
| if (count($translated_args) === 2) { | |
| $str_expr = $translated_args[0]; | |
| $mysql_format_literal = $translated_args[1]; | |
| $mysql_format_str = $this->unquote_string_literal($mysql_format_literal); | |
| $pg_format_str = strtr($mysql_format_str, self::MYSQL_DATE_FORMAT_TO_PG_TO_CHAR_MAP); // Re-use map, some overlap | |
| $pg_format_str = str_replace('%%', '%', $pg_format_str); | |
| // Determine if it's date or timestamp based on format. | |
| // If format includes H, M, S -> TO_TIMESTAMP, else TO_DATE. | |
| if (preg_match('/(HH|MI|SS|MS|US)/i', $pg_format_str)) { | |
| return "TO_TIMESTAMP(" . $str_expr . ", " . $this->pdo->quote($pg_format_str) . ")"; | |
| } else { | |
| return "TO_DATE(" . $str_expr . ", " . $this->pdo->quote($pg_format_str) . ")"; | |
| } | |
| } | |
| break; | |
| case 'FOUND_ROWS': // FOUND_ROWS() | |
| // Requires prior SQL_CALC_FOUND_ROWS. Value stored in $this->last_sql_calc_found_rows. | |
| $found_rows_val = $this->last_sql_calc_found_rows ?? 0; | |
| // If used in SELECT list, an alias is needed. If used in expression, just the value. | |
| // return (string)$found_rows_val; // As a literal value | |
| // If used as "SELECT FOUND_ROWS()", then: | |
| return "(SELECT " . (int)$found_rows_val . ") AS FOUND_ROWS"; // For SELECT list compatibility | |
| case 'LAST_INSERT_ID': // LAST_INSERT_ID() or LAST_INSERT_ID(expr) | |
| // PG: CURRVAL(pg_get_serial_sequence('table', 'id_col')) | |
| // This is hard to translate generally without knowing table/sequence. | |
| // PDO::lastInsertId() is preferred way to get this after an INSERT. | |
| // If used in a general expression, it's problematic. | |
| // MySQL LAST_INSERT_ID(expr) sets the value for next LAST_INSERT_ID(). PG has no such concept. | |
| if (!empty($translated_args)) throw $this->new_not_supported_exception("LAST_INSERT_ID(expr)"); | |
| // Rely on $this->get_insert_id() to have been called after an insert. | |
| // This needs a mechanism to pass the sequence name if not using RETURNING. | |
| // For now, assume it might be used in subquery after insert (bad practice). | |
| // A placeholder that will likely fail or return 0 if not immediately after an insert. | |
| return "(SELECT 0)"; // Placeholder - this needs context of prior INSERT. | |
| case 'GROUP_CONCAT': | |
| // GROUP_CONCAT([DISTINCT] expr [, expr ...] [ORDER BY ...] [SEPARATOR str]) | |
| // PG: STRING_AGG(expression, delimiter [ORDER BY ...]) | |
| // PG STRING_AGG doesn't have DISTINCT directly inside. Use subquery for DISTINCT. | |
| $separator = ","; // Default MySQL separator | |
| $order_by_clause = ""; | |
| $expr_to_agg = ""; | |
| // This requires parsing the arguments of GROUP_CONCAT from $args_list_node | |
| // For now, assume simple form: GROUP_CONCAT(col SEPARATOR ';') | |
| $expr_to_agg = $translated_args[0]; // First arg is expression | |
| if (count($translated_args) > 1) { | |
| // Look for SEPARATOR | |
| // This is a very simplified parsing of GROUP_CONCAT args | |
| // A full parser for function args is needed. | |
| // for ($i=1; $i < count($translated_args); $i++) { | |
| // if (is_separator_clause($translated_args[$i])) $separator = extract_separator($translated_args[$i]); | |
| // if (is_orderby_clause($translated_args[$i])) $order_by_clause = translate_orderby($translated_args[$i]); | |
| // } | |
| } | |
| // For now, hardcode separator or assume it's passed if $translated_args[1] is the separator. | |
| if (isset($translated_args[1])) $separator = $this->unquote_string_literal($translated_args[1]); | |
| // Handle DISTINCT, ORDER BY from MySQL AST if available. | |
| // $distinct_str = $node->has_distinct_keyword ? "DISTINCT " : ""; | |
| // $expr_to_agg = $distinct_str . $expr_to_agg; | |
| return "STRING_AGG(" . $expr_to_agg . ", " . $this->pdo->quote($separator) . $order_by_clause . ")"; | |
| // Add more function translations... MD5, SHA1, etc. | |
| case 'MD5': | |
| if (count($translated_args) === 1) return "MD5(" . $translated_args[0] . ")"; // PG MD5 needs pgcrypto | |
| break; | |
| case 'SHA1': // MySQL SHA1 returns hex string. PG SHA1 digest function returns bytea. | |
| if (count($translated_args) === 1) { | |
| // return "ENCODE(SHA1(" . $translated_args[0] . "::bytea), 'hex')"; // Requires pgcrypto | |
| throw $this->new_not_supported_exception("SHA1 (requires pgcrypto and careful type handling for input)."); | |
| } | |
| break; | |
| default: // Fallback: use MySQL name, hope PG has compatible function or it's an error. | |
| return $mysql_func_name_upper . "(" . implode(", ", $translated_args) . ")"; | |
| } | |
| throw $this->new_not_supported_exception("Function: " . $mysql_func_name_upper); | |
| } | |
| private function translate_system_variable_for_pgsql(WP_Parser_Node $node): string { | |
| // MySQL: @@varname, @@GLOBAL.varname, @@SESSION.varname | |
| // PG: SHOW varname; current_setting('varname'); | |
| // Or specific functions like current_database(), current_schema(), session_user. | |
| // Extract varname and scope (GLOBAL/SESSION) from $node | |
| $var_name_node = $node->get_first_child_node('textOrIdentifier'); // Or similar path in AST | |
| if (!$var_name_node) throw $this->new_driver_exception("Cannot parse system variable name."); | |
| $mysql_var_name = strtolower($this->unquote_pgsql_identifier($this->translate($var_name_node))); | |
| $scope_node = $node->get_first_child_node('varIdentType'); // e.g. GLOBAL., SESSION. | |
| $is_global = $scope_node && strtoupper($this->translate($scope_node)) === 'GLOBAL'; | |
| // Map common MySQL vars to PG settings or functions | |
| switch ($mysql_var_name) { | |
| case 'version': return "VERSION()"; | |
| case 'database': // Note: MySQL @@database is function DATABASE() | |
| case 'schema': // MySQL @@schema is function SCHEMA() | |
| return "CURRENT_SCHEMA()"; // Or CURRENT_DATABASE() depending on MySQL meaning | |
| case 'session.tx_isolation': | |
| case 'tx_isolation': | |
| return "current_setting('transaction_isolation')"; | |
| case 'session.time_zone': | |
| case 'time_zone': | |
| return "current_setting('TimeZone')"; | |
| case 'character_set_client': | |
| case 'character_set_connection': | |
| case 'character_set_results': | |
| return "current_setting('client_encoding')"; | |
| case 'collation_connection': | |
| case 'collation_database': | |
| case 'collation_server': | |
| // PG collation is more complex. Default from DB, or per-column. | |
| // current_setting('lc_collate') might be relevant for server/db default. | |
| return "(SELECT datcollate FROM pg_database WHERE datname = current_database())"; // DB default collation | |
| case 'sql_mode': | |
| // Return emulated $this->active_sql_modes | |
| return $this->pdo->quote(implode(',', $this->active_sql_modes)); | |
| case 'auto_increment_increment': | |
| case 'auto_increment_offset': | |
| // PG sequences have increment and start value. Not a direct session var. | |
| // This usually applies to table def. If read as var, return dummy or typical MySQL value. | |
| return '1'; // Default for MySQL usually | |
| case 'last_insert_id': // @@last_insert_id | |
| // This is tricky. In PG, it's sequence-specific (CURRVAL) or from RETURNING. | |
| // It's a function call in MySQL (LAST_INSERT_ID()), not typically @@var. | |
| // For now, return 0 or throw error. | |
| return '0'; /* Placeholder */ | |
| default: | |
| if ($is_global) { | |
| // Reading global MySQL vars often maps to pg_settings with context 'postmaster' or 'superuser' | |
| // For simplicity, many global vars won't map directly. | |
| throw $this->new_not_supported_exception("Global variable @@GLOBAL." . $mysql_var_name); | |
| } | |
| // For session vars, try current_setting if PG has an equivalent. | |
| // return "current_setting(" . $this->pdo->quote($mysql_var_name) . ", true)"; // true = missing_ok | |
| // The above is too generic. Fallback to quoted var name for "AS" clause in SELECT. | |
| // Or, if it's a known MySQL var without PG equivalent, return its typical default or NULL. | |
| return $this->pdo->quote('Unsupported MySQL Variable: ' . $mysql_var_name); // As a string value | |
| } | |
| } | |
| /** | |
| * Get an array of PostgreSQL CREATE TABLE and related statements (indexes, constraints) | |
| * based on data previously stored by the Information_Schema_Builder. | |
| */ | |
| private function get_pgsql_create_table_statement( | |
| bool $table_is_temporary, | |
| string $table_name, | |
| ?string $new_table_name = null // For ALTER TABLE emulation (rename during recreate) | |
| ): array { | |
| $actual_table_name = $new_table_name ?? $table_name; | |
| $quoted_actual_table_name = $this->quote_pgsql_identifier($actual_table_name); | |
| $table_info = $this->information_schema_builder->get_emulated_table_info($table_name, $table_is_temporary); | |
| if (!$table_info) { | |
| throw $this->new_driver_exception("Table '$table_name' not found in emulated schema for CREATE statement generation."); | |
| } | |
| $column_infos = $this->information_schema_builder->get_emulated_column_info($table_name, $table_is_temporary); | |
| $constraint_infos = $this->information_schema_builder->get_emulated_constraint_info($table_name, $table_is_temporary); // Includes indexes | |
| $column_definitions = []; | |
| $table_constraints = []; // For multi-column PK, UNIQUE, CHECK, FK | |
| $create_index_queries = []; | |
| $alter_col_sequences = []; // For SERIAL/IDENTITY columns after table creation | |
| foreach ($column_infos as $col) { | |
| $col_def_parts = []; | |
| $col_def_parts[] = $this->quote_pgsql_identifier($col['COLUMN_NAME']); | |
| // Map MySQL type string (from COLUMN_TYPE) to PG type string | |
| $pg_type_str = $this->map_mysql_coltype_to_pgsql_coltype($col['COLUMN_TYPE'], $col['EXTRA']); | |
| $col_def_parts[] = $pg_type_str; | |
| if ($col['IS_NULLABLE'] === 'NO') { | |
| $col_def_parts[] = "NOT NULL"; | |
| } | |
| if ($col['COLUMN_DEFAULT'] !== null) { | |
| // Handle CURRENT_TIMESTAMP, expressions vs literals | |
| if (strtoupper($col['COLUMN_DEFAULT']) === 'CURRENT_TIMESTAMP' && | |
| in_array(strtolower($col['DATA_TYPE']), ['timestamp', 'datetime'])) { | |
| $col_def_parts[] = "DEFAULT CURRENT_TIMESTAMP"; | |
| } else { | |
| // Default value might need casting if it's a string for a numeric type, etc. | |
| // $default_val_translated = $this->translate_default_value($col['COLUMN_DEFAULT'], $pg_type_str); | |
| // For now, direct quote. This might be wrong for non-string types. | |
| $col_def_parts[] = "DEFAULT " . $this->pdo->quote($col['COLUMN_DEFAULT']); | |
| } | |
| } | |
| // MySQL AUTO_INCREMENT -> PG SERIAL/BIGSERIAL or IDENTITY | |
| if (str_contains(strtolower($col['EXTRA']), 'auto_increment')) { | |
| // Type mapping should already handle SERIAL. If not, this is fallback. | |
| // If pg_type_str is INTEGER -> SERIAL, BIGINT -> BIGSERIAL. | |
| // This logic is better in map_mysql_coltype_to_pgsql_coltype. | |
| // If using IDENTITY: col_name INT GENERATED BY DEFAULT AS IDENTITY | |
| } | |
| $column_definitions[] = implode(' ', $col_def_parts); | |
| } | |
| // Process constraints (PK, UNIQUE from $constraint_infos) | |
| $primary_key_cols = null; | |
| foreach ($constraint_infos as $idx_name => $idx_data) { | |
| if ($idx_data['type'] === 'PRIMARY KEY') { | |
| $pk_col_names = array_map([$this, 'quote_pgsql_identifier'], $idx_data['columns']); | |
| $primary_key_cols = $pk_col_names; // Store for later use if single col PK on SERIAL | |
| if (count($pk_col_names) > 0) { | |
| // If single column PK is on a SERIAL type, it's implicit. | |
| // Otherwise, add explicit PRIMARY KEY constraint. | |
| $is_serial_pk = false; | |
| if (count($pk_col_names) === 1) { | |
| $pk_col_name_unquoted = $this->unquote_pgsql_identifier($pk_col_names[0]); | |
| foreach($column_infos as $ci) { | |
| if ($ci['COLUMN_NAME'] === $pk_col_name_unquoted && | |
| str_contains(strtolower($this->map_mysql_coltype_to_pgsql_coltype($ci['COLUMN_TYPE'], $ci['EXTRA'])), 'serial')) { | |
| $is_serial_pk = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (!$is_serial_pk) { | |
| $constraint_name = $this->quote_pgsql_identifier($idx_name === 'PRIMARY' ? $actual_table_name . "_pkey" : $idx_name); | |
| $table_constraints[] = "CONSTRAINT " . $constraint_name . " PRIMARY KEY (" . implode(', ', $pk_col_names) . ")"; | |
| } | |
| } | |
| } elseif ($idx_data['type'] === 'UNIQUE') { | |
| $unique_col_names = array_map([$this, 'quote_pgsql_identifier'], $idx_data['columns']); | |
| $constraint_name = $this->quote_pgsql_identifier($idx_name); | |
| $table_constraints[] = "CONSTRAINT " . $constraint_name . " UNIQUE (" . implode(', ', $unique_col_names) . ")"; | |
| } elseif ($idx_data['type'] === 'INDEX') { // Normal index | |
| $idx_col_names_with_opts = []; | |
| foreach($idx_data['columns_with_options'] as $col_opt) { // Assuming columns_with_options: ['name'=>name, 'order'=>ASC/DESC] | |
| $idx_col_names_with_opts[] = $this->quote_pgsql_identifier($col_opt['name']) . ($col_opt['order'] ? ' '.($col_opt['order'] === 'A' ? 'ASC' : 'DESC') : ''); | |
| } | |
| $idx_create_name = $this->quote_pgsql_identifier($idx_name); | |
| $create_index_queries[] = "CREATE INDEX " . $idx_create_name . " ON " . $quoted_actual_table_name . " (" . implode(', ', $idx_col_names_with_opts) . ")"; | |
| } | |
| // FOREIGN KEY, CHECK constraints would be handled here too. | |
| } | |
| $all_definitions = array_merge($column_definitions, $table_constraints); | |
| $create_table_sql = sprintf( | |
| "CREATE %sTABLE %s (\n %s\n)", | |
| $table_is_temporary ? 'TEMPORARY ' : '', | |
| $quoted_actual_table_name, | |
| implode( ",\n ", $all_definitions ) | |
| ); | |
| // PG does not have table-level STRICT mode. Column types are always strict. | |
| return array_merge([$create_table_sql], $create_index_queries, $alter_col_sequences); | |
| } | |
| private function map_mysql_coltype_to_pgsql_coltype(string $mysql_column_type, string $mysql_extra): string { | |
| // $mysql_column_type is like 'int(11) unsigned', 'varchar(255)', 'text' | |
| // $mysql_extra is like 'auto_increment' | |
| // This needs robust parsing of $mysql_column_type. | |
| $type_lower = strtolower($mysql_column_type); | |
| $extra_lower = strtolower($mysql_extra); | |
| if (str_contains($extra_lower, 'auto_increment')) { | |
| if (str_starts_with($type_lower, 'tinyint')) return 'SMALLSERIAL'; // PG SMALLSERIAL is smallint | |
| if (str_starts_with($type_lower, 'smallint')) return 'SMALLSERIAL'; | |
| if (str_starts_with($type_lower, 'mediumint')) return 'SERIAL'; // PG SERIAL is integer | |
| if (str_starts_with($type_lower, 'int')) return 'SERIAL'; | |
| if (str_starts_with($type_lower, 'bigint')) return 'BIGSERIAL'; | |
| } | |
| // Basic mapping, then add length/precision. | |
| // E.g. 'int(11) unsigned' -> base 'int', unsigned flag, length '11' | |
| // 'varchar(255)' -> base 'varchar', length '255' | |
| // 'decimal(10,2)' -> base 'decimal', precision '10', scale '2' | |
| if (preg_match('/^([a-z]+)\s*\((\d+)\s*,\s*(\d+)\)/', $type_lower, $matches)) { // decimal(P,S), float(P,S), double(P,S) | |
| $base_mysql_type = $matches[1]; | |
| $precision = $matches[2]; | |
| $scale = $matches[3]; | |
| $pg_base = self::DATA_TYPE_STRING_MAP[$base_mysql_type] ?? 'NUMERIC'; // Fallback | |
| return $pg_base . "(" . $precision . "," . $scale . ")"; | |
| } elseif (preg_match('/^([a-z]+)\s*\((\d+)\)/', $type_lower, $matches)) { // int(L), varchar(L), char(L), bit(L), binary(L) | |
| $base_mysql_type = $matches[1]; | |
| $length = $matches[2]; | |
| $pg_base = self::DATA_TYPE_STRING_MAP[$base_mysql_type] ?? 'TEXT'; | |
| if (in_array($pg_base, ['CHAR', 'VARCHAR', 'BIT', 'VARBIT'])) { // Types that take length | |
| return $pg_base . "(" . $length . ")"; | |
| } | |
| // For INT(L), display width (L) is ignored in PG. | |
| // If $base_mysql_type is 'int', 'mediumint', etc., map to PG INTEGER, BIGINT. | |
| if (str_contains($type_lower, 'unsigned')) { | |
| if ($pg_base === 'SMALLINT') return 'INTEGER'; | |
| if ($pg_base === 'INTEGER') return 'BIGINT'; | |
| // BIGINT UNSIGNED needs check constraint or error | |
| } | |
| return $pg_base; // e.g. INTEGER, SMALLINT, BIGINT | |
| } else { // Types without length in definition usually, e.g. TEXT, DATE, BLOB | |
| $base_mysql_type = preg_replace('/\s*unsigned|\s*zerofill/i', '', $type_lower); // remove unsigned/zerofill | |
| $base_mysql_type = trim($base_mysql_type); | |
| $pg_base = self::DATA_TYPE_STRING_MAP[$base_mysql_type] ?? 'TEXT'; | |
| if (str_contains($type_lower, 'unsigned')) { | |
| if ($pg_base === 'SMALLINT') return 'INTEGER'; | |
| if ($pg_base === 'INTEGER') return 'BIGINT'; | |
| } | |
| return $pg_base; | |
| } | |
| } | |
| // ... Other private helper methods from original WP_SQLite_Driver, adapted for PostgreSQL ... | |
| // e.g., unquote_pgsql_identifier, quote_pgsql_identifier | |
| // recreate_table_from_information_schema needs to be PG specific. | |
| // translate_show_like_or_where_condition (if used by SHOW commands) | |
| private function unquote_pgsql_identifier( string $quoted_identifier ): string { | |
| if (str_starts_with($quoted_identifier, '"') && str_ends_with($quoted_identifier, '"')) { | |
| $unquoted = substr( $quoted_identifier, 1, -1 ); | |
| return str_replace( '""', '"', $unquoted ); // PG escapes " with "" | |
| } | |
| // If not quoted, it might be case-folded by PG unless it was created with quotes. | |
| // For internal consistency, assume identifiers passed here are either correctly quoted | |
| // or are intended to be case-folded if unquoted. | |
| return $quoted_identifier; | |
| } | |
| private function quote_pgsql_identifier( string $unquoted_identifier ): string { | |
| // Only quote if it's not already quoted, or contains special chars/uppercase/keywords | |
| // For safety, always quote unless it's a very simple lowercase identifier. | |
| // But to match MySQL's backtick behavior (always quote in translation): | |
| return '"' . str_replace( '"', '""', $unquoted_identifier ) . '"'; | |
| } | |
| private function flush(): void { | |
| $this->last_mysql_query = ''; | |
| $this->last_pgsql_queries = array(); | |
| $this->last_result = null; | |
| $this->last_return_value = null; | |
| $this->is_readonly = false; | |
| // Transaction level reset needs care if a transaction was uncommitted. | |
| // $this->transaction_level = 0; // Resetting here might be premature if flush is called mid-transaction. | |
| } | |
| private function set_results_from_fetched_data( array $data ): void { | |
| $this->last_result = $data; | |
| $this->last_return_value = $this->last_result; // For SELECT, result is the data. | |
| } | |
| private function set_result_from_affected_rows( ?int $affected_rows = null ): void { | |
| // For PostgreSQL, PDOStatement::rowCount() works for INSERT, UPDATE, DELETE. | |
| // If $affected_rows is passed (e.g. from stmt->rowCount()), use it. | |
| // Otherwise, this method implies no rows were fetched as primary result. | |
| if ($affected_rows === null) { | |
| // This might be called for DDL where rowCount is not meaningful or 0. | |
| // Or if last query was not DML. | |
| // Default to 0 if not provided and not a SELECT. | |
| $affected_rows = 0; | |
| } | |
| $this->last_result = $affected_rows; | |
| $this->last_return_value = $affected_rows; | |
| } | |
| private function new_driver_exception( string $message, $code = 0, ?Throwable $previous = null ): WP_PostgreSQL_Driver_Exception { | |
| return new WP_PostgreSQL_Driver_Exception( $this, $message, $code, $previous ); | |
| } | |
| private function new_invalid_input_exception(): WP_PostgreSQL_Driver_Exception { | |
| return new WP_PostgreSQL_Driver_Exception( $this, 'MySQL query syntax error (or invalid AST for PG translation).' ); | |
| } | |
| private function new_not_supported_exception( string $cause ): WP_PostgreSQL_Driver_Exception { | |
| return new WP_PostgreSQL_Driver_Exception( $this, sprintf( 'MySQL query construct not supported for PostgreSQL: %s', $cause ) ); | |
| } | |
| } | |
| /** | |
| * Custom Exception class for the PostgreSQL Driver. | |
| */ | |
| class WP_PostgreSQL_Driver_Exception extends Exception { | |
| protected $driver; | |
| public function __construct( WP_PostgreSQL_Driver $driver, string $message, $code = 0, ?Throwable $previous = null ) { | |
| $this->driver = $driver; | |
| // Append query info to message if available | |
| $last_mysql_query = $driver->get_last_mysql_query(); | |
| if ( $last_mysql_query ) { | |
| $message .= "\nLast MySQL query: " . $last_mysql_query; | |
| } | |
| $last_pgsql_queries = $driver->get_last_pgsql_queries(); | |
| if (!empty($last_pgsql_queries)) { | |
| $message .= "\nExecuted PostgreSQL queries:"; | |
| foreach($last_pgsql_queries as $q) { | |
| $message .= "\n SQL: " . $q['sql'] . ( !empty($q['params']) ? " | Params: " . json_encode($q['params']) : ""); | |
| } | |
| } | |
| parent::__construct( $message, is_int($code) ? $code : 0, $previous ); | |
| if (is_string($code)) { // SQLSTATE | |
| $this->code = $code; | |
| } | |
| } | |
| } | |
| /** | |
| * PostgreSQL information schema builder for MySQL emulation. | |
| * | |
| * This class builds and maintains structures that help emulate MySQL's | |
| * INFORMATION_SCHEMA behavior, if PostgreSQL's own schema is insufficient | |
| * or needs to be augmented for precise MySQL DDL interpretation. | |
| * It consumes the AST of MySQL DDL queries and records schema information. | |
| */ | |
| class WP_PostgreSQL_Information_Schema_Builder { | |
| // SQL definitions for tables that emulate MySQL "information_schema". | |
| // These will be created within the target PostgreSQL schema. | |
| // PostgreSQL types should be used. `STRICT` keyword is SQLite-specific. | |
| const CREATE_EMULATED_INFORMATION_SCHEMA_QUERIES = array( | |
| // TABLES (emulated) | |
| "CREATE TABLE IF NOT EXISTS <prefix>tables ( | |
| TABLE_CATALOG TEXT NOT NULL DEFAULT current_database(), -- PG current_database() | |
| TABLE_SCHEMA TEXT NOT NULL, -- Emulated MySQL DB name, maps to PG schema | |
| TABLE_NAME TEXT NOT NULL, | |
| TABLE_TYPE TEXT NOT NULL, -- 'BASE TABLE' or 'VIEW' (MySQL concept) | |
| ENGINE TEXT, -- MySQL engine (e.g., InnoDB, MyISAM) - for info only | |
| VERSION INTEGER DEFAULT 10, | |
| ROW_FORMAT TEXT, -- MySQL specific | |
| TABLE_ROWS BIGINT DEFAULT 0, | |
| AVG_ROW_LENGTH BIGINT DEFAULT 0, | |
| DATA_LENGTH BIGINT DEFAULT 0, | |
| MAX_DATA_LENGTH BIGINT DEFAULT 0, | |
| INDEX_LENGTH BIGINT DEFAULT 0, | |
| DATA_FREE BIGINT DEFAULT 0, | |
| AUTO_INCREMENT BIGINT, | |
| CREATE_TIME TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | |
| UPDATE_TIME TIMESTAMP WITH TIME ZONE, | |
| CHECK_TIME TIMESTAMP WITH TIME ZONE, | |
| TABLE_COLLATION TEXT, -- MySQL table collation | |
| CHECKSUM INTEGER, | |
| CREATE_OPTIONS TEXT DEFAULT '', | |
| TABLE_COMMENT TEXT DEFAULT '', | |
| PRIMARY KEY (TABLE_SCHEMA, TABLE_NAME) -- Ensure uniqueness | |
| )", | |
| // COLUMNS (emulated) | |
| "CREATE TABLE IF NOT EXISTS <prefix>columns ( | |
| TABLE_CATALOG TEXT NOT NULL DEFAULT current_database(), | |
| TABLE_SCHEMA TEXT NOT NULL, | |
| TABLE_NAME TEXT NOT NULL, | |
| COLUMN_NAME TEXT NOT NULL, | |
| ORDINAL_POSITION INTEGER NOT NULL, | |
| COLUMN_DEFAULT TEXT, | |
| IS_NULLABLE TEXT NOT NULL, -- 'YES' or 'NO' | |
| DATA_TYPE TEXT NOT NULL, -- Normalized MySQL data type (e.g., 'varchar', 'int') | |
| CHARACTER_MAXIMUM_LENGTH INTEGER, | |
| CHARACTER_OCTET_LENGTH INTEGER, | |
| NUMERIC_PRECISION INTEGER, | |
| NUMERIC_SCALE INTEGER, | |
| DATETIME_PRECISION INTEGER, | |
| CHARACTER_SET_NAME TEXT, -- MySQL charset | |
| COLLATION_NAME TEXT, -- MySQL collation | |
| COLUMN_TYPE TEXT NOT NULL, -- Full MySQL column type string (e.g., 'varchar(255)') | |
| COLUMN_KEY TEXT DEFAULT '', -- 'PRI', 'UNI', 'MUL' | |
| EXTRA TEXT DEFAULT '', -- 'auto_increment', etc. | |
| PRIVILEGES TEXT, | |
| COLUMN_COMMENT TEXT DEFAULT '', | |
| GENERATION_EXPRESSION TEXT DEFAULT '', | |
| SRS_ID INTEGER, | |
| PRIMARY KEY (TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME) | |
| )", | |
| // STATISTICS (indexes - emulated) | |
| "CREATE TABLE IF NOT EXISTS <prefix>statistics ( | |
| TABLE_CATALOG TEXT NOT NULL DEFAULT current_database(), | |
| TABLE_SCHEMA TEXT NOT NULL, | |
| TABLE_NAME TEXT NOT NULL, | |
| NON_UNIQUE INTEGER NOT NULL, -- 0 for unique/primary, 1 otherwise | |
| INDEX_SCHEMA TEXT NOT NULL, -- Same as TABLE_SCHEMA | |
| INDEX_NAME TEXT NOT NULL, | |
| SEQ_IN_INDEX INTEGER NOT NULL, | |
| COLUMN_NAME TEXT, -- Can be NULL for expression indexes | |
| \"COLLATION\" TEXT, -- 'A' (asc), 'D' (desc), or NULL | |
| CARDINALITY BIGINT, | |
| SUB_PART INTEGER, -- For MySQL prefix indexes | |
| PACKED TEXT, | |
| NULLABLE TEXT NOT NULL, -- 'YES' if column can contain NULL, '' otherwise | |
| INDEX_TYPE TEXT NOT NULL, -- MySQL index type (BTREE, HASH, FULLTEXT, SPATIAL) | |
| COMMENT TEXT DEFAULT '', | |
| INDEX_COMMENT TEXT DEFAULT '', | |
| IS_VISIBLE TEXT DEFAULT 'YES', | |
| EXPRESSION TEXT, -- For functional/expression indexes | |
| PRIMARY KEY (TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX) | |
| )", | |
| // VIEWS (emulated) | |
| "CREATE TABLE IF NOT EXISTS <prefix>views ( | |
| TABLE_CATALOG TEXT NOT NULL, | |
| TABLE_SCHEMA TEXT NOT NULL, | |
| TABLE_NAME TEXT NOT NULL, | |
| VIEW_DEFINITION TEXT NOT NULL, | |
| CHECK_OPTION TEXT NOT NULL, | |
| IS_UPDATABLE TEXT NOT NULL, | |
| DEFINER TEXT NOT NULL, | |
| SECURITY_TYPE TEXT NOT NULL, | |
| CHARACTER_SET_CLIENT TEXT NOT NULL, | |
| COLLATION_CONNECTION TEXT NOT NULL, | |
| ALGORITHM TEXT NOT NULL, | |
| PRIMARY KEY (TABLE_SCHEMA, TABLE_NAME) | |
| )", | |
| // TABLE_CONSTRAINTS (emulated) | |
| "CREATE TABLE IF NOT EXISTS <prefix>table_constraints ( | |
| CONSTRAINT_CATALOG TEXT NOT NULL, | |
| CONSTRAINT_SCHEMA TEXT NOT NULL, | |
| CONSTRAINT_NAME TEXT NOT NULL, | |
| TABLE_SCHEMA TEXT NOT NULL, | |
| TABLE_NAME TEXT NOT NULL, | |
| CONSTRAINT_TYPE TEXT NOT NULL, -- 'PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY', 'CHECK' | |
| PRIMARY KEY (CONSTRAINT_SCHEMA, CONSTRAINT_NAME) | |
| )", | |
| // KEY_COLUMN_USAGE (emulated for foreign keys mostly) | |
| "CREATE TABLE IF NOT EXISTS <prefix>key_column_usage ( | |
| CONSTRAINT_CATALOG TEXT NOT NULL, | |
| CONSTRAINT_SCHEMA TEXT NOT NULL, | |
| CONSTRAINT_NAME TEXT NOT NULL, | |
| TABLE_CATALOG TEXT NOT NULL, | |
| TABLE_SCHEMA TEXT NOT NULL, | |
| TABLE_NAME TEXT NOT NULL, | |
| COLUMN_NAME TEXT NOT NULL, | |
| ORDINAL_POSITION INTEGER NOT NULL, | |
| POSITION_IN_UNIQUE_CONSTRAINT INTEGER, | |
| REFERENCED_TABLE_SCHEMA TEXT, | |
| REFERENCED_TABLE_NAME TEXT, | |
| REFERENCED_COLUMN_NAME TEXT, | |
| PRIMARY KEY (CONSTRAINT_SCHEMA, CONSTRAINT_NAME, ORDINAL_POSITION) | |
| )", | |
| // REFERENTIAL_CONSTRAINTS (emulated for foreign keys) | |
| "CREATE TABLE IF NOT EXISTS <prefix>referential_constraints ( | |
| CONSTRAINT_CATALOG TEXT NOT NULL, | |
| CONSTRAINT_SCHEMA TEXT NOT NULL, | |
| CONSTRAINT_NAME TEXT NOT NULL, | |
| UNIQUE_CONSTRAINT_CATALOG TEXT NOT NULL, | |
| UNIQUE_CONSTRAINT_SCHEMA TEXT NOT NULL, | |
| UNIQUE_CONSTRAINT_NAME TEXT, | |
| MATCH_OPTION TEXT NOT NULL, | |
| UPDATE_RULE TEXT NOT NULL, | |
| DELETE_RULE TEXT NOT NULL, | |
| TABLE_NAME TEXT NOT NULL, -- Table that has the foreign key | |
| REFERENCED_TABLE_NAME TEXT NOT NULL, -- Table that is referenced | |
| PRIMARY KEY (CONSTRAINT_SCHEMA, CONSTRAINT_NAME) | |
| )", | |
| ); | |
| const TOKEN_TO_TYPE_MAP = array( | |
| WP_MySQL_Lexer::INT_SYMBOL => 'int', | |
| WP_MySQL_Lexer::TINYINT_SYMBOL => 'tinyint', | |
| WP_MySQL_Lexer::SMALLINT_SYMBOL => 'smallint', | |
| WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'mediumint', | |
| WP_MySQL_Lexer::BIGINT_SYMBOL => 'bigint', | |
| WP_MySQL_Lexer::REAL_SYMBOL => 'double', | |
| WP_MySQL_Lexer::DOUBLE_SYMBOL => 'double', | |
| WP_MySQL_Lexer::FLOAT_SYMBOL => 'float', | |
| WP_MySQL_Lexer::DECIMAL_SYMBOL => 'decimal', | |
| WP_MySQL_Lexer::NUMERIC_SYMBOL => 'decimal', | |
| WP_MySQL_Lexer::FIXED_SYMBOL => 'decimal', | |
| WP_MySQL_Lexer::BIT_SYMBOL => 'bit', | |
| WP_MySQL_Lexer::BOOL_SYMBOL => 'tinyint', // MySQL BOOL is TINYINT(1) | |
| WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'tinyint', // MySQL BOOLEAN is TINYINT(1) | |
| WP_MySQL_Lexer::BINARY_SYMBOL => 'binary', | |
| WP_MySQL_Lexer::VARBINARY_SYMBOL => 'varbinary', | |
| WP_MySQL_Lexer::YEAR_SYMBOL => 'year', | |
| WP_MySQL_Lexer::DATE_SYMBOL => 'date', | |
| WP_MySQL_Lexer::TIME_SYMBOL => 'time', | |
| WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'timestamp', | |
| WP_MySQL_Lexer::DATETIME_SYMBOL => 'datetime', | |
| WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'tinyblob', | |
| WP_MySQL_Lexer::BLOB_SYMBOL => 'blob', | |
| WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'mediumblob', | |
| WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'longblob', | |
| WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'tinytext', | |
| WP_MySQL_Lexer::TEXT_SYMBOL => 'text', | |
| WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'mediumtext', | |
| WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'longtext', | |
| WP_MySQL_Lexer::ENUM_SYMBOL => 'enum', | |
| WP_MySQL_Lexer::SET_SYMBOL => 'set', | |
| WP_MySQL_Lexer::SERIAL_SYMBOL => 'bigint', // SERIAL is alias for BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE | |
| WP_MySQL_Lexer::GEOMETRY_SYMBOL => 'geometry', | |
| WP_MySQL_Lexer::GEOMETRYCOLLECTION_SYMBOL => 'geomcollection', | |
| WP_MySQL_Lexer::POINT_SYMBOL => 'point', | |
| WP_MySQL_Lexer::MULTIPOINT_SYMBOL => 'multipoint', | |
| WP_MySQL_Lexer::LINESTRING_SYMBOL => 'linestring', | |
| WP_MySQL_Lexer::MULTILINESTRING_SYMBOL => 'multilinestring', | |
| WP_MySQL_Lexer::POLYGON_SYMBOL => 'polygon', | |
| WP_MySQL_Lexer::MULTIPOLYGON_SYMBOL => 'multipolygon', | |
| WP_MySQL_Lexer::JSON_SYMBOL => 'json', | |
| ); | |
| const CHARSET_DEFAULT_COLLATION_MAP = array( | |
| 'armscii8' => 'armscii8_general_ci', 'ascii' => 'ascii_general_ci', | |
| 'big5' => 'big5_chinese_ci', 'binary' => 'binary', | |
| 'cp1250' => 'cp1250_general_ci', 'cp1251' => 'cp1251_general_ci', | |
| 'cp1256' => 'cp1256_general_ci', 'cp1257' => 'cp1257_general_ci', | |
| 'cp850' => 'cp850_general_ci', 'cp852' => 'cp852_general_ci', | |
| 'cp866' => 'cp866_general_ci', 'cp932' => 'cp932_japanese_ci', | |
| 'dec8' => 'dec8_swedish_ci', 'eucjpms' => 'eucjpms_japanese_ci', | |
| 'euckr' => 'euckr_korean_ci', 'gb18030' => 'gb18030_chinese_ci', | |
| 'gb2312' => 'gb2312_chinese_ci', 'gbk' => 'gbk_chinese_ci', | |
| 'geostd8' => 'geostd8_general_ci', 'greek' => 'greek_general_ci', | |
| 'hebrew' => 'hebrew_general_ci', 'hp8' => 'hp8_english_ci', | |
| 'keybcs2' => 'keybcs2_general_ci', 'koi8r' => 'koi8r_general_ci', | |
| 'koi8u' => 'koi8u_general_ci', 'latin1' => 'latin1_swedish_ci', | |
| 'latin2' => 'latin2_general_ci', 'latin5' => 'latin5_turkish_ci', | |
| 'latin7' => 'latin7_general_ci', 'macce' => 'macce_general_ci', | |
| 'macroman' => 'macroman_general_ci', 'sjis' => 'sjis_japanese_ci', | |
| 'swe7' => 'swe7_swedish_ci', 'tis620' => 'tis620_thai_ci', | |
| 'ucs2' => 'ucs2_general_ci', 'ujis' => 'ujis_japanese_ci', | |
| 'utf16' => 'utf16_general_ci', 'utf16le' => 'utf16le_general_ci', | |
| 'utf32' => 'utf32_general_ci', 'utf8' => 'utf8_general_ci', // utf8mb3 | |
| 'utf8mb4' => 'utf8mb4_0900_ai_ci', // MySQL 8.0 default for utf8mb4 | |
| ); | |
| const CHARSET_MAX_BYTES_MAP = array( | |
| 'big5' => 2, 'cp932' => 2, 'eucjpms' => 3, 'euckr' => 2, | |
| 'gb18030' => 4, 'gb2312' => 2, 'gbk' => 2, 'sjis' => 2, | |
| 'ucs2' => 2, 'ujis' => 3, 'utf16' => 4, 'utf16le' => 4, | |
| 'utf32' => 4, 'utf8' => 3, 'utf8mb4' => 4, | |
| ); | |
| private $emulated_db_name; // The MySQL database name being emulated | |
| private $actual_pg_schema_name; // The PostgreSQL schema where tables are actually created/managed | |
| private $table_name_prefix_regular; // Schema-qualified prefix for regular emulated info tables | |
| private $table_name_prefix_temporary; // Schema-qualified prefix for temporary emulated info tables | |
| private $query_callback; | |
| private $temporary_information_schema_exists = false; | |
| private $pdo; | |
| public function __construct( $pdo, string $emulated_database, string $actual_pg_schema, string $reserved_prefix, callable $query_callback ) { | |
| $this->emulated_db_name = $emulated_database; | |
| $this->actual_pg_schema_name = $actual_pg_schema; | |
| $this->query_callback = $query_callback; | |
| // For PostgreSQL, identifiers are folded to lowercase unless quoted. To be safe, quote schema and prefix. | |
| $quoted_schema = $this->actual_pg_schema_name; | |
| $base_prefix = $reserved_prefix . 'mysql_info_'; | |
| $base_temp_prefix = $reserved_prefix . 'mysql_info_temp_'; | |
| $this->table_name_prefix_regular = $quoted_schema . "." . $base_prefix; | |
| $this->table_name_prefix_temporary = $quoted_schema . "." . $base_temp_prefix; // Still in regular schema, but for temp table info. | |
| } | |
| public function get_emulated_info_table_name(string $base_info_table_name, bool $is_for_temp_mysql_table): string { | |
| // @TODO: Quote the returned value | |
| if ($is_for_temp_mysql_table) { | |
| // If truly using PG temporary tables for this info schema (which has session scope issues for a persistent builder) | |
| // Then prefix would be different, e.g. "pg_temp". For now, assume they are regular tables distinguished by prefix or internal flag. | |
| return $this->table_name_prefix_temporary . $base_info_table_name; | |
| } | |
| return $this->table_name_prefix_regular . $base_info_table_name; | |
| } | |
| public function ensure_information_schema_tables(): void { | |
| $this->query("CREATE SCHEMA IF NOT EXISTS " . '"' . str_replace('"', '""', $this->actual_pg_schema_name) . '"'); | |
| foreach ( self::CREATE_EMULATED_INFORMATION_SCHEMA_QUERIES as $query_template ) { | |
| $query = str_replace( '<prefix>', $this->table_name_prefix_regular, $query_template ); | |
| $this->query( $query ); | |
| // For temporary info tables, if needed separately and not distinguished by a flag. | |
| // $temp_query = str_replace( '<prefix>', $this->table_name_prefix_temporary, $query_template ); | |
| // $this->query( $temp_query ); // Potentially. | |
| } | |
| // This doesn't create temporary tables for the schema itself, but tables *about* temporary MySQL tables. | |
| // If the schema tables themselves needed to be temporary, the CREATE TABLE would be `CREATE TEMPORARY TABLE`. | |
| } | |
| public function table_exists_in_emulated_schema(string $table_name, bool $is_temporary): bool { | |
| $info_tables_table = $this->get_emulated_info_table_name('tables', $is_temporary); | |
| try { | |
| $stmt = $this->query( | |
| "SELECT 1 FROM $info_tables_table WHERE table_schema = ? AND table_name = ?", | |
| [$this->emulated_db_name, $table_name] | |
| ); | |
| return $stmt->fetchColumn() === '1'; | |
| } catch (PDOException $e) { | |
| // If table $info_tables_table itself doesn't exist yet (e.g. first run before ensure_... completed or in rolled-back tx) | |
| if (str_contains($e->getMessage(), 'does not exist')) { // Check PG error code for "undefined table" (42P01) | |
| return false; | |
| } | |
| throw $e; | |
| } | |
| } | |
| public function temporary_table_exists_in_emulated_schema(string $table_name): bool { | |
| return $this->table_exists_in_emulated_schema($table_name, true); | |
| } | |
| public function record_create_table( WP_Parser_Node $node ): void { | |
| $create_table_node = $node->get_first_child_node(); // Assuming 'createTable' rule | |
| if (!$create_table_node) return; | |
| $table_name_str = $this->get_value($create_table_node->get_first_descendant_node('tableName')); | |
| $is_temporary = $create_table_node->has_child_token(WP_MySQL_Lexer::TEMPORARY_SYMBOL); | |
| if ($is_temporary && !$this->temporary_information_schema_exists) { | |
| // If using separate tables for temp info and they aren't created yet. | |
| // For now, assume ensure_information_schema_tables creates both sets if needed, | |
| // or they are distinguished by a flag in regular info tables. | |
| // This part of logic might need refinement based on how temp table info is stored. | |
| } | |
| $tables_info_table = $this->get_emulated_info_table_name('tables', $is_temporary); | |
| $columns_info_table = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $statistics_info_table = $this->get_emulated_info_table_name('statistics', $is_temporary); | |
| $table_constraints_info_table = $this->get_emulated_info_table_name('table_constraints', $is_temporary); | |
| $key_column_usage_info_table = $this->get_emulated_info_table_name('key_column_usage', $is_temporary); | |
| // 1. Record in 'tables' | |
| $table_engine = $this->get_table_engine_from_ast($create_table_node); | |
| $table_collation = $this->get_table_collation_from_ast($create_table_node); | |
| // Other table options (COMMENT, ROW_FORMAT, CREATE_OPTIONS) | |
| $table_comment = $this->get_table_option_value($create_table_node, 'COMMENT'); | |
| $table_data = array( | |
| 'table_schema' => $this->emulated_db_name, | |
| 'table_name' => $table_name_str, | |
| 'table_type' => 'BASE TABLE', // Assuming not CREATE VIEW here | |
| 'engine' => $table_engine, | |
| 'table_collation' => $table_collation, | |
| 'table_comment' => $table_comment, | |
| // CREATE_TIME is default CURRENT_TIMESTAMP | |
| ); | |
| $this->insert_values($tables_info_table, $table_data); | |
| // 2. Record in 'columns' | |
| $ordinal_position = 1; | |
| $column_definition_nodes = $create_table_node->get_descendant_nodes('columnDefinition'); | |
| foreach ($column_definition_nodes as $col_def_node) { | |
| $column_name_str = $this->get_value($col_def_node->get_first_child_node('fieldIdentifier')); | |
| $mysql_column_data = $this->extract_mysql_column_data_from_ast($table_name_str, $column_name_str, $col_def_node, $ordinal_position, $table_collation); | |
| $this->insert_values($columns_info_table, $mysql_column_data); | |
| // Inline constraints (PRIMARY KEY, UNIQUE on column def) | |
| $inline_constraint_key = $mysql_column_data['column_key']; // PRI or UNI if defined inline | |
| if ($inline_constraint_key === 'PRI' || $inline_constraint_key === 'UNI') { | |
| $index_name = ($inline_constraint_key === 'PRI') ? 'PRIMARY' : $column_name_str; // MySQL default unique index name often column name | |
| $this->insert_values($statistics_info_table, [ | |
| 'table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, | |
| 'non_unique' => 0, 'index_schema' => $this->emulated_db_name, | |
| 'index_name' => $index_name, 'seq_in_index' => 1, | |
| 'column_name' => $column_name_str, 'collation' => 'A', // Default collation | |
| 'nullable' => ($mysql_column_data['is_nullable'] === 'YES' && $inline_constraint_key !== 'PRI') ? 'YES' : '', | |
| 'index_type' => 'BTREE', // Default | |
| ]); | |
| $this->insert_values($table_constraints_info_table, [ | |
| 'constraint_catalog' => $this->actual_pg_schema_name, 'constraint_schema' => $this->emulated_db_name, | |
| 'constraint_name' => $index_name, // Or generate one like tbl_col_key | |
| 'table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, | |
| 'constraint_type' => ($inline_constraint_key === 'PRI') ? 'PRIMARY KEY' : 'UNIQUE' | |
| ]); | |
| $this->insert_values($key_column_usage_info_table, [ | |
| 'constraint_catalog' => $this->actual_pg_schema_name, 'constraint_schema' => $this->emulated_db_name, | |
| 'constraint_name' => $index_name, 'table_catalog' => $this->actual_pg_schema_name, | |
| 'table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, | |
| 'column_name' => $column_name_str, 'ordinal_position' => 1, | |
| ]); | |
| } | |
| $ordinal_position++; | |
| } | |
| // 3. Record table-level constraints (PRIMARY KEY, UNIQUE, INDEX/KEY, FOREIGN KEY, CHECK) | |
| $table_constraint_nodes = $create_table_node->get_descendant_nodes('tableConstraintDef'); | |
| foreach ($table_constraint_nodes as $constraint_node) { | |
| $this->record_table_constraint_def_from_ast($table_name_str, $constraint_node, $is_temporary, $statistics_info_table, $table_constraints_info_table, $key_column_usage_info_table); | |
| } | |
| $this->sync_column_key_info_after_change($table_name_str, $is_temporary); | |
| } | |
| public function record_alter_table(WP_Parser_Node $node): ?array { | |
| $alter_table_node = $node->get_first_child_node(); // 'alterTable' rule | |
| if (!$alter_table_node) return null; | |
| $table_name_str = $this->get_value($alter_table_node->get_first_descendant_node('tableRef')); | |
| $is_temporary = $this->temporary_table_exists_in_emulated_schema($table_name_str); // Check if info for this temp table exists | |
| $columns_info_table = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $statistics_info_table = $this->get_emulated_info_table_name('statistics', $is_temporary); | |
| $table_constraints_info_table = $this->get_emulated_info_table_name('table_constraints', $is_temporary); | |
| $key_column_usage_info_table = $this->get_emulated_info_table_name('key_column_usage', $is_temporary); | |
| $current_columns_stmt = $this->query("SELECT column_name, ordinal_position FROM $columns_info_table WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position", [$this->emulated_db_name, $table_name_str]); | |
| $current_columns_map = []; | |
| while($row = $current_columns_stmt->fetch(PDO::FETCH_ASSOC)) { | |
| $current_columns_map[$row['column_name']] = (int)$row['ordinal_position']; | |
| } | |
| $column_map_for_recreate = array_combine(array_keys($current_columns_map), array_keys($current_columns_map)); | |
| $alter_actions = $alter_table_node->get_descendant_nodes('alterListItem'); | |
| foreach ($alter_actions as $action_node) { | |
| $first_token = $action_node->get_first_child_token(); | |
| if (!$first_token) continue; | |
| switch ($first_token->id) { | |
| case WP_MySQL_Lexer::ADD_SYMBOL: | |
| $this->handle_alter_add_from_ast($action_node, $table_name_str, $is_temporary, $columns_info_table, $statistics_info_table, $table_constraints_info_table, $key_column_usage_info_table, $current_columns_map); | |
| break; | |
| case WP_MySQL_Lexer::DROP_SYMBOL: | |
| $this->handle_alter_drop_from_ast($action_node, $table_name_str, $is_temporary, $columns_info_table, $statistics_info_table, $table_constraints_info_table, $key_column_usage_info_table, $column_map_for_recreate); | |
| break; | |
| case WP_MySQL_Lexer::CHANGE_SYMBOL: | |
| $this->handle_alter_change_from_ast($action_node, $table_name_str, $is_temporary, $columns_info_table, $statistics_info_table, $table_constraints_info_table, $key_column_usage_info_table, $column_map_for_recreate); | |
| break; | |
| case WP_MySQL_Lexer::MODIFY_SYMBOL: | |
| $this->handle_alter_modify_from_ast($action_node, $table_name_str, $is_temporary, $columns_info_table, $statistics_info_table, $table_constraints_info_table, $key_column_usage_info_table); | |
| break; | |
| case WP_MySQL_Lexer::ALTER_SYMBOL: // ALTER [COLUMN] col SET DEFAULT / DROP DEFAULT | |
| $this->handle_alter_column_options_from_ast($action_node, $table_name_str, $is_temporary, $columns_info_table); | |
| break; | |
| case WP_MySQL_Lexer::RENAME_SYMBOL: // RENAME [COLUMN | INDEX | TO] | |
| $this->handle_alter_rename_from_ast($action_node, $table_name_str, $is_temporary, $column_map_for_recreate /* more tables needed */); | |
| break; | |
| // Other ALTER actions: RENAME TABLE, ALTER TABLE options (ENGINE, CHARSET, COLLATE) | |
| } | |
| } | |
| $this->sync_column_key_info_after_change($table_name_str, $is_temporary); | |
| $this->resequence_ordinal_positions($table_name_str, $is_temporary, $columns_info_table); | |
| return $column_map_for_recreate; | |
| } | |
| public function record_drop_table(WP_Parser_Node $node): void { | |
| $drop_table_node = $node->get_first_child_node(); // 'dropTable' rule | |
| if (!$drop_table_node) return; | |
| $has_temporary_keyword = $drop_table_node->has_child_token(WP_MySQL_Lexer::TEMPORARY_SYMBOL); | |
| $table_refs_list_node = $drop_table_node->get_first_child_node('tableRefList'); | |
| foreach ($table_refs_list_node->get_child_nodes('tableRef') as $table_ref_node) { | |
| $table_name_str = $this->get_value($table_ref_node); | |
| // Determine if it was a temporary table info based on keyword or prior existence in temp info. | |
| $is_temporary = $has_temporary_keyword || $this->temporary_table_exists_in_emulated_schema($table_name_str); | |
| $where_criteria = ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str]; | |
| $this->delete_values($this->get_emulated_info_table_name('tables', $is_temporary), $where_criteria); | |
| $this->delete_values($this->get_emulated_info_table_name('columns', $is_temporary), $where_criteria); | |
| $this->delete_values($this->get_emulated_info_table_name('statistics', $is_temporary), $where_criteria); | |
| $this->delete_values($this->get_emulated_info_table_name('table_constraints', $is_temporary), $where_criteria); | |
| $this->delete_values($this->get_emulated_info_table_name('key_column_usage', $is_temporary), | |
| array_merge($where_criteria, ['constraint_schema' => $this->emulated_db_name])); // KCU also has constraint_schema | |
| $this->delete_values($this->get_emulated_info_table_name('referential_constraints', $is_temporary), | |
| ['constraint_schema' => $this->emulated_db_name, 'table_name' => $table_name_str]); | |
| } | |
| } | |
| // Retrieve full emulated schema info (used by WP_PostgreSQL_Driver for CREATE TABLE and SHOW CREATE TABLE) | |
| public function get_emulated_table_info(string $table_name, bool $is_temporary): ?array { | |
| $tables_info_table = $this->get_emulated_info_table_name('tables', $is_temporary); | |
| $stmt = $this->query("SELECT * FROM $tables_info_table WHERE table_schema = ? AND table_name = ?", [$this->emulated_db_name, $table_name]); | |
| $result = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| foreach ($result as &$row) { | |
| $row = array_change_key_case($row, CASE_UPPER); | |
| } | |
| return $result; | |
| } | |
| public function get_emulated_column_info(string $table_name, bool $is_temporary): array { | |
| $cols_info_table = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $stmt = $this->query("SELECT * FROM $cols_info_table WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position", [$this->emulated_db_name, $table_name]); | |
| $result = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| foreach ($result as &$row) { | |
| $row = array_change_key_case($row, CASE_UPPER); | |
| } | |
| return $result; | |
| } | |
| public function get_emulated_constraint_info(string $table_name, bool $is_temporary): array { | |
| // This should gather info from 'statistics', 'table_constraints', 'key_column_usage' | |
| // and format it in a way that's useful for reconstructing MySQL DDL or PG DDL. | |
| // For simplicity, let's focus on 'statistics' for PK, UNIQUE, INDEX | |
| $stats_info_table = $this->get_emulated_info_table_name('statistics', $is_temporary); | |
| $stmt = $this->query("SELECT * FROM $stats_info_table WHERE table_schema = ? AND table_name = ? ORDER BY index_name, seq_in_index", [$this->emulated_db_name, $table_name]); | |
| $raw_stats = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| $constraints = []; | |
| foreach ($raw_stats as $stat) { | |
| $idx_name = $stat['index_name']; | |
| if (!isset($constraints[$idx_name])) { | |
| $constraints[$idx_name] = [ | |
| 'type' => $stat['index_type'], // BTREE, HASH, etc. (MySQL types) | |
| 'non_unique' => (bool)$stat['non_unique'], | |
| 'columns' => [], | |
| 'columns_with_options' => [], // For PG index creation: name, order, sub_part (if translatable) | |
| 'is_primary' => ($idx_name === 'PRIMARY'), | |
| 'is_unique' => ($stat['non_unique'] == 0), | |
| 'index_comment' => $stat['index_comment'], | |
| ]; | |
| if ($idx_name === 'PRIMARY') $constraints[$idx_name]['type'] = 'PRIMARY KEY'; | |
| elseif ($stat['non_unique'] == 0) $constraints[$idx_name]['type'] = 'UNIQUE'; | |
| else $constraints[$idx_name]['type'] = 'INDEX'; // Or KEY | |
| } | |
| $constraints[$idx_name]['columns'][] = $stat['column_name']; | |
| $constraints[$idx_name]['columns_with_options'][] = [ | |
| 'name' => $stat['column_name'], | |
| 'order' => $stat['COLLATION'], // 'A' or 'D' | |
| 'sub_part' => $stat['sub_part'] // MySQL specific prefix length | |
| ]; | |
| } | |
| // Further processing for foreign keys from referential_constraints and key_column_usage | |
| return $constraints; | |
| } | |
| public function get_mysql_create_table_statement(bool $is_temporary, string $table_name): ?string { | |
| $table_info = $this->get_emulated_table_info($table_name, $is_temporary); | |
| if (!$table_info) return null; | |
| $column_infos = $this->get_emulated_column_info($table_name, $is_temporary); | |
| $constraint_infos = $this->get_emulated_constraint_info($table_name, $is_temporary); // This needs to be MySQL-centric structure | |
| $sql_parts = []; | |
| $sql_parts[] = "CREATE " . ($is_temporary ? "TEMPORARY " : "") . "TABLE " . $this->quote_mysql_identifier($table_name) . " ("; | |
| $col_defs = []; | |
| foreach($column_infos as $col) { | |
| $cdp = []; | |
| $cdp[] = $this->quote_mysql_identifier($col['COLUMN_NAME']); | |
| $cdp[] = $col['COLUMN_TYPE']; // Full MySQL type string from emulated schema: e.g., int(11) unsigned | |
| if ($col['IS_NULLABLE'] === 'NO') $cdp[] = "NOT NULL"; | |
| if ($col['COLUMN_DEFAULT'] !== null) { | |
| if (strtoupper($col['COLUMN_DEFAULT']) === 'CURRENT_TIMESTAMP' && | |
| in_array(strtolower($col['DATA_TYPE']), ['timestamp', 'datetime'])) { | |
| $cdp[] = "DEFAULT CURRENT_TIMESTAMP"; | |
| } elseif (strtolower($col['DATA_TYPE']) === 'bit' && preg_match("/b'[01]+'/", $col['COLUMN_DEFAULT'])) { | |
| $cdp[] = "DEFAULT " . $col['COLUMN_DEFAULT']; // bit literals like b'0101' | |
| } | |
| else { | |
| // Default value needs to be quoted if it's a string type, not for numbers/boolean | |
| // $this->pdo->quote() is for SQL literals, not DDL defaults. | |
| // This needs a helper for DDL default quoting based on $col['DATA_TYPE'] | |
| $cdp[] = "DEFAULT " . $this->format_mysql_default_value($col['COLUMN_DEFAULT'], $col['DATA_TYPE']); | |
| } | |
| } | |
| if (!empty($col['EXTRA'])) { | |
| // EXTRA can be "AUTO_INCREMENT", "ON UPDATE CURRENT_TIMESTAMP", "VIRTUAL GENERATED", etc. | |
| $extras = explode(' ', strtoupper($col['EXTRA'])); | |
| foreach($extras as $extra_part) { | |
| if ($extra_part === 'DEFAULT_GENERATED') continue; // This is informational, not DDL keyword usually | |
| if ($extra_part === 'VIRTUAL' || $extra_part === 'STORED') { | |
| $cdp[] = "GENERATED ALWAYS AS (" . $col['GENERATION_EXPRESSION'] . ") " . $extra_part; | |
| } elseif ($extra_part === 'ON') { // For "ON UPDATE" | |
| $cdp[] = "ON UPDATE CURRENT_TIMESTAMP"; // Assuming this is the only ON UPDATE case for now | |
| } | |
| else { | |
| $cdp[] = $extra_part; // e.g. AUTO_INCREMENT | |
| } | |
| } | |
| } | |
| if (!empty($col['COLUMN_COMMENT'])) $cdp[] = "COMMENT " . $this->pdo->quote($col['COLUMN_COMMENT']); // Use PDO quote for comments | |
| $col_defs[] = " " . implode(" ", $cdp); | |
| } | |
| $constraint_defs = []; | |
| foreach($constraint_infos as $idx_name => $idx_data) { | |
| $cols_str = implode(', ', array_map(function($col_opt) { | |
| $s = $this->quote_mysql_identifier($col_opt['name']); | |
| if ($col_opt['sub_part']) $s .= "(" . $col_opt['sub_part'] . ")"; | |
| if ($col_opt['order'] === 'D') $s .= " DESC"; | |
| // ASC is default | |
| return $s; | |
| }, $idx_data['columns_with_options'])); | |
| $idx_def = " "; | |
| if ($idx_data['is_primary']) { | |
| $idx_def .= "PRIMARY KEY (" . $cols_str . ")"; | |
| } elseif ($idx_data['is_unique']) { | |
| $idx_def .= "UNIQUE KEY " . $this->quote_mysql_identifier($idx_name) . " (" . $cols_str . ")"; | |
| } else { // Regular index | |
| $idx_def .= "KEY " . $this->quote_mysql_identifier($idx_name) . " (" . $cols_str . ")"; | |
| } | |
| // MySQL Index type (USING BTREE/HASH) and comments | |
| if ($idx_data['type'] !== 'PRIMARY KEY' && $idx_data['type'] !== 'UNIQUE' && $idx_data['type'] !== 'BTREE' && !empty($idx_data['type'])) { // Don't add USING BTREE if it's default | |
| if (in_array($idx_data['type'], ['BTREE', 'HASH'])) { // MySQL supports these for general indexes | |
| $idx_def .= " USING " . $idx_data['type']; | |
| } | |
| // FULLTEXT/SPATIAL are keywords before INDEX/KEY | |
| } | |
| if (!empty($idx_data['index_comment'])) { | |
| $idx_def .= " COMMENT " . $this->pdo->quote($idx_data['index_comment']); | |
| } | |
| $constraint_defs[] = $idx_def; | |
| } | |
| $all_defs_for_create = array_merge($col_defs, $constraint_defs); | |
| $sql_parts[] = implode(",\n", $all_defs_for_create); | |
| $sql_parts[] = ")"; | |
| // Table options: ENGINE, CHARSET, COLLATE, COMMENT | |
| if (!empty($table_info['ENGINE'])) $sql_parts[] = "ENGINE=" . $table_info['ENGINE']; | |
| if (!empty($table_info['TABLE_COLLATION'])) { | |
| $charset = explode('_', $table_info['TABLE_COLLATION'])[0]; | |
| if ($charset === 'utf8mb3') $charset = 'utf8'; // Normalize for older MySQL versions | |
| $sql_parts[] = "DEFAULT CHARACTER SET=" . $charset; // Or extract from table_info if stored separately | |
| $sql_parts[] = "COLLATE=" . $table_info['TABLE_COLLATION']; | |
| } | |
| if (!empty($table_info['TABLE_COMMENT'])) $sql_parts[] = "COMMENT=" . $this->pdo->quote($table_info['TABLE_COMMENT']); | |
| if (!empty($table_info['AUTO_INCREMENT'])) $sql_parts[] = "AUTO_INCREMENT=" . $table_info['AUTO_INCREMENT']; | |
| return implode("\n ", $sql_parts); | |
| } | |
| private function format_mysql_default_value(string $value, string $mysql_data_type): string { | |
| $type_cat = strtolower($mysql_data_type); | |
| if (in_array($type_cat, ['int', 'tinyint', 'smallint', 'mediumint', 'bigint', 'float', 'double', 'decimal', 'year'])) { | |
| return $value; // Numeric types don't quote default | |
| } | |
| if (in_array($type_cat, ['bool', 'boolean'])) { | |
| return (strtolower($value) === 'true' || $value === '1') ? 'TRUE' : 'FALSE'; | |
| } | |
| // For string, date, time, enum, set, json, text, blob, default values are quoted. | |
| return $this->pdo->quote($value); | |
| } | |
| private function quote_mysql_identifier(string $identifier): string { | |
| return '`' . str_replace('`', '``', $identifier) . '`'; | |
| } | |
| private function get_value( WP_Parser_Node $node ): string { | |
| $full_value = ''; | |
| foreach ( $node->get_children() as $child ) { | |
| if ( $child instanceof WP_Parser_Node ) { | |
| $value = $this->get_value( $child ); | |
| } elseif ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $child->id ) { | |
| $value = substr( $child->value, 1, -1 ); | |
| $value = str_replace( '``', '`', $value ); | |
| } elseif ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $child->id ) { | |
| $value = $child->value; | |
| $value = substr( $value, 1, -1 ); | |
| $value = str_replace( '\"', '"', $value ); | |
| $value = str_replace( '""', '"', $value ); | |
| } elseif ( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $child->id ) { | |
| $value = $child->value; | |
| $value = substr( $value, 1, -1 ); | |
| $value = str_replace( '\"', '"', $value ); | |
| $value = str_replace( '""', '"', $value ); | |
| } else { | |
| $value = $child->value; | |
| } | |
| $full_value .= $value; | |
| } | |
| return $full_value; | |
| } | |
| private function get_table_engine_from_ast(WP_Parser_Node $create_table_node): string { | |
| $engine_option_node = $this->find_table_option_node($create_table_node, 'ENGINE'); | |
| if ($engine_option_node) { | |
| $engine_ref_node = $engine_option_node->get_first_child_node('engineRef'); // Or identifier | |
| if ($engine_ref_node) return strtoupper($this->get_value($engine_ref_node)); | |
| } | |
| return 'InnoDB'; // MySQL default | |
| } | |
| private function get_table_collation_from_ast(WP_Parser_Node $create_table_node): ?string { | |
| // Look for DEFAULT COLLATE or COLLATE option, or derive from CHARSET | |
| $collate_option_node = $this->find_table_option_node($create_table_node, 'COLLATE'); | |
| if ($collate_option_node) { | |
| $collation_name_node = $collate_option_node->get_first_child_node('collationName'); | |
| if ($collation_name_node) return strtolower($this->get_value($collation_name_node)); | |
| } | |
| $charset_option_node = $this->find_table_option_node($create_table_node, 'CHARACTER SET') ?? | |
| $this->find_table_option_node($create_table_node, 'CHARSET'); | |
| if ($charset_option_node) { | |
| $charset_name_node = $charset_option_node->get_first_child_node('charsetName'); | |
| if ($charset_name_node) { | |
| $charset = strtolower($this->get_value($charset_name_node)); | |
| if ($charset === 'utf8mb3') $charset = 'utf8'; | |
| return self::CHARSET_DEFAULT_COLLATION_MAP[$charset] ?? $charset . '_general_ci'; | |
| } | |
| } | |
| // Global default if not specified (e.g. from DB settings, or hardcoded fallback) | |
| return 'utf8mb4_0900_ai_ci'; // Sensible default for modern MySQL | |
| } | |
| private function get_table_option_value(WP_Parser_Node $create_table_node, string $option_name): ?string { | |
| $option_node = $this->find_table_option_node($create_table_node, $option_name); | |
| if ($option_node) { | |
| // Assuming option value is a textLiteral or identifier after the option keyword and '=' | |
| $children = $option_node->get_children(); | |
| $value_node_found = false; | |
| foreach($children as $child) { | |
| if ($value_node_found && ($child instanceof WP_Parser_Node)) { // value node could be textLiteral, identifier, etc. | |
| if ($child->rule_name === 'textStringLiteral') return $this->get_value($child); | |
| if ($child->rule_name === 'identifier') return $this->get_value($child); | |
| // Handle other literal types if necessary | |
| break; | |
| } | |
| if ($child instanceof WP_MySQL_Token && strtoupper($child->value) === $option_name) { | |
| $value_node_found = true; // Next node should be '=' or value | |
| } | |
| if ($value_node_found && $child instanceof WP_MySQL_Token && $child->value === '=') { | |
| // Value is next node | |
| continue; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| private function find_table_option_node(WP_Parser_Node $create_table_node, string $option_name_upper): ?WP_Parser_Node { | |
| $table_option_list_node = $create_table_node->get_first_descendant_node('tableOptionList'); | |
| if ($table_option_list_node) { | |
| foreach ($table_option_list_node->get_child_nodes('tableOptionItem') as $option_item_node) { | |
| // A tableOptionItem might start with ENGINE, CHARSET, COLLATE, COMMENT, etc. | |
| // Check the first token(s) of option_item_node. | |
| $tokens = $option_item_node->get_child_tokens(); // Get first few tokens | |
| if (empty($tokens)) continue; | |
| // Handle multi-word options like CHARACTER SET | |
| $option_keyword_from_ast = ""; | |
| if (strtoupper($tokens[0]->value) === $option_name_upper) { | |
| return $option_item_node; | |
| } | |
| if (count($tokens) > 1 && strtoupper($tokens[0]->value . " " . $tokens[1]->value) === $option_name_upper) { | |
| return $option_item_node; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| private function extract_mysql_column_data_from_ast(string $table_name_str, string $column_name_str, WP_Parser_Node $col_def_node, int $ordinal_position, ?string $table_collation): array { | |
| $data_type_node = $col_def_node->get_first_descendant_node('dataType'); | |
| list($mysql_norm_type, $mysql_full_type) = $this->parse_mysql_datatype_from_ast($data_type_node); | |
| $is_nullable = 'YES'; | |
| $column_key = ''; | |
| $extra = ''; | |
| $default_value = null; | |
| $comment = ''; | |
| $gen_expr = ''; | |
| // SERIAL implies NOT NULL, AUTO_INCREMENT, UNIQUE (becomes PRI if only unique key) | |
| if ($data_type_node->get_first_descendant_token(WP_MySQL_Lexer::SERIAL_SYMBOL)) { | |
| $is_nullable = 'NO'; | |
| $extra = 'AUTO_INCREMENT'; | |
| $column_key = 'UNI'; // Could become PRI later | |
| } | |
| $attributes = $col_def_node->get_descendant_nodes('columnAttribute'); | |
| foreach ($attributes as $attr_node) { | |
| $first_attr_token = $attr_node->get_first_child_token(); | |
| if (!$first_attr_token) continue; | |
| switch ($first_attr_token->id) { | |
| case WP_MySQL_Lexer::NOT_SYMBOL: // NOT NULL | |
| if ($attr_node->get_first_child_node('nullLiteral')) $is_nullable = 'NO'; | |
| break; | |
| case WP_MySQL_Lexer::NULL_SYMBOL: // NULL (explicitly, though default is nullable if NOT NULL absent) | |
| // This might override NOT NULL if "NULL" appears after "NOT NULL", though unusual. | |
| // Usually it's "NOT NULL" or just "NULL" (or implicit nullable). | |
| // $is_nullable = 'YES'; | |
| break; | |
| case WP_MySQL_Lexer::DEFAULT_SYMBOL: | |
| $default_literal_node = $attr_node->get_first_child_node(); // signedLiteral, textLiteral, nullLiteral, NOW(), etc. | |
| if ($default_literal_node && $default_literal_node->rule_name === 'nullLiteral') { | |
| $default_value = null; // Explicit DEFAULT NULL | |
| } elseif ($attr_node->has_child_token(WP_MySQL_Lexer::NOW_SYMBOL) || $attr_node->has_child_token(WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL)) { | |
| $default_value = 'CURRENT_TIMESTAMP'; | |
| } else { | |
| // Handle expressions in default (e.g. (UUID())) | |
| $expr_node = $attr_node->get_first_child_node('exprWithParentheses'); | |
| if ($expr_node) { | |
| $default_value = $this->get_value_from_expression_node($expr_node); // Needs a robust expression serializer | |
| if (!str_contains($extra, 'DEFAULT_GENERATED')) $extra = trim($extra . ' DEFAULT_GENERATED'); | |
| } else { | |
| // Simple literal default | |
| $val_node = $attr_node->get_first_child_node(); // Could be signedLiteral or textStringLiteral | |
| if ($val_node) $default_value = $this->get_value($val_node); | |
| } | |
| } | |
| break; | |
| case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL: | |
| $extra = trim($extra . ' AUTO_INCREMENT'); | |
| $is_nullable = 'NO'; // Auto_increment columns are implicitly NOT NULL | |
| break; | |
| case WP_MySQL_Lexer::UNIQUE_SYMBOL: // UNIQUE [KEY] | |
| $column_key = 'UNI'; | |
| break; | |
| case WP_MySQL_Lexer::KEY_SYMBOL: // PRIMARY KEY (inline) | |
| case WP_MySQL_Lexer::PRIMARY_SYMBOL: // PRIMARY KEY (if PRIMARY token first) | |
| if ($attr_node->has_child_token(WP_MySQL_Lexer::KEY_SYMBOL) || $first_attr_token->id === WP_MySQL_Lexer::PRIMARY_SYMBOL) { | |
| $column_key = 'PRI'; | |
| $is_nullable = 'NO'; | |
| } | |
| break; | |
| case WP_MySQL_Lexer::COMMENT_SYMBOL: | |
| $comment_node = $attr_node->get_first_child_node('textStringLiteral'); | |
| if ($comment_node) $comment = $this->get_value($comment_node); | |
| break; | |
| case WP_MySQL_Lexer::ON_SYMBOL: // ON UPDATE CURRENT_TIMESTAMP | |
| if ($attr_node->has_child_token(WP_MySQL_Lexer::UPDATE_SYMBOL) && $attr_node->has_child_token(WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL)) { | |
| $extra = trim($extra . ' ON UPDATE CURRENT_TIMESTAMP'); | |
| } | |
| break; | |
| // Other attributes: COLLATE, COLUMN_FORMAT, STORAGE, REFERENCES (inline FK) | |
| } | |
| } | |
| // Generated columns | |
| $generated_always_node = $col_def_node->get_first_child_node('generatedAlways'); | |
| if ($generated_always_node) { | |
| $gen_expr_node = $generated_always_node->get_first_child_node('exprWithParentheses'); | |
| if ($gen_expr_node) $gen_expr = $this->get_value_from_expression_node($gen_expr_node); | |
| if ($generated_always_node->has_child_token(WP_MySQL_Lexer::VIRTUAL_SYMBOL)) $extra = trim($extra . ' VIRTUAL GENERATED'); | |
| elseif ($generated_always_node->has_child_token(WP_MySQL_Lexer::STORED_SYMBOL)) $extra = trim($extra . ' STORED GENERATED'); | |
| } | |
| list($charset, $collation) = $this->get_column_charset_collation_from_ast($col_def_node, $mysql_norm_type, $table_collation); | |
| list($char_max_len, $char_octet_len) = $this->get_column_lengths_from_ast($data_type_node, $mysql_norm_type, $charset); | |
| list($num_precision, $num_scale) = $this->get_column_numeric_attrs_from_ast($data_type_node, $mysql_norm_type); | |
| $dt_precision = $this->get_column_datetime_precision_from_ast($data_type_node, $mysql_norm_type); | |
| return array( | |
| 'table_schema' => $this->emulated_db_name, | |
| 'table_name' => $table_name_str, | |
| 'column_name' => $column_name_str, | |
| 'ordinal_position' => $ordinal_position, | |
| 'column_default' => $default_value, | |
| 'is_nullable' => $is_nullable, | |
| 'data_type' => $mysql_norm_type, // e.g. "varchar" | |
| 'character_maximum_length' => $char_max_len, | |
| 'character_octet_length' => $char_octet_len, | |
| 'numeric_precision' => $num_precision, | |
| 'numeric_scale' => $num_scale, | |
| 'datetime_precision' => $dt_precision, | |
| 'character_set_name' => $charset, | |
| 'collation_name' => $collation, | |
| 'column_type' => $mysql_full_type, // e.g. "varchar(255)" | |
| 'column_key' => $column_key, | |
| 'extra' => trim($extra), | |
| 'privileges' => 'select,insert,update,references', // Default MySQL privileges | |
| 'column_comment' => $comment, | |
| 'generation_expression' => $gen_expr, | |
| 'srs_id' => null, // Spatial Reference System ID, not handled yet | |
| ); | |
| } | |
| private function parse_mysql_datatype_from_ast(WP_Parser_Node $data_type_node): array { | |
| // Input: AST node for dataType rule. Output: [normalized_mysql_type, full_mysql_type_string] | |
| // Example: INT(11) UNSIGNED -> ['int', 'int(11) unsigned'] | |
| // Example: VARCHAR(255) CHARACTER SET utf8 -> ['varchar', 'varchar(255)'] (charset/collate handled separately for column) | |
| $tokens = $data_type_node->get_descendant_tokens(); // Get all tokens within dataType | |
| $base_type_token = $tokens[0]; // Usually the first token is the base type | |
| // Handle multi-token types like `NATIONAL VARCHAR`, `DOUBLE PRECISION` (MySQL treats `REAL` and `DOUBLE PRECISION` as `DOUBLE`) | |
| if (count($tokens) > 1) { | |
| if ($base_type_token->id === WP_MySQL_Lexer::NATIONAL_SYMBOL && $tokens[1]->id === WP_MySQL_Lexer::VARCHAR_SYMBOL) { | |
| $base_type_token = $tokens[1]; // Effective type is VARCHAR | |
| } elseif ($base_type_token->id === WP_MySQL_Lexer::NATIONAL_SYMBOL && $tokens[1]->id === WP_MySQL_Lexer::CHAR_SYMBOL) { | |
| $base_type_token = $tokens[1]; // Effective type is CHAR | |
| } elseif ( ($base_type_token->id === WP_MySQL_Lexer::DOUBLE_SYMBOL || $base_type_token->id === WP_MySQL_Lexer::REAL_SYMBOL) && | |
| isset($tokens[1]) && strtoupper($tokens[1]->value) === 'PRECISION' ) { | |
| // MySQL just takes DOUBLE for DOUBLE PRECISION | |
| $base_type_token = new WP_MySQL_Token(WP_MySQL_Lexer::DOUBLE_SYMBOL, 'DOUBLE'); | |
| } // Add other multi-word types if necessary | |
| } | |
| $norm_type = self::TOKEN_TO_TYPE_MAP[$base_type_token->id] ?? strtolower($base_type_token->value); | |
| $full_type_str = strtolower($base_type_token->value); | |
| // Length/Precision: (M) or (M,D) | |
| $length_node = $data_type_node->get_first_descendant_node('fieldLength'); // For (M) | |
| $precision_node = $data_type_node->get_first_descendant_node('precision'); // For (M,D) | |
| $datetime_precision_node = $data_type_node->get_first_descendant_node('typeDatetimePrecision'); // For TIME/DATETIME/TIMESTAMP (FSP) | |
| if ($length_node) $full_type_str .= $this->get_value_from_expression_node($length_node); // Includes parens | |
| if ($precision_node) $full_type_str .= $this->get_value_from_expression_node($precision_node); // Includes parens | |
| if ($datetime_precision_node) $full_type_str .= $this->get_value_from_expression_node($datetime_precision_node); // Includes parens | |
| // Special cases for default lengths if not specified (for COLUMN_TYPE string) | |
| if (!$length_node && !$precision_node) { | |
| if ($norm_type === 'char' || $norm_type === 'binary') $full_type_str .= '(1)'; | |
| if ($norm_type === 'bit') $full_type_str .= '(1)'; | |
| if ($norm_type === 'decimal') $full_type_str .= '(10,0)'; | |
| if ($norm_type === 'year' && $base_type_token->value === 'YEAR') $full_type_str .= '(4)'; // YEAR(4) is default | |
| } | |
| if ($norm_type === 'tinyint' && ($base_type_token->id === WP_MySQL_Lexer::BOOL_SYMBOL || $base_type_token->id === WP_MySQL_Lexer::BOOLEAN_SYMBOL)){ | |
| if (!str_contains($full_type_str, '(')) $full_type_str .= '(1)'; // For BOOL/BOOLEAN if length not specified | |
| } | |
| // UNSIGNED and ZEROFILL attributes | |
| if ($data_type_node->get_first_descendant_token(WP_MySQL_Lexer::UNSIGNED_SYMBOL) || | |
| $data_type_node->get_first_descendant_token(WP_MySQL_Lexer::SERIAL_SYMBOL) // SERIAL implies UNSIGNED | |
| ) { | |
| $full_type_str .= ' unsigned'; | |
| } | |
| if ($data_type_node->get_first_descendant_token(WP_MySQL_Lexer::ZEROFILL_SYMBOL)) { | |
| $full_type_str .= ' zerofill'; | |
| // ZEROFILL also implies UNSIGNED for numeric types | |
| if (!str_contains($full_type_str, 'unsigned') && | |
| in_array($norm_type, ['tinyint', 'smallint', 'mediumint', 'int', 'bigint', 'decimal', 'float', 'double'])) { | |
| $full_type_str .= ' unsigned'; | |
| } | |
| } | |
| // ENUM / SET values | |
| if ($norm_type === 'enum' || $norm_type === 'set') { | |
| $string_list_node = $data_type_node->get_first_descendant_node('stringList'); | |
| if ($string_list_node) { | |
| $enum_set_values = []; | |
| foreach ($string_list_node->get_child_nodes('textStringLiteral') as $val_node) { // Or textString | |
| $enum_set_values[] = $this->pdo->quote($this->get_value($val_node)); | |
| } | |
| $full_type_str .= '(' . implode(',', $enum_set_values) . ')'; | |
| } | |
| } | |
| return [$norm_type, $full_type_str]; | |
| } | |
| private function get_column_charset_collation_from_ast(WP_Parser_Node $col_def_node, string $norm_type, ?string $table_collation): array { | |
| // String types: CHAR, VARCHAR, TEXT variants, ENUM, SET | |
| $string_types = ['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set']; | |
| if (!in_array($norm_type, $string_types)) { | |
| return [null, null]; | |
| } | |
| $charset_val = null; | |
| $collation_val = null; | |
| // Check for CHARACTER SET clause on column | |
| $charset_attr_node = $this->find_column_attribute_node($col_def_node, 'CHARACTER SET') ?? | |
| $this->find_column_attribute_node($col_def_node, 'CHARSET'); | |
| if ($charset_attr_node) { | |
| $charset_name_node = $charset_attr_node->get_first_child_node('charsetName'); // Or identifier | |
| if ($charset_name_node) { | |
| $charset_val = strtolower($this->get_value($charset_name_node)); | |
| if ($charset_val === 'utf8mb3') $charset_val = 'utf8'; | |
| } | |
| } | |
| // Check for COLLATE clause on column | |
| $collate_attr_node = $this->find_column_attribute_node($col_def_node, 'COLLATE'); | |
| if ($collate_attr_node) { | |
| $collation_name_node = $collate_attr_node->get_first_child_node('collationName'); // Or identifier | |
| if ($collation_name_node) { | |
| $collation_val = strtolower($this->get_value($collation_name_node)); | |
| } | |
| } | |
| // Binary attribute on string types (e.g. VARCHAR(50) BINARY) implies binary collation for that charset. | |
| // The dataType node itself might have a BINARY token for some types e.g. CHAR BINARY | |
| $data_type_node = $col_def_node->get_first_descendant_node('dataType'); | |
| $is_type_declared_binary = false; // Check if CHAR/VARCHAR was defined with BINARY keyword itself. | |
| if ($data_type_node->has_child_token(WP_MySQL_Lexer::BINARY_SYMBOL) && ($norm_type === 'char' || $norm_type === 'varchar')) { | |
| $is_type_declared_binary = true; | |
| } | |
| if ($charset_val && !$collation_val) { | |
| // If charset specified, derive collation | |
| $collation_val = ($is_type_declared_binary || str_ends_with($charset_val, '_bin')) ? $charset_val . '_bin' : (self::CHARSET_DEFAULT_COLLATION_MAP[$charset_val] ?? $charset_val . '_general_ci'); | |
| } elseif (!$charset_val && $collation_val) { | |
| // If collation specified, derive charset | |
| $charset_val = explode('_', $collation_val)[0]; | |
| if ($charset_val === 'utf8mb3') $charset_val = 'utf8'; | |
| } elseif (!$charset_val && !$collation_val) { | |
| // Neither specified, inherit from table (or ultimately DB/server default) | |
| if ($table_collation) { | |
| $collation_val = $table_collation; | |
| $charset_val = explode('_', $collation_val)[0]; | |
| if ($charset_val === 'utf8mb3') $charset_val = 'utf8'; | |
| } else { | |
| // Fallback to hardcoded default if table collation also unknown | |
| $charset_val = 'utf8mb4'; | |
| $collation_val = 'utf8mb4_0900_ai_ci'; | |
| } | |
| } | |
| // If CHAR/VARCHAR was defined with BINARY keyword, this implies the _bin collation for its charset. | |
| if ($is_type_declared_binary && $charset_val && !str_ends_with($collation_val, '_bin')) { | |
| $collation_val = $charset_val . '_bin'; | |
| } | |
| return [$charset_val, $collation_val]; | |
| } | |
| private function find_column_attribute_node(WP_Parser_Node $col_def_node, string $attr_name_upper): ?WP_Parser_Node { | |
| foreach ($col_def_node->get_descendant_nodes('columnAttribute') as $attr_node) { | |
| $tokens = $attr_node->get_child_tokens(); | |
| if (empty($tokens)) continue; | |
| if (strtoupper($tokens[0]->value) === $attr_name_upper) return $attr_node; | |
| if (count($tokens)>1 && strtoupper($tokens[0]->value . " " . $tokens[1]->value) === $attr_name_upper) return $attr_node; // For "CHARACTER SET" | |
| } | |
| return null; | |
| } | |
| // Placeholder for a method that gets value from a general expression AST node. | |
| // This is complex and requires a mini-SQL-serializer for expressions. | |
| private function get_value_from_expression_node(WP_Parser_Node $expr_node): string { | |
| // Simplified: attempt to concatenate values of all child tokens. | |
| // This will not correctly handle operator precedence, function calls, etc. | |
| // A proper implementation would recursively build the string representation. | |
| $str = ""; | |
| foreach($expr_node->get_descendant_tokens() as $token) { | |
| $str .= $token->value . " "; | |
| } | |
| return trim($str); | |
| } | |
| // ... Other private helpers for parsing lengths, numeric attributes, datetime precision from AST ... | |
| // These would be similar to the ones in the SQLite driver's InformationSchemaBuilder, | |
| // but focused on extracting MySQL specific details from the AST. | |
| private function get_column_lengths_from_ast(WP_Parser_Node $data_type_node, string $norm_type, ?string $charset): array { | |
| // Returns [char_max_len, char_octet_len] | |
| $len_m = null; | |
| $field_length_node = $data_type_node->get_first_descendant_node('fieldLength'); | |
| if ($field_length_node) { | |
| $len_m = (int) $this->get_value_from_expression_node($field_length_node); // extracts number from (M) | |
| $len_m = abs($len_m); // M must be positive | |
| } | |
| $string_types = ['char', 'varchar']; | |
| $text_blob_sizes = [ | |
| 'tinytext' => 255, 'tinyblob' => 255, | |
| 'text' => 65535, 'blob' => 65535, | |
| 'mediumtext' => 16777215, 'mediumblob' => 16777215, | |
| 'longtext' => 4294967295, 'longblob' => 4294967295, | |
| ]; | |
| if (in_array($norm_type, $string_types)) { | |
| $max_len = $len_m ?? (($norm_type === 'char') ? 1 : null); // VARCHAR has no default length in MySQL, error if not given. CHAR defaults to 1. | |
| if ($max_len === null && $norm_type === 'varchar') { | |
| // This should be an error during MySQL parsing if VARCHAR length is missing. | |
| // Here, assume AST is valid, so length must be present for VARCHAR. | |
| } | |
| $bytes_per_char = ($charset && isset(self::CHARSET_MAX_BYTES_MAP[$charset])) ? self::CHARSET_MAX_BYTES_MAP[$charset] : 1; | |
| return [$max_len, $max_len ? $max_len * $bytes_per_char : null]; | |
| } elseif (array_key_exists($norm_type, $text_blob_sizes)) { | |
| $size = $text_blob_sizes[$norm_type]; | |
| return [$size, $size]; // For text/blob, char_max_len and octet_len are same fixed values. | |
| } elseif ($norm_type === 'enum' || $norm_type === 'set') { | |
| // Max length of an enum/set value string | |
| $max_val_len = 0; | |
| $string_list_node = $data_type_node->get_first_descendant_node('stringList'); | |
| if ($string_list_node) { | |
| foreach ($string_list_node->get_child_nodes('textStringLiteral') as $val_node) { | |
| $max_val_len = max($max_val_len, strlen($this->get_value($val_node))); | |
| } | |
| } | |
| $bytes_per_char = ($charset && isset(self::CHARSET_MAX_BYTES_MAP[$charset])) ? self::CHARSET_MAX_BYTES_MAP[$charset] : 1; | |
| return [$max_val_len, $max_val_len * $bytes_per_char]; | |
| } | |
| return [null, null]; // Not applicable for non-string types | |
| } | |
| private function get_column_numeric_attrs_from_ast(WP_Parser_Node $data_type_node, string $norm_type): array { | |
| // Returns [numeric_precision, numeric_scale] | |
| $precision = null; $scale = null; | |
| $length_node = $data_type_node->get_first_descendant_node('fieldLength'); // (M) | |
| $precision_def_node = $data_type_node->get_first_descendant_node('precision'); // (M,D) | |
| if ($precision_def_node) { | |
| $parts = explode(',', trim($this->get_value_from_expression_node($precision_def_node), '()')); | |
| $precision = isset($parts[0]) ? (int)$parts[0] : null; | |
| $scale = isset($parts[1]) ? (int)$parts[1] : null; | |
| } elseif ($length_node) { | |
| $precision = (int)trim($this->get_value_from_expression_node($length_node), '()'); | |
| // For float/double, if only M is given, it's total digits. D is digits after decimal. | |
| // MySQL docs say: if D is omitted, it's 0 for DECIMAL/NUMERIC. For FLOAT/DOUBLE, D is not fixed if omitted. | |
| // The INFORMATION_SCHEMA for FLOAT/DOUBLE with only M specified is a bit murky. | |
| // Often NUMERIC_PRECISION reflects M, and NUMERIC_SCALE is NULL or related to actual stored values. | |
| if ($norm_type === 'decimal' || $norm_type === 'numeric') $scale = 0; | |
| } | |
| // Default precisions for integer types (display width, not storage precision) | |
| $int_precisions = ['tinyint'=>3, 'smallint'=>5, 'mediumint'=>8, 'int'=>10, 'bigint'=>19]; // Signed max digits approx | |
| if (array_key_exists($norm_type, $int_precisions)) { | |
| $precision = $precision ?? $int_precisions[$norm_type]; // M is display width, not really precision | |
| $scale = $scale ?? 0; | |
| // If unsigned, bigint can be 20 | |
| if ($norm_type === 'bigint' && $data_type_node->get_first_descendant_token(WP_MySQL_Lexer::UNSIGNED_SYMBOL)) { | |
| $precision = 20; | |
| } | |
| } elseif ($norm_type === 'float') { | |
| $precision = $precision ?? 12; // Default approx based on single precision range | |
| // Scale for float is variable if not specified. | |
| } elseif ($norm_type === 'double' || $norm_type === 'real') { | |
| $precision = $precision ?? 22; // Default approx based on double precision range | |
| } elseif ($norm_type === 'decimal' || $norm_type === 'numeric') { | |
| $precision = $precision ?? 10; | |
| $scale = $scale ?? 0; | |
| } elseif ($norm_type === 'bit') { | |
| $precision = $precision ?? 1; // M in BIT(M) is bit length | |
| $scale = null; // Not applicable | |
| } | |
| return [$precision, $scale]; | |
| } | |
| private function get_column_datetime_precision_from_ast(WP_Parser_Node $data_type_node, string $norm_type): ?int { | |
| // Fractional Seconds Precision (FSP) for TIME, DATETIME, TIMESTAMP | |
| if (in_array($norm_type, ['time', 'datetime', 'timestamp'])) { | |
| $fsp_node = $data_type_node->get_first_descendant_node('typeDatetimePrecision'); | |
| if ($fsp_node) { | |
| return (int)trim($this->get_value_from_expression_node($fsp_node), '()'); | |
| } | |
| return 0; // Default FSP is 0 if not specified | |
| } | |
| return null; | |
| } | |
| // --- CRUD helpers for emulated schema tables --- | |
| private function insert_values( string $table_name, array $data ): void { | |
| if (empty($data)) return; | |
| $columns = implode(', ', array_map([$this, 'quote_pgsql_identifier_simple'], array_keys($data))); | |
| $placeholders = implode(', ', array_fill(0, count($data), '?')); | |
| $sql = "INSERT INTO $table_name ($columns) VALUES ($placeholders)"; | |
| $this->query($sql, array_values($data)); | |
| } | |
| private function update_values( string $table_name, array $data, array $where_criteria ): void { | |
| if (empty($data) || empty($where_criteria)) return; | |
| $set_clauses = []; | |
| $params = []; | |
| foreach ($data as $col => $val) { | |
| $set_clauses[] = $this->quote_pgsql_identifier_simple($col) . " = ?"; | |
| $params[] = $val; | |
| } | |
| $where_clauses = []; | |
| foreach ($where_criteria as $col => $val) { | |
| $where_clauses[] = $this->quote_pgsql_identifier_simple($col) . " = ?"; | |
| $params[] = $val; | |
| } | |
| $sql = "UPDATE $table_name SET " . implode(', ', $set_clauses) . " WHERE " . implode(' AND ', $where_clauses); | |
| $this->query($sql, $params); | |
| } | |
| private function delete_values( string $table_name, array $where_criteria ): void { | |
| if (empty($where_criteria)) return; // Safety: don't delete all rows | |
| $where_clauses = []; | |
| $params = []; | |
| foreach ($where_criteria as $col => $val) { | |
| $where_clauses[] = $this->quote_pgsql_identifier_simple($col) . " = ?"; | |
| $params[] = $val; | |
| } | |
| $sql = "DELETE FROM $table_name WHERE " . implode(' AND ', $where_clauses); | |
| $this->query($sql, $params); | |
| } | |
| // Simplified quoter for known-safe column names in emulated schema tables | |
| private function quote_pgsql_identifier_simple(string $name): string { | |
| return '"' . str_replace('"', '""', $name) . '"'; | |
| } | |
| private function query( string $query, array $params = array() ) { | |
| return call_user_func($this->query_callback, $query, $params); | |
| } | |
| // --- Helper methods for handling specific ALTER TABLE actions and table constraint definitions --- | |
| private function record_table_constraint_def_from_ast(string $table_name_str, WP_Parser_Node $constraint_node, bool $is_temporary, string $stats_table, string $tbl_constraints_table, string $kcu_table): void { | |
| $constraint_name_node = $constraint_node->get_first_child_node('constraintName'); | |
| $constraint_name_str = $constraint_name_node ? $this->get_value($constraint_name_node) : null; | |
| $constraint_type_str = ''; | |
| $index_name_for_stats = null; | |
| $non_unique_for_stats = 1; | |
| $index_type_for_stats = 'BTREE'; // Default | |
| $key_list_node = $constraint_node->get_first_descendant_node('keyListVariants'); // Or keyList | |
| if (!$key_list_node) return; // Cannot define constraint without columns | |
| $key_parts_nodes = $key_list_node->get_descendant_nodes('keyPart'); // Or keyPartOrExpression for functional indexes | |
| $columns_in_constraint = []; | |
| foreach($key_parts_nodes as $kp_node) { | |
| $col_name_node = $kp_node->get_first_child_node('identifier'); | |
| if ($col_name_node) { | |
| $columns_in_constraint[] = [ | |
| 'name' => $this->get_value($col_name_node), | |
| 'sub_part' => null, // Parse length if present: (M) | |
| 'order' => ($kp_node->has_child_token(WP_MySQL_Lexer::DESC_SYMBOL)) ? 'D' : 'A', | |
| ]; | |
| // TODO: Parse sub_part length from keyPart -> fieldLength if it exists | |
| } | |
| // TODO: Handle functional index expressions if keyPartOrExpression is used | |
| } | |
| if (empty($columns_in_constraint)) return; | |
| $first_token_in_constraint = $constraint_node->get_first_child_token(); // e.g. PRIMARY, UNIQUE, FOREIGN, KEY, INDEX, CONSTRAINT | |
| // Determine constraint type and name for stats | |
| if ($constraint_node->has_child_token(WP_MySQL_Lexer::PRIMARY_SYMBOL) || ($first_token_in_constraint && $first_token_in_constraint->id === WP_MySQL_Lexer::PRIMARY_SYMBOL)) { | |
| $constraint_type_str = 'PRIMARY KEY'; | |
| $index_name_for_stats = 'PRIMARY'; | |
| $non_unique_for_stats = 0; | |
| if (!$constraint_name_str) $constraint_name_str = 'PRIMARY'; // MySQL names PK constraint 'PRIMARY' | |
| } elseif ($constraint_node->has_child_token(WP_MySQL_Lexer::UNIQUE_SYMBOL) || ($first_token_in_constraint && $first_token_in_constraint->id === WP_MySQL_Lexer::UNIQUE_SYMBOL)) { | |
| $constraint_type_str = 'UNIQUE'; | |
| $non_unique_for_stats = 0; | |
| $index_name_for_stats = $constraint_name_str ?? $columns_in_constraint[0]['name']; // MySQL default unique key name | |
| if (!$constraint_name_str) $constraint_name_str = $index_name_for_stats; | |
| } elseif ($constraint_node->has_child_token(WP_MySQL_Lexer::FOREIGN_SYMBOL) || ($first_token_in_constraint && $first_token_in_constraint->id === WP_MySQL_Lexer::FOREIGN_SYMBOL)) { | |
| $constraint_type_str = 'FOREIGN KEY'; | |
| // Foreign keys are not typically in 'statistics' table in MySQL in the same way as indexes. | |
| // They are in referential_constraints and key_column_usage. | |
| // Index might be auto-created by InnoDB for FK. If so, its name would differ. | |
| // For now, skip adding to statistics table directly from FK def unless an explicit index is also part of AST. | |
| $index_name_for_stats = null; // Or name of auto-created index if applicable. | |
| if (!$constraint_name_str) $constraint_name_str = $table_name_str . '_ibfk_' . uniqid(); | |
| } elseif ($constraint_node->has_child_token(WP_MySQL_Lexer::INDEX_SYMBOL) || $constraint_node->has_child_token(WP_MySQL_Lexer::KEY_SYMBOL) || | |
| ($first_token_in_constraint && ($first_token_in_constraint->id === WP_MySQL_Lexer::INDEX_SYMBOL || $first_token_in_constraint->id === WP_MySQL_Lexer::KEY_SYMBOL)) ) { | |
| $constraint_type_str = 'INDEX'; // This is not a formal SQL constraint type but MySQL uses KEY/INDEX. | |
| $non_unique_for_stats = 1; | |
| $index_name_for_stats = $constraint_name_str ?? $columns_in_constraint[0]['name']; | |
| if (!$constraint_name_str) $constraint_name_str = $index_name_for_stats; // This is for constraint name, index name for stats is index_name_for_stats | |
| } elseif ($constraint_node->has_child_token(WP_MySQL_Lexer::CHECK_SYMBOL) || ($first_token_in_constraint && $first_token_in_constraint->id === WP_MySQL_Lexer::CHECK_SYMBOL)) { | |
| $constraint_type_str = 'CHECK'; | |
| $index_name_for_stats = null; // CHECK constraints don't go into statistics. | |
| if (!$constraint_name_str) $constraint_name_str = $table_name_str . '_chk_' . uniqid(); | |
| } else { | |
| // Unidentified constraint type (e.g. just CONSTRAINT name ...) - less common for tableConstraintDef | |
| return; | |
| } | |
| // For FULLTEXT/SPATIAL indexes, keyword appears before INDEX/KEY | |
| if ($constraint_node->has_child_token(WP_MySQL_Lexer::FULLTEXT_SYMBOL)) $index_type_for_stats = 'FULLTEXT'; | |
| if ($constraint_node->has_child_token(WP_MySQL_Lexer::SPATIAL_SYMBOL)) $index_type_for_stats = 'SPATIAL'; | |
| // Record in table_constraints | |
| $this->insert_values($tbl_constraints_table, [ | |
| 'constraint_catalog' => $this->actual_pg_schema_name, // Or appropriate catalog source | |
| 'constraint_schema' => $this->emulated_db_name, | |
| 'constraint_name' => $constraint_name_str, | |
| 'table_schema' => $this->emulated_db_name, | |
| 'table_name' => $table_name_str, | |
| 'constraint_type' => $constraint_type_str, | |
| ]); | |
| // Record in statistics (for PK, UNIQUE, INDEX/KEY) | |
| if ($index_name_for_stats !== null && ($constraint_type_str === 'PRIMARY KEY' || $constraint_type_str === 'UNIQUE' || $constraint_type_str === 'INDEX')) { | |
| $seq = 1; | |
| foreach ($columns_in_constraint as $col_info) { | |
| // Need to determine IS_NULLABLE for the column in this index. | |
| // For PK, it's always ''. For UNIQUE, depends on column's nullability. | |
| // This requires querying the columns info table. | |
| $is_col_nullable_in_stats = ''; // Assume NOT NULL for PK. For others, query. | |
| // For simplicity, placeholder. A full implementation would fetch column's IS_NULLABLE. | |
| // If part of PK, then IS_NULLABLE in COLUMNS becomes NO. | |
| $this->insert_values($stats_table, [ | |
| 'table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, | |
| 'non_unique' => $non_unique_for_stats, 'index_schema' => $this->emulated_db_name, | |
| 'index_name' => $index_name_for_stats, 'seq_in_index' => $seq, | |
| 'column_name' => $col_info['name'], | |
| 'COLLATION' => $col_info['order'], | |
| 'sub_part' => $col_info['sub_part'], | |
| 'nullable' => $is_col_nullable_in_stats, // Needs proper derivation | |
| 'index_type' => $index_type_for_stats, // BTREE, HASH, FULLTEXT, SPATIAL | |
| 'index_comment' => '', // Parse from indexOption if present | |
| ]); | |
| $seq++; | |
| } | |
| } | |
| // Record in key_column_usage (for PK, UNIQUE, FK) | |
| if ($constraint_type_str === 'PRIMARY KEY' || $constraint_type_str === 'UNIQUE' || $constraint_type_str === 'FOREIGN KEY') { | |
| $seq_kcu = 1; | |
| foreach ($columns_in_constraint as $col_info) { | |
| $kcu_data = [ | |
| 'constraint_catalog' => $this->actual_pg_schema_name, 'constraint_schema' => $this->emulated_db_name, | |
| 'constraint_name' => $constraint_name_str, 'table_catalog' => $this->actual_pg_schema_name, | |
| 'table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, | |
| 'column_name' => $col_info['name'], 'ordinal_position' => $seq_kcu, | |
| ]; | |
| if ($constraint_type_str === 'FOREIGN KEY') { | |
| // Parse referenced table/columns from AST (referencesClause) | |
| $ref_clause_node = $constraint_node->get_first_descendant_node('referencesClause'); | |
| if ($ref_clause_node) { | |
| $ref_table_node = $ref_clause_node->get_first_child_node('tableName'); // Or tableRef | |
| $kcu_data['referenced_table_name'] = $this->get_value($ref_table_node); | |
| // Referenced columns from keyList in referencesClause | |
| $ref_cols_list_node = $ref_clause_node->get_first_child_node('keyList'); | |
| if ($ref_cols_list_node && isset($ref_cols_list_node->get_child_nodes('identifier')[$seq_kcu-1])) { | |
| $kcu_data['referenced_column_name'] = $this->get_value($ref_cols_list_node->get_child_nodes('identifier')[$seq_kcu-1]); | |
| } | |
| // POSITION_IN_UNIQUE_CONSTRAINT needs matching with referenced table's PK/UNIQUE constraint columns. | |
| } | |
| } | |
| $this->insert_values($kcu_table, $kcu_data); | |
| $seq_kcu++; | |
| } | |
| } | |
| // TODO: Record in referential_constraints for FOREIGN KEY (MATCH, UPDATE/DELETE rules) | |
| } | |
| private function handle_alter_add_from_ast(WP_Parser_Node $action_node, string $table_name_str, bool $is_temporary, string $cols_table, string $stats_table, string $tbl_constraints_table, string $kcu_table, array &$current_cols_map): void { | |
| $column_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::COLUMN_SYMBOL); | |
| $constraint_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::CONSTRAINT_SYMBOL); | |
| if ($column_keyword || $action_node->get_first_descendant_node('columnDefinition')) { // ADD [COLUMN] col_def | |
| $col_def_node = $action_node->get_first_descendant_node('columnDefinition'); | |
| if (!$col_def_node) return; // Should exist if it's ADD COLUMN | |
| $column_name_str = $this->get_value($col_def_node->get_first_child_node('fieldIdentifier')); | |
| // Determine ordinal position (MySQL supports FIRST, AFTER col) | |
| // For simplicity, add at end if not specified. | |
| $max_pos = 0; | |
| foreach ($current_cols_map as $pos) $max_pos = max($max_pos, $pos); | |
| $ordinal_position = $max_pos + 1; | |
| // TODO: Handle FIRST/AFTER for ordinal_position and re-numbering. | |
| $table_collation_stmt = $this->query("SELECT table_collation FROM ".$this->get_emulated_info_table_name('tables', $is_temporary)." WHERE table_schema = ? AND table_name = ?", [$this->emulated_db_name, $table_name_str]); | |
| $table_collation = $table_collation_stmt->fetchColumn(); | |
| $mysql_column_data = $this->extract_mysql_column_data_from_ast($table_name_str, $column_name_str, $col_def_node, $ordinal_position, $table_collation); | |
| $this->insert_values($cols_table, $mysql_column_data); | |
| $current_cols_map[$column_name_str] = $ordinal_position; // Update map | |
| // Handle inline constraint if any | |
| if ($mysql_column_data['column_key'] === 'PRI' || $mysql_column_data['column_key'] === 'UNI') { | |
| // Add to statistics, table_constraints, key_column_usage as in record_create_table | |
| } | |
| } elseif ($constraint_keyword || $action_node->get_first_descendant_node('tableConstraintDef')) { // ADD CONSTRAINT ... | |
| $constraint_def_node = $action_node->get_first_descendant_node('tableConstraintDef'); | |
| if ($constraint_def_node) { | |
| $this->record_table_constraint_def_from_ast($table_name_str, $constraint_def_node, $is_temporary, $stats_table, $tbl_constraints_table, $kcu_table); | |
| } | |
| } elseif ($action_node->get_first_child_token(WP_MySQL_Lexer::INDEX_SYMBOL) || $action_node->get_first_child_token(WP_MySQL_Lexer::KEY_SYMBOL) || | |
| $action_node->get_first_child_token(WP_MySQL_Lexer::FULLTEXT_SYMBOL) || $action_node->get_first_child_token(WP_MySQL_Lexer::SPATIAL_SYMBOL) ) { | |
| // ADD INDEX/KEY/FULLTEXT/SPATIAL ... (not inside a tableConstraintDef node, but directly) | |
| // This structure is common too. Need to parse index name, columns from here. | |
| // This is essentially like creating a tableConstraintDef node on the fly from these parts. | |
| // Example: ADD INDEX my_idx (col1, col2) | |
| // For now, assume it's covered if AST nests this under a tableConstraintDef like structure. | |
| // If not, this needs specific parsing here. | |
| } | |
| } | |
| private function handle_alter_drop_from_ast(WP_Parser_Node $action_node, string $table_name_str, bool $is_temporary, string $cols_table, string $stats_table, string $tbl_constraints_table, string $kcu_table, array &$col_map_recreate): void { | |
| $column_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::COLUMN_SYMBOL); | |
| $constraint_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::CONSTRAINT_SYMBOL); | |
| $index_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::INDEX_SYMBOL) ?? $action_node->get_first_child_token(WP_MySQL_Lexer::KEY_SYMBOL); | |
| $primary_key_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::PRIMARY_SYMBOL) && $action_node->get_first_child_token(WP_MySQL_Lexer::KEY_SYMBOL, 1); // DROP PRIMARY KEY | |
| $foreign_key_keyword = $action_node->get_first_child_token(WP_MySQL_Lexer::FOREIGN_SYMBOL) && $action_node->get_first_child_token(WP_MySQL_Lexer::KEY_SYMBOL, 1); // DROP FOREIGN KEY fk_name | |
| if ($column_keyword || $action_node->get_first_child_node('fieldIdentifier')) { // DROP [COLUMN] col_name | |
| $col_name_node = $action_node->get_first_child_node('fieldIdentifier'); | |
| if (!$col_name_node) return; | |
| $col_name_to_drop = $this->get_value($col_name_node); | |
| $this->delete_values($cols_table, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_name_to_drop]); | |
| unset($col_map_recreate[$col_name_to_drop]); | |
| // Remove column from any statistics entries | |
| $this->delete_values($stats_table, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_name_to_drop]); | |
| // TODO: If all columns of an index are dropped, the index itself should be dropped from statistics and table_constraints. | |
| // This needs checking which indexes are now empty. | |
| // Also re-sequence SEQ_IN_INDEX for affected multi-column indexes. | |
| // Remove from KCU if part of constraints | |
| $this->delete_values($kcu_table, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_name_to_drop]); | |
| } elseif ($index_keyword || $primary_key_keyword || $foreign_key_keyword || $constraint_keyword) { // DROP INDEX idx_name | DROP PRIMARY KEY | DROP FOREIGN KEY fk_name | DROP CONSTRAINT cons_name | |
| $name_to_drop = null; | |
| if ($primary_key_keyword) { | |
| $name_to_drop = 'PRIMARY'; // Standard name for PK index/constraint | |
| $constraint_type_to_drop = 'PRIMARY KEY'; | |
| } else { | |
| $name_node = $action_node->get_first_child_node('indexRef') ?? // For DROP INDEX | |
| $action_node->get_first_child_node('fkSymbol') ?? // For DROP FOREIGN KEY (fk_name) | |
| $action_node->get_first_child_node('constraintName'); // For DROP CONSTRAINT | |
| if (!$name_node) return; | |
| $name_to_drop = $this->get_value($name_node); | |
| $constraint_type_to_drop = $foreign_key_keyword ? 'FOREIGN KEY' : null; // If not FK, type needs to be fetched or assumed INDEX/UNIQUE | |
| } | |
| // Delete from statistics | |
| $this->delete_values($stats_table, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'index_name' => $name_to_drop]); | |
| // Delete from table_constraints | |
| $where_tc = ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'constraint_name' => $name_to_drop]; | |
| if ($constraint_type_to_drop) $where_tc['constraint_type'] = $constraint_type_to_drop; | |
| $this->delete_values($tbl_constraints_table, $where_tc); | |
| // Delete from key_column_usage | |
| $this->delete_values($kcu_table, ['constraint_schema' => $this->emulated_db_name, 'constraint_name' => $name_to_drop, 'table_name' => $table_name_str]); | |
| // If FK dropped, delete from referential_constraints | |
| if ($foreign_key_keyword || ($constraint_type_to_drop === 'FOREIGN KEY')) { | |
| $this->delete_values($this->get_emulated_info_table_name('referential_constraints', $is_temporary), | |
| ['constraint_schema' => $this->emulated_db_name, 'constraint_name' => $name_to_drop]); | |
| } | |
| } | |
| } | |
| private function handle_alter_change_from_ast(WP_Parser_Node $action_node, string $table_name_str, bool $is_temporary, string $cols_table, string $stats_table, string $tbl_constraints_table, string $kcu_table, array &$col_map_recreate): void { | |
| $old_col_name_node = $action_node->get_first_child_node('fieldIdentifier'); | |
| $new_col_def_node = $action_node->get_first_descendant_node('columnDefinition'); // MySQL CHANGE provides full new definition | |
| if (!$old_col_name_node || !$new_col_def_node) return; | |
| $old_col_name_str = $this->get_value($old_col_name_node); | |
| $new_col_name_str = $this->get_value($new_col_def_node->get_first_child_node('fieldIdentifier')); | |
| // Get current ordinal position to preserve it | |
| $ordinal_pos_stmt = $this->query("SELECT ordinal_position FROM $cols_table WHERE table_schema = ? AND table_name = ? AND column_name = ?", [$this->emulated_db_name, $table_name_str, $old_col_name_str]); | |
| $ordinal_position = $ordinal_pos_stmt->fetchColumn(); | |
| if ($ordinal_position === false) $ordinal_position = 999; // Fallback if not found (shouldn't happen) | |
| $table_collation_stmt = $this->query("SELECT table_collation FROM ".$this->get_emulated_info_table_name('tables', $is_temporary)." WHERE table_schema = ? AND table_name = ?", [$this->emulated_db_name, $table_name_str]); | |
| $table_collation = $table_collation_stmt->fetchColumn(); | |
| $mysql_column_data = $this->extract_mysql_column_data_from_ast($table_name_str, $new_col_name_str, $new_col_def_node, (int)$ordinal_position, $table_collation); | |
| $this->update_values($cols_table, $mysql_column_data, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $old_col_name_str]); | |
| if ($old_col_name_str !== $new_col_name_str) { | |
| $col_map_recreate[$old_col_name_str] = $new_col_name_str; | |
| // Update column name in statistics and KCU | |
| $this->update_values($stats_table, ['column_name' => $new_col_name_str], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $old_col_name_str]); | |
| $this->update_values($kcu_table, ['column_name' => $new_col_name_str], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $old_col_name_str, 'constraint_schema' => $this->emulated_db_name]); | |
| } | |
| // If inline constraint changed, it's more complex. CHANGE often implies previous inline constraints on old col are gone unless redefined. | |
| // This simplified version relies on sync_column_key_info to update based on stats table, | |
| // and assumes new_col_def_node might add new inline constraints that get recorded. | |
| } | |
| private function handle_alter_modify_from_ast(WP_Parser_Node $action_node, string $table_name_str, bool $is_temporary, string $cols_table, string $stats_table, string $tbl_constraints_table, string $kcu_table): void { | |
| // MODIFY [COLUMN] col_name new_col_def (no rename) | |
| $col_name_node = $action_node->get_first_child_node('fieldIdentifier'); | |
| $new_col_def_node = $action_node->get_first_descendant_node('columnDefinition'); // Same as CHANGE, but col name in def must match existing. | |
| if (!$col_name_node || !$new_col_def_node) return; | |
| $col_name_str = $this->get_value($col_name_node); | |
| // Ensure name in new_col_def matches col_name_str for MODIFY semantic | |
| $name_in_def = $this->get_value($new_col_def_node->get_first_child_node('fieldIdentifier')); | |
| if ($col_name_str !== $name_in_def) { | |
| // This is an error in MySQL syntax or parser error. MODIFY doesn't rename. | |
| return; | |
| } | |
| // Get current ordinal position | |
| $ordinal_pos_stmt = $this->query("SELECT ordinal_position FROM $cols_table WHERE table_schema = ? AND table_name = ? AND column_name = ?", [$this->emulated_db_name, $table_name_str, $col_name_str]); | |
| $ordinal_position = $ordinal_pos_stmt->fetchColumn(); | |
| if ($ordinal_position === false) return; // Column not found | |
| $table_collation_stmt = $this->query("SELECT table_collation FROM ".$this->get_emulated_info_table_name('tables', $is_temporary)." WHERE table_schema = ? AND table_name = ?", [$this->emulated_db_name, $table_name_str]); | |
| $table_collation = $table_collation_stmt->fetchColumn(); | |
| $mysql_column_data = $this->extract_mysql_column_data_from_ast($table_name_str, $col_name_str, $new_col_def_node, (int)$ordinal_position, $table_collation); | |
| $this->update_values($cols_table, $mysql_column_data, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_name_str]); | |
| } | |
| private function handle_alter_column_options_from_ast(WP_Parser_Node $action_node, string $table_name_str, bool $is_temporary, string $cols_table): void { | |
| // ALTER [COLUMN] col_name SET DEFAULT literal | DROP DEFAULT | |
| $col_name_node = $action_node->get_first_child_node('fieldIdentifier'); | |
| if (!$col_name_node) return; | |
| $col_name_str = $this->get_value($col_name_node); | |
| $update_data = []; | |
| if ($action_node->has_child_token(WP_MySQL_Lexer::SET_SYMBOL) && $action_node->has_child_token(WP_MySQL_Lexer::DEFAULT_SYMBOL, 1)) { | |
| $default_literal_node = $action_node->get_first_descendant_node('signedLiteral') ?? $action_node->get_first_descendant_node('textStringLiteral'); | |
| // Could also be CURRENT_TIMESTAMP etc. | |
| if ($default_literal_node) { | |
| $update_data['column_default'] = $this->get_value($default_literal_node); | |
| } elseif ($action_node->has_child_token(WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL) || $action_node->has_child_token(WP_MySQL_Lexer::NOW_SYMBOL)) { | |
| $update_data['column_default'] = 'CURRENT_TIMESTAMP'; | |
| } | |
| } elseif ($action_node->has_child_token(WP_MySQL_Lexer::DROP_SYMBOL) && $action_node->has_child_token(WP_MySQL_Lexer::DEFAULT_SYMBOL, 1)) { | |
| $update_data['column_default'] = null; | |
| } | |
| if (!empty($update_data)) { | |
| $this->update_values($cols_table, $update_data, ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_name_str]); | |
| } | |
| } | |
| private function handle_alter_rename_from_ast(WP_Parser_Node $action_node, string $current_table_name_str, bool $is_temporary, array &$col_map_recreate /* more tables needed for full rename handling */): void { | |
| // RENAME [COLUMN] old TO new | RENAME [INDEX] old TO new | RENAME [TO|AS] new_table_name | |
| $tables_info = $this->get_emulated_info_table_name('tables', $is_temporary); | |
| $cols_info = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $stats_info = $this->get_emulated_info_table_name('statistics', $is_temporary); | |
| $constraints_info = $this->get_emulated_info_table_name('table_constraints', $is_temporary); | |
| $kcu_info = $this->get_emulated_info_table_name('key_column_usage', $is_temporary); | |
| // etc. for other info tables. | |
| if ($action_node->has_child_token(WP_MySQL_Lexer::COLUMN_SYMBOL)) { | |
| $old_name_node = $action_node->get_first_child_node('fieldIdentifier'); // After COLUMN | |
| $new_name_node = $action_node->get_first_child_node('identifier'); // After TO or target of RENAME | |
| if ($old_name_node && $new_name_node) { | |
| $old_name = $this->get_value($old_name_node); | |
| $new_name = $this->get_value($new_name_node); | |
| $this->update_values($cols_info, ['column_name' => $new_name], ['table_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str, 'column_name'=>$old_name]); | |
| $this->update_values($stats_info, ['column_name' => $new_name], ['table_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str, 'column_name'=>$old_name]); | |
| $this->update_values($kcu_info, ['column_name' => $new_name], ['table_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str, 'column_name'=>$old_name, 'constraint_schema'=>$this->emulated_db_name]); | |
| if (isset($col_map_recreate[$old_name])) $col_map_recreate[$new_name] = $col_map_recreate[$old_name]; unset($col_map_recreate[$old_name]); | |
| } | |
| } elseif ($action_node->has_child_token(WP_MySQL_Lexer::INDEX_SYMBOL) || $action_node->has_child_token(WP_MySQL_Lexer::KEY_SYMBOL)) { | |
| $old_name_node = $action_node->get_first_child_node('indexRef'); // old index name | |
| $new_name_node = $action_node->get_first_child_node('identifier'); // new index name (target of TO) | |
| if ($old_name_node && $new_name_node) { | |
| $old_name = $this->get_value($old_name_node); | |
| $new_name = $this->get_value($new_name_node); | |
| $this->update_values($stats_info, ['index_name' => $new_name], ['table_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str, 'index_name'=>$old_name]); | |
| // Also update constraint_name in table_constraints and key_column_usage if this index was backing a constraint | |
| $this->update_values($constraints_info, ['constraint_name' => $new_name], ['table_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str, 'constraint_name'=>$old_name]); | |
| $this->update_values($kcu_info, ['constraint_name' => $new_name], ['constraint_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str, 'constraint_name'=>$old_name]); | |
| } | |
| } elseif ($action_node->has_child_token(WP_MySQL_Lexer::TO_SYMBOL) || $action_node->has_child_token(WP_MySQL_Lexer::AS_SYMBOL)) { // RENAME [TO|AS] new_table_name | |
| $new_table_name_node = $action_node->get_first_child_node('tableName'); // Or identifier | |
| if ($new_table_name_node) { | |
| $new_table_name = $this->get_value($new_table_name_node); | |
| $where_old_table = ['table_schema'=>$this->emulated_db_name, 'table_name'=>$current_table_name_str]; | |
| $update_new_table_name = ['table_name' => $new_table_name]; | |
| $this->update_values($tables_info, $update_new_table_name, $where_old_table); | |
| $this->update_values($cols_info, $update_new_table_name, $where_old_table); | |
| $this->update_values($stats_info, $update_new_table_name, $where_old_table); | |
| $this->update_values($constraints_info, $update_new_table_name, $where_old_table); | |
| $this->update_values($kcu_info, $update_new_table_name, array_merge($where_old_table, ['constraint_schema'=>$this->emulated_db_name])); | |
| // Update table name in referential_constraints if it's referenced or references others | |
| } | |
| } | |
| } | |
| private function sync_column_key_info_after_change(string $table_name_str, bool $is_temporary): void { | |
| $cols_table = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $stats_table = $this->get_emulated_info_table_name('statistics', $is_temporary); | |
| // First, clear all existing COLUMN_KEY for the table | |
| $this->update_values($cols_table, ['column_key' => ''], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str]); | |
| // Set MUL for any column that is part of any index and is first in that index | |
| $stmt_mul = $this->query( | |
| "SELECT DISTINCT column_name FROM $stats_table | |
| WHERE table_schema = ? AND table_name = ? AND seq_in_index = 1", | |
| [$this->emulated_db_name, $table_name_str] | |
| ); | |
| while ($col_row = $stmt_mul->fetch(PDO::FETCH_ASSOC)) { | |
| $this->update_values($cols_table, ['column_key' => 'MUL'], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_row['column_name']]); | |
| } | |
| // Set UNI for any column that is part of any UNIQUE index and is first in that index | |
| $stmt_uni = $this->query( | |
| "SELECT DISTINCT column_name FROM $stats_table | |
| WHERE table_schema = ? AND table_name = ? AND non_unique = 0 AND seq_in_index = 1 AND index_name != 'PRIMARY'", | |
| [$this->emulated_db_name, $table_name_str] | |
| ); | |
| while ($col_row = $stmt_uni->fetch(PDO::FETCH_ASSOC)) { | |
| $this->update_values($cols_table, ['column_key' => 'UNI'], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_row['column_name']]); | |
| } | |
| // Set PRI for any column that is part of PRIMARY KEY | |
| $stmt_pri = $this->query( | |
| "SELECT DISTINCT column_name FROM $stats_table | |
| WHERE table_schema = ? AND table_name = ? AND index_name = 'PRIMARY'", | |
| [$this->emulated_db_name, $table_name_str] | |
| ); | |
| while ($col_row = $stmt_pri->fetch(PDO::FETCH_ASSOC)) { | |
| $this->update_values($cols_table, ['column_key' => 'PRI', 'is_nullable' => 'NO'], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_row['column_name']]); | |
| } | |
| } | |
| private function resequence_ordinal_positions(string $table_name_str, bool $is_temporary, string $cols_table): void { | |
| // After ADD/DROP/CHANGE that might affect order (if FIRST/AFTER was handled) | |
| // Fetch columns in their current order (which might be just insertion order if not explicitly managed) | |
| // For simplicity, this just re-numbers based on current DB order. | |
| // MySQL's internal order is usually preserved unless columns are reordered. | |
| // If actual reordering (FIRST/AFTER) was implemented, this would ensure ORDINAL_POSITION is sequential. | |
| $stmt = $this->query( | |
| "SELECT column_name FROM $cols_table | |
| WHERE table_schema = ? AND table_name = ? | |
| ORDER BY ordinal_position", // Assume ordinal_position reflects desired order somehow. | |
| [$this->emulated_db_name, $table_name_str] | |
| ); | |
| $current_ordered_cols = $stmt->fetchAll(PDO::FETCH_COLUMN); | |
| $pos = 1; | |
| foreach($current_ordered_cols as $col_name) { | |
| $this->update_values($cols_table, ['ordinal_position' => $pos], ['table_schema' => $this->emulated_db_name, 'table_name' => $table_name_str, 'column_name' => $col_name]); | |
| $pos++; | |
| } | |
| } | |
| public function get_detailed_index_info_for_show(string $pg_schema_unused, string $pg_table_name, string $mysql_index_name): array { | |
| // This method should query the *emulated* MySQL schema table 'statistics'. | |
| // $pg_schema_unused is the actual PG schema, but our emulated info uses $this->emulated_db_name as 'TABLE_SCHEMA'. | |
| // $mysql_index_name is the name of the index as per MySQL conventions (e.g., 'PRIMARY', 'idx_mycolumn'). | |
| $stats_table = $this->get_emulated_info_table_name('statistics', false); // Assuming non-temporary table info | |
| // Prepare the SQL to fetch all parts of the specified index | |
| $sql = "SELECT | |
| table_name AS \"Table\", | |
| non_unique AS \"Non_unique\", | |
| index_name AS \"Key_name\", | |
| seq_in_index AS \"Seq_in_index\", | |
| column_name AS \"Column_name\", | |
| collation AS \"Collation\", -- 'A' for ASC, 'D' for DESC, NULL if not applicable | |
| cardinality AS \"Cardinality\", -- This is often an estimate in MySQL | |
| sub_part AS \"Sub_part\", -- For prefix indexes (e.g., col(10)) | |
| packed AS \"Packed\", -- MySQL specific (relevant for MyISAM, often NULL) | |
| nullable AS \"Null\", -- 'YES' if column in index can be NULL, '' if not (MySQL specific format) | |
| index_type AS \"Index_type\", -- BTREE, HASH, FULLTEXT, SPATIAL (MySQL types) | |
| comment AS \"Comment\", -- MySQL index comment, not column comment | |
| index_comment AS \"Index_comment\",-- MySQL index comment | |
| is_visible AS \"Visible\" -- 'YES' or 'NO' | |
| FROM $stats_table | |
| WHERE table_schema = ? AND table_name = ? AND index_name = ? | |
| ORDER BY seq_in_index ASC"; // Order by sequence in index is crucial | |
| $params = [$this->emulated_db_name, $pg_table_name, $mysql_index_name]; | |
| $stmt = $this->query($sql, $params); | |
| $results = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| // Format results to match MySQL SHOW INDEX output more closely if needed | |
| $formatted_results = []; | |
| foreach ($results as $row) { | |
| // MySQL's Non_unique is 0 for unique, 1 for non-unique. | |
| // MySQL's Null is '' if NOT NULL, 'YES' if nullable. | |
| // MySQL's Collation is 'A' (Ascending), 'D' (Descending), or NULL. | |
| // Cardinality can be a large number or NULL. | |
| // Sub_part is integer or NULL. | |
| $formatted_row = new stdClass(); | |
| $formatted_row->Table = $row['Table']; | |
| $formatted_row->Non_unique = (int)$row['Non_unique']; | |
| $formatted_row->Key_name = $row['Key_name']; | |
| $formatted_row->Seq_in_index = (int)$row['Seq_in_index']; | |
| $formatted_row->Column_name = $row['Column_name']; | |
| $formatted_row->Collation = $row['Collation']; // 'A', 'D', or null | |
| $formatted_row->Cardinality = ($row['Cardinality'] !== null) ? (int)$row['Cardinality'] : null; | |
| $formatted_row->Sub_part = ($row['Sub_part'] !== null) ? (int)$row['Sub_part'] : null; | |
| $formatted_row->Packed = $row['Packed']; // Typically NULL for InnoDB | |
| $formatted_row->Null = $row['Null']; // 'YES' or '' | |
| $formatted_row->Index_type = $row['Index_type']; | |
| $formatted_row->Comment = $row['Comment']; // Often empty unless specific index options were used | |
| $formatted_row->Index_comment = $row['Index_comment']; | |
| $formatted_row->Visible = $row['Visible']; // MySQL 8+ feature | |
| $formatted_row->Expression = $row['EXPRESSION'] ?? null; // MySQL 8+ functional index expression | |
| $formatted_results[] = $formatted_row; | |
| } | |
| return $formatted_results; | |
| } | |
| public function get_primary_key_columns(string $table_name, bool $is_temporary = false): array { | |
| // Query emulated 'statistics' table for INDEX_NAME = 'PRIMARY' | |
| $stats_table = $this->get_emulated_info_table_name('statistics', $is_temporary); | |
| $sql = "SELECT column_name | |
| FROM $stats_table | |
| WHERE table_schema = ? AND table_name = ? AND index_name = 'PRIMARY' | |
| ORDER BY seq_in_index ASC"; | |
| $params = [$this->emulated_db_name, $table_name]; | |
| try { | |
| $stmt = $this->query($sql, $params); | |
| return $stmt->fetchAll(PDO::FETCH_COLUMN); | |
| } catch (PDOException $e) { | |
| // If the statistics table itself doesn't exist (e.g., schema not initialized) | |
| // or other SQL error. | |
| // For robustness, log error and return empty array or re-throw. | |
| // error_log("Error fetching primary key columns: " . $e->getMessage()); | |
| return []; | |
| } | |
| } | |
| public function get_table_columns(string $table_name, bool $is_temporary = false): array { | |
| $cols_table = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $sql = "SELECT column_name | |
| FROM $cols_table | |
| WHERE table_schema = ? AND table_name = ? | |
| ORDER BY ordinal_position ASC"; | |
| $params = [$this->emulated_db_name, $table_name]; | |
| try { | |
| $stmt = $this->query($sql, $params); | |
| return $stmt->fetchAll(PDO::FETCH_COLUMN); | |
| } catch (PDOException $e) { | |
| // error_log("Error fetching table columns: " . $e->getMessage()); | |
| return []; | |
| } | |
| } | |
| public function get_column_key_and_extra_for_show(string $pg_schema_unused, string $pg_table_name, string $mysql_column_name): array { | |
| // This method queries the *emulated* MySQL 'columns' table, which should store | |
| // the COLUMN_KEY and EXTRA fields as they would appear in MySQL's INFORMATION_SCHEMA.COLUMNS. | |
| // $pg_schema_unused is the actual PG schema, but we query based on emulated $this->emulated_db_name. | |
| // Determine if the request is for a temporary table's column. | |
| // This is tricky without more context. Assume non-temporary unless a flag is passed or determined. | |
| // For this method, we'll assume it's querying info about a non-temporary table. | |
| // If info about temporary tables is stored separately, that logic would be needed here. | |
| $is_temporary = $this->temporary_table_exists_in_emulated_schema($pg_table_name); // Check if it's a known temp table. | |
| $cols_emulated_table = $this->get_emulated_info_table_name('columns', $is_temporary); | |
| $sql = "SELECT column_key, extra | |
| FROM $cols_emulated_table | |
| WHERE table_schema = ? AND table_name = ? AND column_name = ?"; | |
| $params = [$this->emulated_db_name, $pg_table_name, $mysql_column_name]; | |
| $key_info = ''; | |
| $extra_info = ''; | |
| try { | |
| $stmt = $this->query($sql, $params); | |
| $result = $stmt->fetch(PDO::FETCH_ASSOC); | |
| if ($result) { | |
| $key_info = $result['column_key'] ?? ''; | |
| $extra_info = $result['extra'] ?? ''; | |
| } | |
| } catch (PDOException $e) { | |
| // Log error or handle if the emulated schema table doesn't exist or query fails. | |
| // error_log("Error in get_column_key_and_extra_for_show: " . $e->getMessage()); | |
| // Fallback to empty if data cannot be retrieved. | |
| } | |
| // The `COLUMN_KEY` in MySQL's INFORMATION_SCHEMA.COLUMNS can be: | |
| // - 'PRI' if the column is a PRIMARY KEY or part of one. | |
| // - 'UNI' if the column is part of a UNIQUE index (and not part of PK). | |
| // Note: If a column is part of multiple UNIQUE indexes, 'UNI' is shown. | |
| // If it's also PRI, PRI takes precedence. | |
| // - 'MUL' if the column is part of a non-UNIQUE index (and not PRI or UNI). | |
| // It means the column can have multiple occurrences of a given value within the index. | |
| // This is shown if the column is the first column of a non-unique index. | |
| // - '' (empty string) if the column has no index, or is not the first column of any index | |
| // that would qualify it for PRI, UNI, or MUL. | |
| // The `EXTRA` field can contain: | |
| // - 'auto_increment' for AUTO_INCREMENT columns. | |
| // - 'on update CURRENT_TIMESTAMP' for TIMESTAMP or DATETIME columns with this property. | |
| // - 'DEFAULT_GENERATED' if column has an expression default (MySQL 8+). | |
| // - 'VIRTUAL GENERATED', 'STORED GENERATED' for generated columns. | |
| // - Multiple values can be space-separated, e.g., 'auto_increment on update CURRENT_TIMESTAMP'. | |
| // The values should already be correctly stored by `extract_mysql_column_data_from_ast` | |
| // and related DDL recording methods. This method just retrieves them. | |
| return [ | |
| 'key' => $key_info, | |
| 'extra' => $extra_info, | |
| ]; | |
| } | |
| } | |
| require_once __DIR__ . '/../../tests/bootstrap.php'; | |
| $driver = new WP_PostgreSQL_Driver([ | |
| 'dsn' => 'pgsql:host=127.0.0.1;port=5432;user=postgres;', | |
| 'password' => 'secret', | |
| 'mysql_database_name' => 'wp_db' | |
| ]); | |
| $result = $driver->query('CREATE DATABASE wp_db'); | |
| $result = $driver->query('SELECT 1'); | |
| var_dump($result); | |
| try { | |
| $result = $driver->query("CREATE TABLE IF NOT EXISTS wp_users ( | |
| ID bigint(20) unsigned NOT NULL auto_increment, | |
| user_login varchar(60) NOT NULL default '', | |
| user_pass varchar(64) NOT NULL default '', | |
| user_nicename varchar(50) NOT NULL default '', | |
| user_email varchar(100) NOT NULL default '', | |
| user_url varchar(100) NOT NULL default '', | |
| user_registered datetime NOT NULL default '1970-01-01 00:00:00', | |
| user_activation_key varchar(60) NOT NULL default '', | |
| user_status int(11) NOT NULL default '0', | |
| display_name varchar(250) NOT NULL default '', | |
| spam tinyint(2) NOT NULL default '0', | |
| deleted tinyint(2) NOT NULL default '0', | |
| PRIMARY KEY (ID), | |
| KEY user_login_key (user_login), | |
| KEY user_nicename (user_nicename) | |
| ) DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci; | |
| "); | |
| var_dump($result); | |
| } catch (\Exception $e) { | |
| // Fatal error: Uncaught PDOException: SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "_wp_pgsql_mysql_info_tables_pkey" | |
| } | |
| $result = $driver->query('SHOW TABLES'); | |
| print_r($result); | |
| $result = $driver->query('INSERT INTO wp_users (user_login, user_pass, user_nicename, user_email, user_url, user_activation_key, user_status, display_name, spam, deleted) VALUES ("test", "test", "test", "test", "test", "test", "test", "test", "test", "test")'); | |
| print_r($result); | |
| $result = $driver->query('SELECT * FROM wp_users'); | |
| print_r($result); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment