PluginProbe
SQLite Database Integration / 2.2.11
SQLite Database Integration v2.2.11
3.0.2 3.0.1 trunk 2.1.13 2.1.14 2.1.15 2.1.16 2.2.0 2.2.1 2.2.10 2.2.11 2.2.12 2.2.13 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.2.2 2.2.20 2.2.21 2.2.22 2.2.23 2.2.3 All 32 releases
sqlite-database-integration / wp-includes / sqlite-ast / class-wp-sqlite-driver.php

class-wp-sqlite-driver.php in SQLite Database Integration 2.2.11, at wp-includes/sqlite-ast/class-wp-sqlite-driver.php

5,472 lines 189.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /*
4 * The SQLite driver uses PDO. Enable PDO function calls:
5 * phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
6 */
7
8 /**
9 * SQLite driver for MySQL.
10 *
11 * This class emulates a MySQL database server on top of an SQLite database.
12 * It translates queries written in MySQL SQL dialect to an SQLite SQL dialect,
13 * maintains necessary metadata, and executes the translated queries in SQLite.
14 *
15 * The driver requires PDO with the SQLite driver, and the PCRE engine.
16 */
17 class WP_SQLite_Driver {
18 /**
19 * The path to the MySQL SQL grammar file.
20 */
21 const MYSQL_GRAMMAR_PATH = __DIR__ . '/../../wp-includes/mysql/mysql-grammar.php';
22
23 /**
24 * The minimum required version of SQLite.
25 *
26 * Currently, we require SQLite >= 3.37.0 due to the STRICT table support:
27 * https://www.sqlite.org/stricttables.html
28 */
29 const MINIMUM_SQLITE_VERSION = '3.37.0';
30
31 /**
32 * An identifier prefix for internal database objects.
33 *
34 * @TODO: Do not allow accessing objects with this prefix.
35 */
36 const RESERVED_PREFIX = '_wp_sqlite_';
37
38 /**
39 * The name of a global variables table.
40 *
41 * This special table is used to emulate MySQL global variables and to store
42 * some internal configuration values.
43 */
44 const GLOBAL_VARIABLES_TABLE_NAME = self::RESERVED_PREFIX . 'global_variables';
45
46 /**
47 * The name of the SQLite driver version variable.
48 *
49 * This internal variable is used to store the latest version of the SQLite
50 * driver that was used to initialize and configure the SQLite database.
51 */
52 const DRIVER_VERSION_VARIABLE_NAME = self::RESERVED_PREFIX . 'driver_version';
53
54 /**
55 * A map of MySQL tokens to SQLite data types.
56 *
57 * This is used to translate a MySQL data type to an SQLite data type.
58 */
59 const DATA_TYPE_MAP = array(
60 // Numeric data types:
61 WP_MySQL_Lexer::BIT_SYMBOL => 'INTEGER',
62 WP_MySQL_Lexer::BOOL_SYMBOL => 'INTEGER',
63 WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'INTEGER',
64 WP_MySQL_Lexer::TINYINT_SYMBOL => 'INTEGER',
65 WP_MySQL_Lexer::SMALLINT_SYMBOL => 'INTEGER',
66 WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'INTEGER',
67 WP_MySQL_Lexer::INT_SYMBOL => 'INTEGER',
68 WP_MySQL_Lexer::INTEGER_SYMBOL => 'INTEGER',
69 WP_MySQL_Lexer::BIGINT_SYMBOL => 'INTEGER',
70 WP_MySQL_Lexer::FLOAT_SYMBOL => 'REAL',
71 WP_MySQL_Lexer::DOUBLE_SYMBOL => 'REAL',
72 WP_MySQL_Lexer::REAL_SYMBOL => 'REAL',
73 WP_MySQL_Lexer::DECIMAL_SYMBOL => 'REAL',
74 WP_MySQL_Lexer::DEC_SYMBOL => 'REAL',
75 WP_MySQL_Lexer::FIXED_SYMBOL => 'REAL',
76 WP_MySQL_Lexer::NUMERIC_SYMBOL => 'REAL',
77
78 // String data types:
79 WP_MySQL_Lexer::CHAR_SYMBOL => 'TEXT',
80 WP_MySQL_Lexer::VARCHAR_SYMBOL => 'TEXT',
81 WP_MySQL_Lexer::NCHAR_SYMBOL => 'TEXT',
82 WP_MySQL_Lexer::NVARCHAR_SYMBOL => 'TEXT',
83 WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'TEXT',
84 WP_MySQL_Lexer::TEXT_SYMBOL => 'TEXT',
85 WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'TEXT',
86 WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'TEXT',
87 WP_MySQL_Lexer::ENUM_SYMBOL => 'TEXT',
88
89 // Date and time data types:
90 WP_MySQL_Lexer::DATE_SYMBOL => 'TEXT',
91 WP_MySQL_Lexer::TIME_SYMBOL => 'TEXT',
92 WP_MySQL_Lexer::DATETIME_SYMBOL => 'TEXT',
93 WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'TEXT',
94 WP_MySQL_Lexer::YEAR_SYMBOL => 'TEXT',
95
96 // Binary data types:
97 WP_MySQL_Lexer::BINARY_SYMBOL => 'BLOB',
98 WP_MySQL_Lexer::VARBINARY_SYMBOL => 'BLOB',
99 WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB',
100 WP_MySQL_Lexer::BLOB_SYMBOL => 'BLOB',
101 WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB',
102 WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB',
103
104 // Spatial data types:
105 WP_MySQL_Lexer::GEOMETRY_SYMBOL => 'TEXT',
106 WP_MySQL_Lexer::POINT_SYMBOL => 'TEXT',
107 WP_MySQL_Lexer::LINESTRING_SYMBOL => 'TEXT',
108 WP_MySQL_Lexer::POLYGON_SYMBOL => 'TEXT',
109 WP_MySQL_Lexer::MULTIPOINT_SYMBOL => 'TEXT',
110 WP_MySQL_Lexer::MULTILINESTRING_SYMBOL => 'TEXT',
111 WP_MySQL_Lexer::MULTIPOLYGON_SYMBOL => 'TEXT',
112 WP_MySQL_Lexer::GEOMCOLLECTION_SYMBOL => 'TEXT',
113 WP_MySQL_Lexer::GEOMETRYCOLLECTION_SYMBOL => 'TEXT',
114
115 // SERIAL, SET, and JSON types are handled in the translation process.
116 );
117
118 /**
119 * A map of normalized MySQL data types to SQLite data types.
120 *
121 * This is used to generate SQLite CREATE TABLE statements from the MySQL
122 * INFORMATION_SCHEMA tables. They keys are MySQL data types normalized
123 * as they appear in the INFORMATION_SCHEMA. Values are SQLite data types.
124 */
125 const DATA_TYPE_STRING_MAP = array(
126 // Numeric data types:
127 'bit' => 'INTEGER',
128 'bool' => 'INTEGER',
129 'boolean' => 'INTEGER',
130 'tinyint' => 'INTEGER',
131 'smallint' => 'INTEGER',
132 'mediumint' => 'INTEGER',
133 'int' => 'INTEGER',
134 'integer' => 'INTEGER',
135 'bigint' => 'INTEGER',
136 'float' => 'REAL',
137 'double' => 'REAL',
138 'real' => 'REAL',
139 'decimal' => 'REAL',
140 'dec' => 'REAL',
141 'fixed' => 'REAL',
142 'numeric' => 'REAL',
143
144 // String data types:
145 'char' => 'TEXT',
146 'varchar' => 'TEXT',
147 'nchar' => 'TEXT',
148 'nvarchar' => 'TEXT',
149 'tinytext' => 'TEXT',
150 'text' => 'TEXT',
151 'mediumtext' => 'TEXT',
152 'longtext' => 'TEXT',
153 'enum' => 'TEXT',
154 'set' => 'TEXT',
155 'json' => 'TEXT',
156
157 // Date and time data types:
158 'date' => 'TEXT',
159 'time' => 'TEXT',
160 'datetime' => 'TEXT',
161 'timestamp' => 'TEXT',
162 'year' => 'TEXT',
163
164 // Binary data types:
165 'binary' => 'BLOB',
166 'varbinary' => 'BLOB',
167 'tinyblob' => 'BLOB',
168 'blob' => 'BLOB',
169 'mediumblob' => 'BLOB',
170 'longblob' => 'BLOB',
171
172 // Spatial data types:
173 'geometry' => 'TEXT',
174 'point' => 'TEXT',
175 'linestring' => 'TEXT',
176 'polygon' => 'TEXT',
177 'multipoint' => 'TEXT',
178 'multilinestring' => 'TEXT',
179 'multipolygon' => 'TEXT',
180 'geomcollection' => 'TEXT',
181 'geometrycollection' => 'TEXT',
182 );
183
184 /**
185 * A map of MySQL to SQLite date format translation.
186 *
187 * It maps MySQL DATE_FORMAT() formats to SQLite STRFTIME() formats.
188 *
189 * For MySQL formats, see:
190 * https://dev.mysql.com/doc/refman/5.7/en/date-and-time-functions.html#function_date-format
191 *
192 * For SQLite formats, see:
193 * https://www.sqlite.org/lang_datefunc.html
194 * https://strftime.org/
195 */
196 const MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAP = array(
197 '%a' => '%D',
198 '%b' => '%M',
199 '%c' => '%n',
200 '%D' => '%jS',
201 '%d' => '%d',
202 '%e' => '%j',
203 '%H' => '%H',
204 '%h' => '%h',
205 '%I' => '%h',
206 '%i' => '%M',
207 '%j' => '%z',
208 '%k' => '%G',
209 '%l' => '%g',
210 '%M' => '%F',
211 '%m' => '%m',
212 '%p' => '%A',
213 '%r' => '%h:%i:%s %A',
214 '%S' => '%s',
215 '%s' => '%s',
216 '%T' => '%H:%i:%s',
217 '%U' => '%W',
218 '%u' => '%W',
219 '%V' => '%W',
220 '%v' => '%W',
221 '%W' => '%l',
222 '%w' => '%w',
223 '%X' => '%Y',
224 '%x' => '%o',
225 '%Y' => '%Y',
226 '%y' => '%y',
227 );
228
229 /**
230 * A map of MySQL data types to implicit default values for non-strict mode.
231 *
232 * In MySQL, when STRICT_TRANS_TABLES and STRICT_ALL_TABLES modes are disabled,
233 * columns get IMPLICIT DEFAULT values that are used under some circumstances.
234 *
235 * See:
236 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
237 */
238 const DATA_TYPE_IMPLICIT_DEFAULT_MAP = array(
239 // Numeric data types:
240 'bit' => '0',
241 'bool' => '0',
242 'boolean' => '0',
243 'tinyint' => '0',
244 'smallint' => '0',
245 'mediumint' => '0',
246 'int' => '0',
247 'integer' => '0',
248 'bigint' => '0',
249 'float' => '0',
250 'double' => '0',
251 'real' => '0',
252 'decimal' => '0',
253 'dec' => '0',
254 'fixed' => '0',
255 'numeric' => '0',
256
257 // String data types:
258 'char' => '',
259 'varchar' => '',
260 'nchar' => '',
261 'nvarchar' => '',
262 'tinytext' => '',
263 'text' => '',
264 'mediumtext' => '',
265 'longtext' => '',
266 'enum' => '', // TODO: Implement (first enum value).
267 'set' => '',
268 'json' => 'null', // String value 'null' (valid JSON)
269
270 // Date and time data types:
271 'date' => '0000-00-00',
272 'time' => '00:00:00',
273 'datetime' => '0000-00-00 00:00:00',
274 'timestamp' => '0000-00-00 00:00:00',
275 'year' => '0000',
276
277 // Binary data types:
278 'binary' => '',
279 'varbinary' => '',
280 'tinyblob' => '',
281 'blob' => '',
282 'mediumblob' => '',
283 'longblob' => '',
284
285 // Spatial data types (no implicit defaults):
286 'geometry' => null,
287 'point' => null,
288 'linestring' => null,
289 'polygon' => null,
290 'multipoint' => null,
291 'multilinestring' => null,
292 'multipolygon' => null,
293 'geomcollection' => null,
294 'geometrycollection' => null,
295 );
296
297 /**
298 * A map of MySQL column data types to native types in MySQL column meta.
299 *
300 * This maps normalized MySQL column data types (as per information schema)
301 * to MySQL "PDOStatement::getColumnMeta()" data types in the "native_type"
302 * field, as well as the "len" and "precision" fields, where applicable:
303 *
304 * <mysql-column-type> => array( <native_type>, <mysqli_type>, <len>, <precision> )
305 *
306 * This is used to compute the column metadata from the information schema.
307 */
308 const COLUMN_INFO_MYSQL_TO_NATIVE_TYPES_MAP = array(
309 // Numeric data types:
310 'bit' => array( 'BIT', 16, 1, 0 ),
311 'tinyint' => array( 'TINY', 1, 4, 0 ),
312 'smallint' => array( 'SHORT', 2, 6, 0 ),
313 'mediumint' => array( 'INT24', 9, 9, 0 ),
314 'int' => array( 'LONG', 3, 11, 0 ),
315 'bigint' => array( 'LONGLONG', 8, 20, 0 ),
316 'float' => array( 'FLOAT', 4, 12, 31 ),
317 'double' => array( 'DOUBLE', 5, 22, 31 ),
318 'decimal' => array( 'NEWDECIMAL', 246, null, null ),
319
320 // String data types:
321 'char' => array( 'STRING', 254, null, 0 ),
322 'varchar' => array( 'VAR_STRING', 253, null, 0 ),
323 'tinytext' => array( 'BLOB', 252, null, 0 ),
324 'text' => array( 'BLOB', 252, null, 0 ),
325 'mediumtext' => array( 'BLOB', 252, null, 0 ),
326 'longtext' => array( 'BLOB', 252, null, 0 ),
327 'enum' => array( 'STRING', 254, null, 0 ),
328 'set' => array( 'STRING', 254, null, 0 ),
329 'json' => array( 'BLOB', 245, 4294967295, 0 ),
330
331 // Date and time data types:
332 'date' => array( 'DATE', 10, 10, 0 ),
333 'time' => array( 'TIME', 11, 10, 0 ),
334 'datetime' => array( 'DATETIME', 12, 19, 0 ),
335 'timestamp' => array( 'TIMESTAMP', 7, 19, 0 ),
336 'year' => array( 'YEAR', 13, 4, 0 ),
337
338 // Binary data types:
339 'binary' => array( 'BLOB', 254, null, 0 ),
340 'varbinary' => array( 'BLOB', 253, null, 0 ),
341 'tinyblob' => array( 'BLOB', 252, null, 0 ),
342 'blob' => array( 'BLOB', 252, null, 0 ),
343 'mediumblob' => array( 'BLOB', 252, null, 0 ),
344 'longblob' => array( 'BLOB', 252, null, 0 ),
345
346 // Spatial data types:
347 'geometry' => array( 'GEOMETRY', 255, 4294967295, 0 ),
348 'point' => array( 'GEOMETRY', 255, 4294967295, 0 ),
349 'linestring' => array( 'GEOMETRY', 255, 4294967295, 0 ),
350 'polygon' => array( 'GEOMETRY', 255, 4294967295, 0 ),
351 'multipoint' => array( 'GEOMETRY', 255, 4294967295, 0 ),
352 'multilinestring' => array( 'GEOMETRY', 255, 4294967295, 0 ),
353 'multipolygon' => array( 'GEOMETRY', 255, 4294967295, 0 ),
354 'geomcollection' => array( 'GEOMETRY', 255, 4294967295, 0 ),
355 );
356
357 /**
358 * A map of SQLite column definition data types and SQLite column meta data
359 * types to native types in MySQL column meta.
360 *
361 * This maps both SQLite column definition data types and SQLite column meta
362 * data types (as per "PDOStatement::getColumnMeta()") to MySQL column meta
363 * "native_type" field, as per "PDOStatement::getColumnMeta()", as well as
364 * the "len" and "precision" fields, where applicable:
365 *
366 * <sqlite-column-definition-type> => array( <native_type>, <mysqli_type>, <len>, <precision> )
367 * <sqlite-column-meta-type> => array( <native_type>, <mysqli_type>, <len>, <precision> )
368 *
369 * This is used to compute the MySQL column metadata for non-column fields
370 * that have no records in the information schema (i.e., expressions).
371 */
372 const COLUMN_INFO_SQLITE_TO_NATIVE_TYPES_MAP = array(
373 'NULL' => array( 'NULL', 6, 0, 0 ),
374 'INT' => array( 'LONGLONG', 8, 21, 0 ),
375 'INTEGER' => array( 'LONGLONG', 8, 21, 0 ),
376 'STRING' => array( 'VAR_STRING', 253, 65535, 31 ),
377 'TEXT' => array( 'BLOB', 252, null, 0 ),
378 'REAL' => array( 'DOUBLE', 5, 22, 31 ),
379 'DOUBLE' => array( 'DOUBLE', 5, 23, 31 ),
380 'BLOB' => array( 'BLOB', 252, null, 0 ),
381 );
382
383 /**
384 * The SQLite engine version.
385 *
386 * This is a mysqli-like property that is needed to avoid a PHP warning in
387 * the WordPress health info. The "WP_Debug_Data::get_wp_database()" method
388 * calls "$wpdb->dbh->client_info" - a mysqli-specific abstraction leak.
389 *
390 * @TODO: This should be fixed in WordPress core.
391 *
392 * See:
393 * https://github.com/WordPress/wordpress-develop/blob/bcdca3f9925f1d3eca7b78d231837c0caf0c8c24/src/wp-admin/includes/class-wp-debug-data.php#L1579
394 *
395 * @var string
396 */
397 public $client_info;
398
399 /**
400 * A MySQL query parser grammar.
401 *
402 * @var WP_Parser_Grammar
403 */
404 private static $mysql_grammar;
405
406 /**
407 * The main database name.
408 *
409 * The name of the main database that is used by the driver.
410 *
411 * @var string|null
412 */
413 private $main_db_name;
414
415 /**
416 * The name of the current database in use.
417 *
418 * This can be set with the USE statement. At the moment, we support only
419 * the main driver database and the INFORMATION_SCHEMA database.
420 *
421 * @var string
422 */
423 private $db_name;
424
425 /**
426 * An instance of the SQLite connection.
427 *
428 * @var WP_SQLite_Connection
429 */
430 private $connection;
431
432 /**
433 * A service for managing MySQL INFORMATION_SCHEMA tables in SQLite.
434 *
435 * @var WP_SQLite_Information_Schema_Builder
436 */
437 private $information_schema_builder;
438
439 /**
440 * Last executed MySQL query.
441 *
442 * @var string
443 */
444 private $last_mysql_query;
445
446 /**
447 * A list of SQLite queries executed for the last MySQL query.
448 *
449 * @var array{ sql: string, params: array }[]
450 */
451 private $last_sqlite_queries = array();
452
453 /**
454 * Results of the last emulated query.
455 *
456 * @var array|null
457 */
458 private $last_result;
459
460 /**
461 * Return value of the last emulated query.
462 *
463 * @var mixed
464 */
465 private $last_return_value;
466
467 /**
468 * SQLite column metadata for the last emulated query.
469 *
470 * @var array
471 */
472 private $last_column_meta = array();
473
474 /**
475 * Number of rows found by the last SQL_CALC_FOUND_ROW query.
476 *
477 * @var int
478 */
479 private $last_sql_calc_found_rows = null;
480
481 /**
482 * Whether the current MySQL query is read-only.
483 *
484 * @var bool
485 */
486 private $is_readonly;
487
488 /**
489 * Transaction nesting level of the executed SQLite queries.
490 *
491 * @var int
492 */
493 private $transaction_level = 0;
494
495 /**
496 * Whether a MySQL table lock is active.
497 *
498 * Set to "true" when a lock is acquired using the MySQL LOCK statement.
499 * Set to "false" when locks are released using the MySQL UNLOCK statement.
500 *
501 * @var bool
502 */
503 private $table_lock_active = false;
504
505 /**
506 * The PDO fetch mode used for the emulated query.
507 *
508 * @var mixed
509 */
510 private $pdo_fetch_mode;
511
512 /**
513 * The currently active MySQL SQL modes.
514 *
515 * The default value reflects the default SQL modes for MySQL 8.0.
516 *
517 * TODO: This may be represented using a temporary table in the future,
518 * together with GLOBAL SQL mode (a non-temporary table).
519 *
520 * @var string[]
521 */
522 private $active_sql_modes = array(
523 'ERROR_FOR_DIVISION_BY_ZERO',
524 'NO_ENGINE_SUBSTITUTION',
525 'NO_ZERO_DATE',
526 'NO_ZERO_IN_DATE',
527 'ONLY_FULL_GROUP_BY',
528 'STRICT_TRANS_TABLES',
529 );
530
531 /**
532 * A name-to-value map of MySQL system variables for the current session.
533 *
534 * MySQL session system variables are session-specific, so we can store them
535 * in-memory. In SQL queries, they are combined with global system variables.
536 *
537 * See:
538 * https://dev.mysql.com/doc/refman/8.4/en/using-system-variables.html
539 *
540 * @var array<string, string>
541 */
542 private $session_system_variables = array();
543
544 /**
545 * A name-to-value map of MySQL user variables.
546 *
547 * MySQL user variables are session-specific, so we can store them in-memory.
548 *
549 * See:
550 * https://dev.mysql.com/doc/refman/8.4/en/user-variables.html
551 *
552 * @var array<string, string>
553 */
554 private $user_variables = array();
555
556 /**
557 * Constructor.
558 *
559 * Set up an SQLite connection and the MySQL-on-SQLite driver.
560 *
561 * @param WP_SQLite_Connection $connection A SQLite database connection.
562 * @param string $database The database name.
563 *
564 * @throws WP_SQLite_Driver_Exception When the driver initialization fails.
565 */
566 public function __construct( WP_SQLite_Connection $connection, string $database ) {
567 $this->connection = $connection;
568 $this->main_db_name = $database;
569 $this->db_name = $database;
570
571 // Check the database name.
572 if ( '' === $this->db_name ) {
573 throw $this->new_driver_exception( 'The database name cannot be empty.' );
574 }
575
576 // Check the SQLite version.
577 $sqlite_version = $this->get_sqlite_version();
578 if ( version_compare( $sqlite_version, self::MINIMUM_SQLITE_VERSION, '<' ) ) {
579 if ( defined( 'WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS' ) && WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS ) {
580 // When "WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS" is enabled,
581 // allow using legacy SQLite versions, but not older than 3.27.0.
582 if ( version_compare( $sqlite_version, '3.27.0', '<' ) ) {
583 throw $this->new_driver_exception(
584 sprintf(
585 'The SQLite version %s is not supported. Minimum required version is %s.'
586 . ' With "WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS" enabled, you must use 3.27.0 or newer.',
587 $sqlite_version,
588 self::MINIMUM_SQLITE_VERSION
589 )
590 );
591 }
592
593 /*
594 * SQLite versions prior to 3.37.0 do not support STRICT tables.
595 *
596 * However, a database created with SQLite >= 3.37.0 can be used
597 * with SQLite versions < 3.37.0 when "PRAGMA writable_schema" is
598 * set to "ON", which also enables error-tolerant schema parsing.
599 *
600 * This is an unsafe opt-in feature for special back compatibility
601 * use cases, as it can corrupt the database by allowing incorrect
602 * types into STRICT tables. Additionally, depending on the legacy
603 * SQLite version used, there is no guarantee that all features of
604 * the SQLite driver will work as expected. Use this with caution.
605 *
606 * See: https://www.sqlite.org/stricttables.html#accessing_strict_tables_in_earlier_versions_of_sqlite
607 *
608 * TODO: Remove this flag when we drop support for PHP 8.0.
609 * From PHP 8.1, SQLite 3.46.1 is used by default.
610 */
611 $this->execute_sqlite_query( 'PRAGMA writable_schema=ON' );
612 } else {
613 throw $this->new_driver_exception(
614 sprintf(
615 'The SQLite version %s is not supported. Minimum required version is %s.',
616 $sqlite_version,
617 self::MINIMUM_SQLITE_VERSION
618 )
619 );
620 }
621 }
622
623 // Load SQLite version to a property used by WordPress health info.
624 $this->client_info = $sqlite_version;
625
626 // Enable foreign keys. By default, they are off.
627 $this->connection->query( 'PRAGMA foreign_keys = ON' );
628
629 // Register SQLite functions.
630 WP_SQLite_PDO_User_Defined_Functions::register_for( $this->connection->get_pdo() );
631
632 // Load MySQL grammar.
633 if ( null === self::$mysql_grammar ) {
634 self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH );
635 }
636
637 // Initialize information schema builder.
638 $this->information_schema_builder = new WP_SQLite_Information_Schema_Builder(
639 $this->main_db_name,
640 self::RESERVED_PREFIX,
641 $this->connection
642 );
643
644 // Ensure that the database is configured.
645 $migrator = new WP_SQLite_Configurator( $this->db_name, $this, $this->information_schema_builder );
646 $migrator->ensure_database_configured();
647
648 $this->connection->set_query_logger(
649 function ( string $sql, array $params ) {
650 $this->last_sqlite_queries[] = array(
651 'sql' => $sql,
652 'params' => $params,
653 );
654 }
655 );
656 }
657
658 /**
659 * Get the SQLite connection instance.
660 *
661 * @return WP_SQLite_Connection
662 */
663 public function get_connection(): WP_SQLite_Connection {
664 return $this->connection;
665 }
666
667 /**
668 * Get the version of the SQLite engine.
669 *
670 * @return string SQLite engine version as a string.
671 */
672 public function get_sqlite_version(): string {
673 return $this->connection->query( 'SELECT SQLITE_VERSION()' )->fetchColumn();
674 }
675
676 /**
677 * Get the SQLite driver version saved in the database.
678 *
679 * The saved driver version corresponds to the latest version of the SQLite
680 * driver that was used to initialize and configure the SQLite database.
681 *
682 * @return string SQLite driver version as a string.
683 * @throws PDOException When the query execution fails.
684 */
685 public function get_saved_driver_version(): string {
686 $default_version = '0.0.0';
687 try {
688 $stmt = $this->execute_sqlite_query(
689 sprintf(
690 'SELECT value FROM %s WHERE name = ?',
691 $this->quote_sqlite_identifier( self::GLOBAL_VARIABLES_TABLE_NAME )
692 ),
693 array( self::DRIVER_VERSION_VARIABLE_NAME )
694 );
695 return $stmt->fetchColumn() ?? $default_version;
696 } catch ( PDOException $e ) {
697 if ( str_contains( $e->getMessage(), 'no such table' ) ) {
698 return $default_version;
699 }
700 throw $e;
701 }
702 }
703
704 /**
705 * Get the database name saved in the database.
706 *
707 * The saved database name represents the database name that was used when
708 * the database was initialized and configured.
709 *
710 * @return string The database name.
711 * @throws PDOException When the query execution fails.
712 */
713 public function get_saved_database_name(): string {
714 try {
715 $schemata_table = $this->information_schema_builder->get_table_name( false, 'schemata' );
716 return $this->execute_sqlite_query(
717 sprintf(
718 'SELECT SCHEMA_NAME FROM %s WHERE SCHEMA_NAME != "information_schema" LIMIT 1',
719 $this->quote_sqlite_identifier( $schemata_table )
720 )
721 )->fetchColumn() ?? '';
722 } catch ( PDOException $e ) {
723 if ( str_contains( $e->getMessage(), 'no such table' ) ) {
724 return '';
725 }
726 throw $e;
727 }
728 }
729
730 /**
731 * Check if a specific SQL mode is active.
732 *
733 * @param string $mode The SQL mode to check.
734 * @return bool True if the SQL mode is active, false otherwise.
735 */
736 public function is_sql_mode_active( string $mode ): bool {
737 return in_array( strtoupper( $mode ), $this->active_sql_modes, true );
738 }
739
740 /**
741 * Get the last executed MySQL query.
742 *
743 * @return string|null
744 */
745 public function get_last_mysql_query(): ?string {
746 return $this->last_mysql_query;
747 }
748
749 /**
750 * Get SQLite queries executed for the last MySQL query.
751 *
752 * @return array{ sql: string, params: array }[]
753 */
754 public function get_last_sqlite_queries(): array {
755 return $this->last_sqlite_queries;
756 }
757
758 /**
759 * Get the auto-increment value generated for the last query.
760 *
761 * @return int|string
762 */
763 public function get_insert_id() {
764 $last_insert_id = $this->connection->get_last_insert_id();
765 if ( is_numeric( $last_insert_id ) ) {
766 $last_insert_id = (int) $last_insert_id;
767 }
768 return $last_insert_id;
769 }
770
771 /**
772 * Translate and execute a MySQL query in SQLite.
773 *
774 * A single MySQL query can be translated into zero or more SQLite queries.
775 *
776 * @param string $query Full SQL statement string.
777 * @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ.
778 * @param array ...$fetch_mode_args Additional fetch mode arguments.
779 *
780 * @return mixed Return value, depending on the query type.
781 *
782 * @throws WP_SQLite_Driver_Exception When the query execution fails.
783 *
784 * TODO:
785 * The API of this function is not final.
786 * We should also add support for parametrized queries.
787 * See: https://github.com/Automattic/sqlite-database-integration/issues/7
788 */
789 public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) {
790 $this->flush();
791 $this->pdo_fetch_mode = $fetch_mode;
792 $this->last_mysql_query = $query;
793
794 try {
795 // Parse the MySQL query.
796 $parser = $this->create_parser( $query );
797 $parser->next_query();
798 $ast = $parser->get_query_ast();
799 if ( null === $ast ) {
800 throw $this->new_driver_exception( 'Failed to parse the MySQL query.' );
801 }
802
803 if ( $parser->next_query() ) {
804 throw $this->new_driver_exception( 'Multi-query is not supported.' );
805 }
806
807 /*
808 * Determine if we need to wrap the translated queries in a transaction.
809 *
810 * [GRAMMAR]
811 * query:
812 * EOF
813 * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF)
814 */
815 $child_node = $ast->get_first_child_node();
816 if (
817 null === $child_node
818 || 'beginWork' === $child_node->rule_name
819 || $child_node->has_child_node( 'transactionOrLockingStatement' )
820 ) {
821 $wrap_in_transaction = false;
822 } else {
823 $wrap_in_transaction = true;
824 }
825
826 if ( $wrap_in_transaction ) {
827 $this->begin_transaction();
828 }
829
830 $this->execute_mysql_query( $ast );
831
832 if ( $wrap_in_transaction ) {
833 $this->commit();
834 }
835 return $this->last_return_value;
836 } catch ( Throwable $e ) {
837 try {
838 $this->rollback();
839 } catch ( Throwable $rollback_exception ) {
840 // Ignore rollback errors.
841 }
842 if ( $e instanceof WP_SQLite_Driver_Exception ) {
843 throw $e;
844 } elseif ( $e instanceof WP_SQLite_Information_Schema_Exception ) {
845 throw $this->convert_information_schema_exception( $e );
846 }
847 throw $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e );
848 }
849 }
850
851 /**
852 * Tokenize a MySQL query and initialize a parser.
853 *
854 * @param string $query The MySQL query to parse.
855 * @return WP_MySQL_Parser A parser initialized for the MySQL query.
856 */
857 public function create_parser( string $query ): WP_MySQL_Parser {
858 $lexer = new WP_MySQL_Lexer(
859 $query,
860 80038,
861 $this->active_sql_modes
862 );
863 $tokens = $lexer->remaining_tokens();
864 return new WP_MySQL_Parser( self::$mysql_grammar, $tokens );
865 }
866
867 /**
868 * Get results of the last query.
869 *
870 * @return mixed
871 */
872 public function get_query_results() {
873 return $this->last_result;
874 }
875
876 /**
877 * Get return value of the last query() function call.
878 *
879 * @return mixed
880 */
881 public function get_last_return_value() {
882 return $this->last_return_value;
883 }
884
885 /**
886 * Get the number of columns returned by the last emulated query.
887 *
888 * @return int
889 */
890 public function get_last_column_count(): int {
891 return count( $this->last_column_meta );
892 }
893
894 /**
895 * Get column metadata for results of the last emulated query.
896 *
897 * @return array
898 */
899 public function get_last_column_meta(): array {
900 // Build the column metadata as per "PDOStatement::getColumnMeta()".
901 $column_meta = array();
902 foreach ( $this->last_column_meta as $meta ) {
903 $table = $meta['table'] ?? null;
904 $name = $meta['name'];
905 $type = strtoupper( $meta['sqlite:decl_type'] ?? $meta['native_type'] ?? '' );
906
907 // When table is known, we can get data from the information schema.
908 $column_info = null;
909 if ( null !== $table ) {
910 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table );
911 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
912 $column_info = $this->execute_sqlite_query(
913 sprintf(
914 '
915 SELECT
916 IS_NULLABLE,
917 DATA_TYPE,
918 COLUMN_TYPE,
919 COLUMN_KEY,
920 CHARACTER_MAXIMUM_LENGTH,
921 NUMERIC_PRECISION,
922 NUMERIC_SCALE
923 FROM %s
924 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
925 ',
926 $this->quote_sqlite_identifier( $columns_table )
927 ),
928 array( $this->db_name, $table, $name )
929 )->fetch( PDO::FETCH_ASSOC );
930
931 if ( false === $column_info ) {
932 $column_info = null;
933 }
934 }
935
936 // If we have information schema data, we can use it.
937 if ( null !== $column_info ) {
938 $type_info = self::COLUMN_INFO_MYSQL_TO_NATIVE_TYPES_MAP[ $column_info['DATA_TYPE'] ] ?? null;
939 if ( null === $type_info ) {
940 $type_info = self::COLUMN_INFO_SQLITE_TO_NATIVE_TYPES_MAP[ $type ] ?? null;
941 }
942 $native_type = $type_info[0];
943 $mysqli_type = $type_info[1];
944 $len = $type_info[2];
945 $precision = $type_info[3];
946
947 if ( 'tinyint(1)' === $column_info['COLUMN_TYPE'] ) {
948 $len = 1;
949 }
950
951 if ( 'decimal' === $column_info['DATA_TYPE'] ) {
952 $len = (int) $column_info['NUMERIC_PRECISION'] + (int) $column_info['NUMERIC_SCALE'];
953 $precision = (int) $column_info['NUMERIC_SCALE'];
954 }
955
956 if (
957 str_contains( $column_info['COLUMN_TYPE'], 'unsigned' )
958 && ! str_contains( $column_info['COLUMN_TYPE'], 'bigint' )
959 ) {
960 $len -= 1;
961 }
962
963 // If set, lenght can be taken from the information schema.
964 if ( isset( $column_info['CHARACTER_MAXIMUM_LENGTH'] ) ) {
965 $len = (int) $column_info['CHARACTER_MAXIMUM_LENGTH'];
966 }
967
968 // For string types, the length is multiplied by the maximum number
969 // of bytes per character for the used connection encoding. In our
970 // case, it's always "utf8mb4" and therefore 4 bytes per character.
971 if (
972 str_contains( $column_info['DATA_TYPE'], 'text' )
973 || str_contains( $column_info['DATA_TYPE'], 'char' )
974 || 'enum' === $column_info['DATA_TYPE']
975 || 'set' === $column_info['DATA_TYPE']
976 ) {
977 // Except for "longtext" - this might be a MySQL bug.
978 if ( 'longtext' !== $column_info['DATA_TYPE'] ) {
979 $len = 4 * $len;
980 }
981 }
982
983 // Flags.
984 $flags = array();
985 if ( 'NO' === $column_info['IS_NULLABLE'] ) {
986 $flags[] = 'not_null';
987 }
988 if ( 'PRI' === $column_info['COLUMN_KEY'] ) {
989 $flags[] = 'primary_key';
990 } elseif ( 'UNI' === $column_info['COLUMN_KEY'] ) {
991 $flags[] = 'unique_key';
992 } elseif ( 'MUL' === $column_info['COLUMN_KEY'] ) {
993 $flags[] = 'multiple_key';
994 }
995 } else {
996 $type_info = self::COLUMN_INFO_SQLITE_TO_NATIVE_TYPES_MAP[ $type ];
997 $native_type = $type_info[0];
998 $mysqli_type = $type_info[1];
999 $len = $type_info[2] ?? 0;
1000 $precision = $type_info[3];
1001
1002 // Flags.
1003 $flags = array();
1004 if ( 'NULL' !== $type ) {
1005 $flags[] = 'not_null';
1006 }
1007 }
1008
1009 if ( 'BLOB' === $native_type || 'GEOMETRY' === $native_type ) {
1010 $flags[] = 'blob';
1011 }
1012
1013 // PDO type.
1014 if ( 'INT' === $type || 'INTEGER' === $type ) {
1015 $pdo_type = PDO::PARAM_INT;
1016 } else {
1017 $pdo_type = PDO::PARAM_STR;
1018 }
1019
1020 // MySQLi charset number.
1021 $is_string = 'STRING' === $type || 'TEXT' === $type;
1022 $is_binary = 'BLOB' === $type || 'GEOMETRY' === $native_type;
1023 $is_datetime = str_contains( $native_type, 'DATE' ) || str_contains( $native_type, 'TIME' ) || 'YEAR' === $native_type;
1024 if ( $is_string && ! $is_binary && ! $is_datetime ) {
1025 $mysqli_charsetnr = 255; // utf8mb4_0900_ai_ci
1026 } else {
1027 $mysqli_charsetnr = 63; // binary
1028 }
1029
1030 $column_meta[] = array(
1031 'native_type' => $native_type,
1032 'pdo_type' => $pdo_type,
1033 'flags' => $flags,
1034 'table' => $meta['table'] ?? '',
1035 'name' => $meta['name'],
1036 'len' => $len,
1037 'precision' => $precision,
1038 'sqlite:decl_type' => $meta['sqlite:decl_type'] ?? '',
1039
1040 /*
1041 * The MySQLi PHP extension exposes more MySQL column metadata than PDO.
1042 * We'll add the data here for use cases such as "wpdb::get_col_info()".
1043 */
1044 'mysqli:orgname' => $meta['name'], // TODO: Use correct original name when alias is used.
1045 'mysqli:orgtable' => $meta['table'] ?? '', // TODO: Use correct original name when table alias is used.
1046 'mysqli:db' => $this->db_name, // TODO: Use correct DB for queries to information schema.
1047 'mysqli:charsetnr' => $mysqli_charsetnr,
1048 'mysqli:flags' => 0, // TODO: We can compute correct MySQL flags.
1049 'mysqli:type' => $mysqli_type,
1050 );
1051 }
1052 return $column_meta;
1053 }
1054
1055 /**
1056 * Execute a query in SQLite.
1057 *
1058 * @param string $sql The query to execute.
1059 * @param array $params The query parameters.
1060 * @throws PDOException When the query execution fails.
1061 * @return PDOStatement The PDO statement object.
1062 */
1063 public function execute_sqlite_query( string $sql, array $params = array() ): PDOStatement {
1064 return $this->connection->query( $sql, $params );
1065 }
1066
1067 /**
1068 * Begin a new transaction or nested transaction.
1069 */
1070 public function begin_transaction(): void {
1071 if ( 0 === $this->transaction_level ) {
1072 /*
1073 * When we're executing a statement that will write to the database,
1074 * we need to use "BEGIN IMMEDIATE" to open a write transaction.
1075 *
1076 * This is needed to avoid the "database is locked" error (SQLITE_BUSY)
1077 * when SQLite can't upgrade a read transaction to a write transaction,
1078 * because another connection is modifying the database.
1079 *
1080 * From the SQLite documentation:
1081 *
1082 * ## Read transactions versus write transactions
1083 *
1084 * If a write statement occurs while a read transaction is active,
1085 * then the read transaction is upgraded to a write transaction if
1086 * possible. If some other database connection has already modified
1087 * the database or is already in the process of modifying the database,
1088 * then upgrading to a write transaction is not possible and the write
1089 * statement will fail with SQLITE_BUSY.
1090 *
1091 * ## DEFERRED, IMMEDIATE, and EXCLUSIVE transactions
1092 *
1093 * Transactions can be DEFERRED, IMMEDIATE, or EXCLUSIVE. The default
1094 * transaction behavior is DEFERRED.
1095 *
1096 * DEFERRED means that the transaction does not actually start until
1097 * the database is first accessed.
1098 *
1099 * IMMEDIATE causes the database connection to start a new write
1100 * immediately, without waiting for a write statement. The BEGIN
1101 * IMMEDIATE might fail with SQLITE_BUSY if another write transaction
1102 * is already active on another database connection.
1103 *
1104 * See:
1105 * - https://www.sqlite.org/lang_transaction.html
1106 * - https://www.sqlite.org/rescode.html#busy
1107 *
1108 * For better performance, we could also consider opening the write
1109 * transaction later in the session - just before the first write.
1110 */
1111 $this->execute_sqlite_query( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' );
1112 } else {
1113 $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level );
1114 $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) );
1115 }
1116 ++$this->transaction_level;
1117 }
1118
1119 /**
1120 * Commit the current transaction or nested transaction.
1121 */
1122 public function commit(): void {
1123 if ( 0 === $this->transaction_level ) {
1124 return;
1125 }
1126
1127 --$this->transaction_level;
1128 if ( 0 === $this->transaction_level ) {
1129 $this->execute_sqlite_query( 'COMMIT' );
1130 } else {
1131 $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level );
1132 $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) );
1133 }
1134 }
1135
1136 /**
1137 * Rollback the current transaction or nested transaction.
1138 */
1139 public function rollback(): void {
1140 if ( 0 === $this->transaction_level ) {
1141 return;
1142 }
1143
1144 --$this->transaction_level;
1145 if ( 0 === $this->transaction_level ) {
1146 $this->execute_sqlite_query( 'ROLLBACK' );
1147 } else {
1148 $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level );
1149 $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) );
1150 }
1151 }
1152
1153 /**
1154 * Translate and execute a MySQL query in SQLite.
1155 *
1156 * @param WP_Parser_Node $node The "query" AST node with "simpleStatement" child.
1157 * @throws WP_SQLite_Driver_Exception When the query is not supported.
1158 */
1159 private function execute_mysql_query( WP_Parser_Node $node ): void {
1160 if ( 'query' !== $node->rule_name ) {
1161 throw $this->new_driver_exception(
1162 sprintf( 'Expected "query" node, got: "%s"', $node->rule_name )
1163 );
1164 }
1165
1166 /*
1167 * [GRAMMAR]
1168 * query:
1169 * EOF
1170 * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF)
1171 */
1172 $children = $node->get_child_nodes();
1173 if ( count( $children ) !== 1 ) {
1174 throw $this->new_driver_exception(
1175 sprintf( 'Expected 1 child node, got: %d', count( $children ) )
1176 );
1177 }
1178
1179 if ( 'beginWork' === $children[0]->rule_name ) {
1180 $this->begin_transaction();
1181 return;
1182 }
1183
1184 if ( 'simpleStatement' !== $children[0]->rule_name ) {
1185 throw $this->new_driver_exception(
1186 sprintf( 'Expected "simpleStatement" node, got: "%s"', $children[0]->rule_name )
1187 );
1188 }
1189
1190 // Process the "simpleStatement" AST node.
1191 $node = $children[0]->get_first_child_node();
1192 switch ( $node->rule_name ) {
1193 case 'transactionOrLockingStatement':
1194 $this->execute_transaction_or_locking_statement( $node );
1195 break;
1196 case 'selectStatement':
1197 $this->is_readonly = true;
1198 $this->execute_select_statement( $node );
1199 break;
1200 case 'insertStatement':
1201 case 'replaceStatement':
1202 $this->execute_insert_or_replace_statement( $node );
1203 break;
1204 case 'updateStatement':
1205 $this->execute_update_statement( $node );
1206 break;
1207 case 'deleteStatement':
1208 $this->execute_delete_statement( $node );
1209 break;
1210 case 'createStatement':
1211 $subtree = $node->get_first_child_node();
1212 switch ( $subtree->rule_name ) {
1213 case 'createDatabase':
1214 /*
1215 * TODO:
1216 * We could support this by creating a new SQLite database
1217 * file (e.g., $slugified_db_name.sqlite).
1218 *
1219 * Alternatively, it could be a no-op, in combination with
1220 * DROP DATABASE deleting the data file and recreating it.
1221 */
1222 case 'createTable':
1223 $this->execute_create_table_statement( $node );
1224 break;
1225 case 'createIndex':
1226 $this->execute_create_index_statement( $node );
1227 break;
1228 default:
1229 throw $this->new_not_supported_exception(
1230 sprintf(
1231 'statement type: "%s" > "%s"',
1232 $node->rule_name,
1233 $subtree->rule_name
1234 )
1235 );
1236 }
1237 break;
1238 case 'alterStatement':
1239 $subtree = $node->get_first_child_node();
1240 switch ( $subtree->rule_name ) {
1241 case 'alterTable':
1242 $this->execute_alter_table_statement( $node );
1243 break;
1244 default:
1245 throw $this->new_not_supported_exception(
1246 sprintf(
1247 'statement type: "%s" > "%s"',
1248 $node->rule_name,
1249 $subtree->rule_name
1250 )
1251 );
1252 }
1253 break;
1254 case 'dropStatement':
1255 $subtree = $node->get_first_child_node();
1256 switch ( $subtree->rule_name ) {
1257 case 'dropTable':
1258 $this->execute_drop_table_statement( $node );
1259 break;
1260 case 'dropIndex':
1261 $this->execute_drop_index_statement( $node );
1262 break;
1263 default:
1264 $query = $this->translate( $node );
1265 $this->execute_sqlite_query( $query );
1266 $this->set_result_from_affected_rows();
1267 }
1268 break;
1269 case 'truncateTableStatement':
1270 $this->execute_truncate_table_statement( $node );
1271 break;
1272 case 'setStatement':
1273 $this->execute_set_statement( $node );
1274 break;
1275 case 'showStatement':
1276 $this->is_readonly = true;
1277 $this->execute_show_statement( $node );
1278 break;
1279 case 'utilityStatement':
1280 $subtree = $node->get_first_child_node();
1281 switch ( $subtree->rule_name ) {
1282 case 'describeStatement':
1283 $this->is_readonly = true;
1284 $this->execute_describe_statement( $subtree );
1285 break;
1286 case 'useCommand':
1287 $this->execute_use_statement( $subtree );
1288 break;
1289 default:
1290 throw $this->new_not_supported_exception(
1291 sprintf(
1292 'statement type: "%s" > "%s"',
1293 $node->rule_name,
1294 $subtree->rule_name
1295 )
1296 );
1297 }
1298 break;
1299 case 'tableAdministrationStatement':
1300 $this->execute_administration_statement( $node );
1301 break;
1302 default:
1303 throw $this->new_not_supported_exception(
1304 sprintf( 'statement type: "%s"', $node->rule_name )
1305 );
1306 }
1307 }
1308
1309 /**
1310 * Execute a MySQL transaction or locking statement in SQLite.
1311 *
1312 * @param WP_Parser_Node $node The "transactionOrLockingStatement" AST node.
1313 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1314 */
1315 private function execute_transaction_or_locking_statement( WP_Parser_Node $node ): void {
1316 $subnode = $node->get_first_child_node();
1317 $token = $node->get_first_descendant_token();
1318
1319 switch ( $subnode->rule_name ) {
1320 case 'transactionStatement':
1321 // START TRANSACTION.
1322 if ( WP_MySQL_Lexer::START_SYMBOL === $token->id ) {
1323 $this->begin_transaction();
1324 return;
1325 }
1326
1327 // COMMIT.
1328 if ( WP_MySQL_Lexer::COMMIT_SYMBOL === $token->id ) {
1329 $this->commit();
1330 return;
1331 }
1332
1333 break;
1334 case 'savepointStatement':
1335 $savepoint_name = $this->translate( $subnode->get_first_child_node( 'identifier' ) );
1336
1337 // ROLLBACK/ROLLBACK TO SAVEPOINT <identifier>.
1338 if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $token->id ) {
1339 if ( null === $savepoint_name ) {
1340 $this->rollback();
1341 } else {
1342 $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) );
1343 }
1344 return;
1345 }
1346
1347 // SAVEPOINT.
1348 if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $token->id ) {
1349 $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) );
1350 return;
1351 }
1352
1353 // RELEASE SAVEPOINT.
1354 if ( WP_MySQL_Lexer::RELEASE_SYMBOL === $token->id ) {
1355 $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) );
1356 return;
1357 }
1358
1359 break;
1360 case 'lockStatement':
1361 // LOCK TABLE/LOCK TABLES.
1362 if (
1363 WP_MySQL_Lexer::LOCK_SYMBOL === $token->id
1364 && $subnode->has_child_node( 'lockItem' )
1365 ) {
1366 // Check if the table(s) exists.
1367 $lock_items = $subnode->get_child_nodes( 'lockItem' );
1368 foreach ( $lock_items as $lock_item ) {
1369 $table_name = $this->unquote_sqlite_identifier(
1370 $this->translate( $lock_item->get_first_child_node( 'tableRef' ) )
1371 );
1372 try {
1373 /*
1374 * Attempt to query the table directly rather than checking
1375 * SQLite schema or information schema tables, so that we
1376 * can handle persistent and temporary tables in one query.
1377 */
1378 $this->execute_sqlite_query(
1379 sprintf( 'SELECT 1 FROM %s LIMIT 0', $table_name )
1380 );
1381 } catch ( PDOException $e ) {
1382 throw $this->new_driver_exception(
1383 sprintf( "Table '%s.%s' doesn't exist", $this->db_name, $table_name ),
1384 '42S02'
1385 );
1386 }
1387 }
1388
1389 // Start a transaction when no top-level transaction is active.
1390 if ( 0 === $this->transaction_level ) {
1391 $this->begin_transaction();
1392 $this->table_lock_active = true;
1393 }
1394 return;
1395 }
1396
1397 // UNLOCK TABLES/UNLOCK TABLE.
1398 if (
1399 WP_MySQL_Lexer::UNLOCK_SYMBOL === $token->id
1400 && (
1401 $subnode->has_child_token( WP_MySQL_Lexer::TABLE_SYMBOL )
1402 || $subnode->has_child_token( WP_MySQL_Lexer::TABLES_SYMBOL )
1403 )
1404 ) {
1405 // Commit the transaction when created by the LOCK statement.
1406 if ( 1 === $this->transaction_level && $this->table_lock_active ) {
1407 $this->commit();
1408 $this->table_lock_active = false;
1409 }
1410 return;
1411 }
1412
1413 break;
1414 }
1415
1416 throw $this->new_not_supported_exception(
1417 sprintf(
1418 'statement type: "%s" > "%s"',
1419 $node->rule_name,
1420 $subnode->rule_name
1421 )
1422 );
1423 }
1424
1425 /**
1426 * Translate and execute a MySQL SELECT statement in SQLite.
1427 *
1428 * @param WP_Parser_Node $node The "selectStatement" AST node.
1429 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1430 */
1431 private function execute_select_statement( WP_Parser_Node $node ): void {
1432 /*
1433 * [GRAMMAR]
1434 * selectStatement:
1435 * queryExpression lockingClauseList?
1436 * | selectStatementWithInto
1437 */
1438
1439 // First, translate the query, before we modify last found rows count.
1440 $query = $this->translate( $node->get_first_child() );
1441
1442 $has_sql_calc_found_rows = null !== $node->get_first_descendant_token(
1443 WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL
1444 );
1445
1446 // Handle SQL_CALC_FOUND_ROWS.
1447 if ( true === $has_sql_calc_found_rows ) {
1448 // Recursively find a query expression with the first LIMIT or SELECT.
1449 $query_expr = $node->get_first_descendant_node( 'queryExpression' );
1450 while ( true ) {
1451 if ( $query_expr->has_child_node( 'limitClause' ) ) {
1452 break;
1453 }
1454
1455 $query_expr_parens = $query_expr->get_first_child_node( 'queryExpressionParens' );
1456 if ( null !== $query_expr_parens ) {
1457 $query_expr = $query_expr_parens->get_first_child_node( 'queryExpression' );
1458 continue;
1459 }
1460
1461 $query_expr_body = $query_expr->get_first_child_node( 'queryExpressionBody' );
1462 if ( count( $query_expr_body->get_children() ) > 1 ) {
1463 break;
1464 }
1465
1466 $query_term = $query_expr_body->get_first_child_node( 'queryTerm' );
1467 if (
1468 count( $query_term->get_children() ) === 1
1469 && $query_term->has_child_node( 'queryExpressionParens' )
1470 ) {
1471 $query_expr = $query_term->get_first_child_node( 'queryExpressionParens' )->get_first_child_node( 'queryExpression' );
1472 continue;
1473 }
1474
1475 break;
1476 }
1477
1478 // Exclude the limit clause from the expression.
1479 $count_expr = new WP_Parser_Node( $query_expr->rule_id, $query_expr->rule_name );
1480 foreach ( $query_expr->get_children() as $child ) {
1481 if ( ! ( $child instanceof WP_Parser_Node && 'limitClause' === $child->rule_name ) ) {
1482 $count_expr->append_child( $child );
1483 }
1484 }
1485
1486 // Get count of all the rows.
1487 $result = $this->execute_sqlite_query(
1488 'SELECT COUNT(*) AS cnt FROM (' . $this->translate( $count_expr ) . ')'
1489 );
1490
1491 $this->last_sql_calc_found_rows = $result->fetchColumn();
1492 } else {
1493 $this->last_sql_calc_found_rows = null;
1494 }
1495
1496 // Execute the query.
1497 $stmt = $this->execute_sqlite_query( $query );
1498
1499 // Store column meta info. This must be done before fetching data, which
1500 // seems to erase type information for expressions in the SELECT clause.
1501 $this->last_column_meta = array();
1502 for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
1503 /*
1504 * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3.
1505 * See also: https://github.com/php/php-src/pull/5654
1506 */
1507 if ( PHP_VERSION_ID < 70300 ) {
1508 try {
1509 $this->last_column_meta[] = $stmt->getColumnMeta( $i );
1510 } catch ( Throwable $e ) {
1511 $this->last_column_meta[] = array(
1512 'native_type' => 'null',
1513 'pdo_type' => PDO::PARAM_NULL,
1514 'flags' => array(),
1515 'table' => '',
1516 'name' => '',
1517 'len' => -1,
1518 'precision' => 0,
1519 );
1520 }
1521 continue;
1522 }
1523
1524 $this->last_column_meta[] = $stmt->getColumnMeta( $i );
1525 }
1526
1527 $this->set_results_from_fetched_data(
1528 $stmt->fetchAll( $this->pdo_fetch_mode )
1529 );
1530 }
1531
1532 /**
1533 * Translate and execute a MySQL INSERT or REPLACE statement in SQLite.
1534 *
1535 * @param WP_Parser_Node $node The "insertStatement" or "replaceStatement" AST node.
1536 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1537 */
1538 private function execute_insert_or_replace_statement( WP_Parser_Node $node ): void {
1539 // Check if strict mode is disabled.
1540 $is_non_strict_mode = (
1541 ! $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
1542 && ! $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
1543 );
1544
1545 $parts = array();
1546 foreach ( $node->get_children() as $child ) {
1547 if ( $child instanceof WP_MySQL_Token && WP_MySQL_Lexer::IGNORE_SYMBOL === $child->id ) {
1548 // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE".
1549 $parts[] = 'OR IGNORE';
1550 } elseif (
1551 $is_non_strict_mode
1552 && $child instanceof WP_Parser_Node
1553 && ( 'insertFromConstructor' === $child->rule_name || 'insertQueryExpression' === $child->rule_name )
1554 ) {
1555 $table_ref = $node->get_first_child_node( 'tableRef' );
1556 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
1557 $parts[] = $this->translate_insert_or_replace_body_in_non_strict_mode( $table_name, $child );
1558 } else {
1559 $parts[] = $this->translate( $child );
1560 }
1561 }
1562 $query = implode( ' ', $parts );
1563 $this->execute_sqlite_query( $query );
1564 $this->set_result_from_affected_rows();
1565 }
1566
1567 /**
1568 * Translate and execute a MySQL UPDATE statement in SQLite.
1569 *
1570 * @param WP_Parser_Node $node The "updateStatement" AST node.
1571 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1572 */
1573 private function execute_update_statement( WP_Parser_Node $node ): void {
1574 // @TODO: Add support for UPDATE with multiple tables and JOINs.
1575 // SQLite supports them in the FROM clause.
1576
1577 $has_order = $node->has_child_node( 'orderClause' );
1578 $has_limit = $node->has_child_node( 'simpleLimitClause' );
1579
1580 /*
1581 * SQLite doesn't support UPDATE with ORDER BY/LIMIT.
1582 * We need to use a subquery to emulate this behavior.
1583 *
1584 * For instance, the following query:
1585 * UPDATE t SET c = 1 WHERE c = 2 LIMIT 1;
1586 * Will be rewritten to:
1587 * UPDATE t SET c = 1 WHERE rowid IN ( SELECT rowid FROM t WHERE c = 2 LIMIT 1 );
1588 */
1589 $where_subquery = null;
1590 if ( $has_order || $has_limit ) {
1591 $where_subquery = 'SELECT rowid FROM ' . $this->translate_sequence(
1592 array(
1593 $node->get_first_child_node( 'tableReferenceList' ),
1594 $node->get_first_child_node( 'whereClause' ),
1595 $node->get_first_child_node( 'orderClause' ),
1596 $node->get_first_child_node( 'simpleLimitClause' ),
1597 )
1598 );
1599 }
1600
1601 // Check if strict mode is disabled.
1602 $is_non_strict_mode = (
1603 ! $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
1604 && ! $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
1605 );
1606
1607 /*
1608 * Translate the UPDATE statement parts.
1609 *
1610 * [GRAMMAR]
1611 * updateStatement:
1612 * withClause? UPDATE_SYMBOL LOW_PRIORITY_SYMBOL? IGNORE_SYMBOL? tableReferenceList
1613 * SET_SYMBOL updateList whereClause? orderClause? simpleLimitClause?
1614 */
1615
1616 // Collect all tables used in the UPDATE clause (e.g, UPDATE t1, t2 JOIN t3).
1617 $table_alias_map = $this->create_table_reference_map(
1618 $node->get_first_child_node( 'tableReferenceList' )
1619 );
1620
1621 // Determine whether the UPDATE statement modifies multiple tables.
1622 $update_list_node = $node->get_first_child_node( 'updateList' );
1623 $update_target = null;
1624 $updates_multiple_tables = false;
1625 if ( count( $table_alias_map ) > 1 ) {
1626 foreach ( $update_list_node->get_child_nodes( 'updateElement' ) as $update_element ) {
1627 $column_ref = $update_element->get_first_child_node( 'columnRef' );
1628 $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' );
1629 $table_or_alias = count( $column_ref_parts ) > 1
1630 ? $this->unquote_sqlite_identifier( $this->translate( $column_ref_parts[0] ) )
1631 : null;
1632
1633 // When the SET column reference is not qualified, we need to
1634 // verify whether the column is used in multiple tables.
1635 if ( null === $table_or_alias ) {
1636 $persistent_table_names = array();
1637 $temporary_table_names = array();
1638 foreach ( array_filter( array_column( $table_alias_map, 'table_name' ) ) as $table_name ) {
1639 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
1640 $quoted_table_name = $this->connection->quote( $table_name );
1641 if ( $is_temporary ) {
1642 $temporary_table_names[] = $quoted_table_name;
1643 } else {
1644 $persistent_table_names[] = $quoted_table_name;
1645 }
1646 }
1647
1648 $column_name = $this->unquote_sqlite_identifier(
1649 $this->translate( end( $column_ref_parts ) )
1650 );
1651
1652 $matched_temporary_tables = array();
1653 if ( count( $temporary_table_names ) > 0 ) {
1654 $matched_temporary_tables = $this->execute_sqlite_query(
1655 sprintf(
1656 'SELECT table_name FROM %s WHERE table_schema = ? AND table_name IN ( %s ) AND column_name = ?',
1657 $this->quote_sqlite_identifier(
1658 $this->information_schema_builder->get_table_name( true, 'columns' )
1659 ),
1660 implode( ', ', $temporary_table_names )
1661 ),
1662 array( $this->db_name, $column_name )
1663 )->fetchAll( PDO::FETCH_COLUMN );
1664 }
1665
1666 $matched_persistent_tables = array();
1667 if ( count( $persistent_table_names ) > 0 ) {
1668 $matched_persistent_tables = $this->execute_sqlite_query(
1669 sprintf(
1670 'SELECT table_name FROM %s WHERE table_schema = ? AND table_name IN ( %s ) AND column_name = ?',
1671 $this->quote_sqlite_identifier(
1672 $this->information_schema_builder->get_table_name( false, 'columns' )
1673 ),
1674 implode( ', ', $persistent_table_names )
1675 ),
1676 array( $this->db_name, $column_name )
1677 )->fetchAll( PDO::FETCH_COLUMN );
1678 }
1679
1680 $matched_tables = array_merge( $matched_temporary_tables, $matched_persistent_tables );
1681 $updates_multiple_tables = count( $matched_tables ) > 1;
1682 if ( 1 === count( $matched_tables ) ) {
1683 $table_or_alias = $matched_tables[0];
1684 } else {
1685 break;
1686 }
1687 }
1688
1689 if ( null === $update_target ) {
1690 $update_target = $table_or_alias;
1691 }
1692
1693 if ( $update_target !== $table_or_alias ) {
1694 $updates_multiple_tables = true;
1695 break;
1696 }
1697 }
1698 } else {
1699 $update_target = array_keys( $table_alias_map )[0];
1700 }
1701
1702 // TODO: Support UPDATE that modifies multiple tables.
1703 // This is non-trivial and likely requires temporary tables.
1704 // E.g.: UPDATE t1, t2 SET t1.id = t2.id, t2.id = t1.id;
1705 if ( $updates_multiple_tables ) {
1706 throw $this->new_not_supported_exception( 'UPDATE statement modifying multiple tables' );
1707 }
1708
1709 // Translate WITH clause.
1710 $with = $this->translate( $node->get_first_child_node( 'withClause' ) );
1711
1712 // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE".
1713 $or_ignore = $node->has_child_token( WP_MySQL_Lexer::IGNORE_SYMBOL )
1714 ? 'OR IGNORE'
1715 : null;
1716
1717 // Compose the update target clause.
1718 $update_target_table = $table_alias_map[ $update_target ]['table_name'] ?? $update_target;
1719 $update_target_clause = $this->quote_sqlite_identifier( $update_target_table );
1720 if ( $update_target !== $update_target_table ) {
1721 $update_target_clause .= ' AS ' . $this->quote_sqlite_identifier( $update_target );
1722 }
1723
1724 // Compose the FROM clause using all tables except the one being updated.
1725 // UPDATE with FROM in SQLite is equivalent to UPDATE with JOIN in MySQL.
1726 $from_items = array();
1727 foreach ( $table_alias_map as $alias => $data ) {
1728 if ( $alias === $update_target ) {
1729 continue;
1730 }
1731
1732 $table_name = $data['table_name'];
1733
1734 // Derived table.
1735 if ( null === $table_name ) {
1736 $from_item = $data['table_expr'] . ' AS ' . $this->quote_sqlite_identifier( $alias );
1737 $from_items[] = $from_item;
1738 continue;
1739 }
1740
1741 // Regular table.
1742 $from_item = $this->quote_sqlite_identifier( $table_name );
1743 if ( $alias !== $table_name ) {
1744 $from_item .= ' AS ' . $this->quote_sqlite_identifier( $alias );
1745 }
1746 $from_items[] = $from_item;
1747 }
1748
1749 $from = null;
1750 if ( count( $from_items ) > 0 ) {
1751 $from = 'FROM ' . implode( ', ', $from_items );
1752 }
1753
1754 // Translate UPDATE list.
1755 if ( $is_non_strict_mode ) {
1756 $update_list = $this->translate_update_list_in_non_strict_mode( $update_target, $update_list_node );
1757 } else {
1758 $update_parts = array();
1759 foreach ( $update_list_node->get_child_nodes() as $update_element ) {
1760 $column_ref = $update_element->get_first_child_node( 'columnRef' );
1761 $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' );
1762
1763 $update_part = $this->translate( end( $column_ref_parts ) );
1764 $update_part .= ' = ';
1765 $update_part .= $this->translate( $update_element->get_first_child_node( 'expr' ) );
1766 $update_parts[] = $update_part;
1767 }
1768 $update_list = implode( ', ', $update_parts );
1769 }
1770
1771 // Translate WHERE, ORDER BY, and LIMIT clauses.
1772 if ( $where_subquery ) {
1773 // When using a subquery, skip the original WHERE, ORDER BY, and LIMIT.
1774 $where_clause = ' WHERE rowid IN ( ' . $where_subquery . ' )';
1775 $order_clause = null;
1776 $limit_clause = null;
1777 } else {
1778 $where_clause = $this->translate( $node->get_first_child_node( 'whereClause' ) );
1779 $order_clause = $this->translate( $node->get_first_child_node( 'orderClause' ) );
1780 $limit_clause = $this->translate( $node->get_first_child_node( 'simpleLimitClause' ) );
1781 }
1782
1783 // With JOINs, we need to use the JOIN expressions in the WHERE clause.
1784 $join_exprs = array_filter( array_column( $table_alias_map, 'join_expr' ) );
1785 if ( count( $join_exprs ) > 0 ) {
1786 $where_clause .= $where_clause ? ' AND ' : ' WHERE ';
1787 $where_clause .= implode( ' AND ', $join_exprs );
1788 }
1789
1790 // Compose the UPDATE query.
1791 $parts = array(
1792 $with,
1793 'UPDATE',
1794 $or_ignore,
1795 $update_target_clause,
1796 'SET',
1797 $update_list,
1798 $from,
1799 $where_clause,
1800 $order_clause,
1801 $limit_clause,
1802 );
1803 $query = implode( ' ', array_filter( $parts ) );
1804
1805 $this->execute_sqlite_query( $query );
1806 $this->set_result_from_affected_rows();
1807 }
1808
1809 /**
1810 * Translate and execute a MySQL DELETE statement in SQLite.
1811 *
1812 * @param WP_Parser_Node $node The "deleteStatement" AST node.
1813 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1814 */
1815 private function execute_delete_statement( WP_Parser_Node $node ): void {
1816 /*
1817 * Multi-table DELETE.
1818 *
1819 * MySQL supports multi-table DELETE statements that don't work in SQLite.
1820 * These statements can have the following two flavours:
1821 * 1. "DELETE t1, t2 FROM ... JOIN ... WHERE ..."
1822 * 2. "DELETE FROM t1, t2 USING ... JOIN ... WHERE ..."
1823 *
1824 * We will rewrite such statements into a SELECT to fetch the ROWIDs of
1825 * the rows to delete and then execute a DELETE statement for each table.
1826 */
1827 $alias_ref_list = $node->get_first_child_node( 'tableAliasRefList' );
1828 if ( null !== $alias_ref_list ) {
1829 // 1. Get table aliases targeted by the DELETE statement.
1830 $table_aliases = array();
1831 foreach ( $alias_ref_list->get_child_nodes() as $alias_ref ) {
1832 $table_aliases[] = $this->unquote_sqlite_identifier(
1833 $this->translate( $alias_ref )
1834 );
1835 }
1836
1837 // 2. Create an alias to table name map.
1838 $alias_map = array();
1839 $table_ref_list = $node->get_first_child_node( 'tableReferenceList' );
1840 foreach ( $table_ref_list->get_descendant_nodes( 'singleTable' ) as $single_table ) {
1841 $alias = $this->unquote_sqlite_identifier(
1842 $this->translate( $single_table->get_first_child_node( 'tableAlias' ) )
1843 );
1844 $ref = $this->unquote_sqlite_identifier(
1845 $this->translate( $single_table->get_first_child_node( 'tableRef' ) )
1846 );
1847
1848 $alias_map[ $alias ] = $ref;
1849 }
1850
1851 // 3. Compose the SELECT query to fetch ROWIDs to delete.
1852 $where_clause = $node->get_first_child_node( 'whereClause' );
1853 if ( null !== $where_clause ) {
1854 $where = $this->translate( $where_clause->get_first_child_node( 'expr' ) );
1855 }
1856
1857 $select_list = array();
1858 foreach ( $table_aliases as $table ) {
1859 $select_list[] = sprintf(
1860 '%s.rowid AS %s',
1861 $this->quote_sqlite_identifier( $table ),
1862 $this->quote_sqlite_identifier( $table . '_rowid' )
1863 );
1864 }
1865
1866 $ids = $this->execute_sqlite_query(
1867 sprintf(
1868 'SELECT %s FROM %s %s',
1869 implode( ', ', $select_list ),
1870 $this->translate( $table_ref_list ),
1871 isset( $where ) ? "WHERE $where" : ''
1872 )
1873 )->fetchAll( PDO::FETCH_ASSOC );
1874
1875 // 4. Execute DELETE statements for each table.
1876 $rows = 0;
1877 if ( count( $ids ) > 0 ) {
1878 foreach ( $table_aliases as $table ) {
1879 $this->execute_sqlite_query(
1880 sprintf(
1881 'DELETE FROM %s AS %s WHERE rowid IN ( %s )',
1882 $this->quote_sqlite_identifier( $alias_map[ $table ] ),
1883 $this->quote_sqlite_identifier( $table ),
1884 implode( ', ', array_column( $ids, "{$table}_rowid" ) )
1885 )
1886 );
1887 $this->set_result_from_affected_rows();
1888 $rows += $this->last_result;
1889 }
1890 }
1891
1892 $this->set_result_from_affected_rows( $rows );
1893 return;
1894 }
1895
1896 // @TODO: Translate DELETE with JOIN to use a subquery.
1897
1898 $query = $this->translate( $node );
1899 $this->execute_sqlite_query( $query );
1900 $this->set_result_from_affected_rows();
1901 }
1902
1903 /**
1904 * Translate and execute a MySQL CREATE TABLE statement in SQLite.
1905 *
1906 * @param WP_Parser_Node $node The "createStatement" AST node with "createTable" child.
1907 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1908 */
1909 private function execute_create_table_statement( WP_Parser_Node $node ): void {
1910 $subnode = $node->get_first_child_node();
1911
1912 // Handle TEMPORARY keyword.
1913 $table_is_temporary = $subnode->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL );
1914
1915 // Handle CREATE TABLE ... [AS] SELECT.
1916 $element_list = $subnode->get_first_child_node( 'tableElementList' );
1917 if ( null === $element_list ) {
1918 /*
1919 * While SQLite supports CREATE TABLE ... AS SELECT statements,
1920 * we need to somehow implement information schema support for
1921 * the tables created in this way.
1922 *
1923 * TODO: Implement information schema support for CREATE TABLE ... AS SELECT.
1924 */
1925 throw $this->new_not_supported_exception(
1926 'CREATE TABLE ... [AS] SELECT is currently not supported'
1927 );
1928 }
1929
1930 // Get table name.
1931 $table_name = $this->unquote_sqlite_identifier(
1932 $this->translate( $subnode->get_first_child_node( 'tableName' ) )
1933 );
1934
1935 // Handle IF NOT EXISTS.
1936 if ( $subnode->has_child_node( 'ifNotExists' ) ) {
1937 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
1938 $table_exists = $this->execute_sqlite_query(
1939 sprintf(
1940 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ?',
1941 $this->quote_sqlite_identifier( $tables_table )
1942 ),
1943 array( $this->db_name, $table_name )
1944 )->fetchColumn();
1945
1946 if ( $table_exists ) {
1947 $this->set_result_from_affected_rows( 0 );
1948 return;
1949 }
1950 }
1951
1952 // Save information to information schema tables.
1953 $this->information_schema_builder->record_create_table( $node );
1954
1955 // Generate CREATE TABLE statement from the information schema tables.
1956 $queries = $this->get_sqlite_create_table_statement( $table_is_temporary, $table_name );
1957 $create_table_query = $queries[0];
1958 $constraint_queries = array_slice( $queries, 1 );
1959
1960 $this->execute_sqlite_query( $create_table_query );
1961
1962 foreach ( $constraint_queries as $query ) {
1963 $this->execute_sqlite_query( $query );
1964 }
1965 }
1966
1967 /**
1968 * Translate and execute a MySQL ALTER TABLE statement in SQLite.
1969 *
1970 * @param WP_Parser_Node $node The "alterStatement" AST node with "alterTable" child.
1971 * @throws WP_SQLite_Driver_Exception When the query execution fails.
1972 */
1973 private function execute_alter_table_statement( WP_Parser_Node $node ): void {
1974 $table_name = $this->unquote_sqlite_identifier(
1975 $this->translate( $node->get_first_descendant_node( 'tableRef' ) )
1976 );
1977
1978 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
1979
1980 // Save all column names from the original table.
1981 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
1982 $column_names = $this->execute_sqlite_query(
1983 sprintf(
1984 'SELECT
1985 COLUMN_NAME,
1986 LOWER(COLUMN_NAME) AS COLUMN_NAME_LOWERCASE
1987 FROM %s WHERE table_schema = ? AND table_name = ?',
1988 $this->quote_sqlite_identifier( $columns_table )
1989 ),
1990 array( $this->db_name, $table_name )
1991 )->fetchAll( PDO::FETCH_ASSOC );
1992
1993 // Track column renames and removals.
1994 $column_map = array_combine(
1995 array_column( $column_names, 'COLUMN_NAME_LOWERCASE' ),
1996 array_column( $column_names, 'COLUMN_NAME' )
1997 );
1998 foreach ( $node->get_descendant_nodes( 'alterListItem' ) as $action ) {
1999 $first_token = $action->get_first_child_token();
2000
2001 switch ( $first_token->id ) {
2002 case WP_MySQL_Lexer::DROP_SYMBOL:
2003 $name = $this->translate( $action->get_first_child_node( 'fieldIdentifier' ) );
2004 if ( null !== $name ) {
2005 $name = $this->unquote_sqlite_identifier( $name );
2006 unset( $column_map[ strtolower( $name ) ] );
2007 }
2008 break;
2009 case WP_MySQL_Lexer::CHANGE_SYMBOL:
2010 $old_name = $this->unquote_sqlite_identifier(
2011 $this->translate( $action->get_first_child_node( 'fieldIdentifier' ) )
2012 );
2013 $new_name = $this->unquote_sqlite_identifier(
2014 $this->translate( $action->get_first_child_node( 'identifier' ) )
2015 );
2016
2017 $column_map[ strtolower( $old_name ) ] = $new_name;
2018 break;
2019 case WP_MySQL_Lexer::RENAME_SYMBOL:
2020 $column_ref = $action->get_first_child_node( 'fieldIdentifier' );
2021 if ( null !== $column_ref ) {
2022 $old_name = $this->unquote_sqlite_identifier(
2023 $this->translate( $column_ref )
2024 );
2025 $new_name = $this->unquote_sqlite_identifier(
2026 $this->translate( $action->get_first_child_node( 'identifier' ) )
2027 );
2028
2029 $column_map[ strtolower( $old_name ) ] = $new_name;
2030 }
2031 break;
2032 }
2033 }
2034
2035 $this->information_schema_builder->record_alter_table( $node );
2036 $this->recreate_table_from_information_schema( $table_is_temporary, $table_name, $column_map );
2037
2038 // @TODO: Consider using a "fast path" for ALTER TABLE statements that
2039 // consist only of operations that SQLite's ALTER TABLE supports.
2040 }
2041
2042 /**
2043 * Translate and execute a MySQL DROP TABLE statement in SQLite.
2044 *
2045 * @param WP_Parser_Node $node The "dropStatement" AST node with "dropTable" child.
2046 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2047 */
2048 private function execute_drop_table_statement( WP_Parser_Node $node ): void {
2049 // Record the changes in the information schema.
2050 $this->information_schema_builder->record_drop_table( $node );
2051
2052 // MySQL supports removing multiple tables in a single query DROP query.
2053 // In SQLite, we need to execute each DROP TABLE statement separately.
2054 $child_node = $node->get_first_child_node();
2055 $table_refs = $child_node->get_first_child_node( 'tableRefList' )->get_child_nodes();
2056 $table_is_temporary = $child_node->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL );
2057 $queries = array();
2058 foreach ( $table_refs as $table_ref ) {
2059 $parts = array();
2060 foreach ( $child_node->get_children() as $child ) {
2061 $is_token = $child instanceof WP_MySQL_Token;
2062
2063 // Skip the TEMPORARY keyword.
2064 if ( $is_token && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $child->id ) {
2065 continue;
2066 }
2067
2068 // Replace table list with the current table reference.
2069 if ( ! $is_token && 'tableRefList' === $child->rule_name ) {
2070 // Add a "temp." schema prefix for temporary tables.
2071 $prefix = $table_is_temporary ? '`temp`.' : '';
2072 $part = $prefix . $this->translate( $table_ref );
2073 } else {
2074 $part = $this->translate( $child );
2075 }
2076
2077 if ( null !== $part ) {
2078 $parts[] = $part;
2079 }
2080 }
2081 $queries[] = 'DROP ' . implode( ' ', $parts );
2082 }
2083
2084 foreach ( $queries as $query ) {
2085 $this->execute_sqlite_query( $query );
2086 }
2087 }
2088
2089 /**
2090 * Translate and execute a MySQL TRUNCATE TABLE statement in SQLite.
2091 *
2092 * @param WP_Parser_Node $node The "truncateTableStatement" AST node.
2093 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2094 */
2095 private function execute_truncate_table_statement( WP_Parser_Node $node ): void {
2096 $table_name = $this->unquote_sqlite_identifier(
2097 $this->translate( $node->get_first_child_node( 'tableRef' ) )
2098 );
2099
2100 $this->execute_sqlite_query(
2101 sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) )
2102 );
2103 try {
2104 $this->execute_sqlite_query( 'DELETE FROM sqlite_sequence WHERE name = ?', array( $table_name ) );
2105 } catch ( PDOException $e ) {
2106 if ( str_contains( $e->getMessage(), 'no such table' ) ) {
2107 // The table might not exist if no sequences are used in the DB.
2108 } else {
2109 throw $e;
2110 }
2111 }
2112 $this->set_result_from_affected_rows();
2113 }
2114
2115 /**
2116 * Translate and execute a MySQL CREATE INDEX statement in SQLite.
2117 *
2118 * @param WP_Parser_Node $node The "createStatement" AST node with "createIndex" child.
2119 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2120 */
2121 private function execute_create_index_statement( WP_Parser_Node $node ): void {
2122 $this->information_schema_builder->record_create_index( $node );
2123
2124 $create_index = $node->get_first_child_node( 'createIndex' );
2125 $target = $create_index->get_first_child_node( 'createIndexTarget' );
2126
2127 $table_name = $this->unquote_sqlite_identifier(
2128 $this->translate( $target->get_first_child_node( 'tableRef' ) )
2129 );
2130 $index_name = $this->unquote_sqlite_identifier(
2131 $this->translate( $create_index->get_first_child_node( 'indexName' ) )
2132 );
2133 $is_unique = $create_index->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL );
2134
2135 // Get the key parts.
2136 $key_list_variants = $target->get_first_child_node( 'keyListVariants' );
2137 $key_list_nodes = $key_list_variants->get_first_child_node()->get_child_nodes();
2138 foreach ( $key_list_nodes as $key_list_node ) {
2139 if ( 'keyPartOrExpression' === $key_list_node->rule_name ) {
2140 $key_part_node = $key_list_node->get_first_child();
2141 } else {
2142 $key_part_node = $key_list_node;
2143 }
2144
2145 if ( 'keyPart' === $key_part_node->rule_name ) {
2146 $key_part = $this->translate( $key_part_node->get_first_child_node( 'identifier' ) );
2147 $direction = $key_part_node->get_first_child_node( 'direction' );
2148 if ( null !== $direction ) {
2149 $key_part .= ' ' . $this->translate( $direction );
2150 }
2151 } else {
2152 $key_part = $this->translate( $key_part_node );
2153 }
2154 $key_parts[] = $key_part;
2155 }
2156
2157 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $index_name );
2158 $this->execute_sqlite_query(
2159 sprintf(
2160 'CREATE %sINDEX %s ON %s (%s)',
2161 $is_unique ? 'UNIQUE ' : '',
2162 $this->quote_sqlite_identifier( $sqlite_index_name ),
2163 $this->translate( $target->get_first_child_node( 'tableRef' ) ),
2164 implode( ', ', $key_parts )
2165 )
2166 );
2167 }
2168
2169 /**
2170 * Translate and execute a MySQL DROP INDEX statement in SQLite.
2171 *
2172 * @param WP_Parser_Node $node The "dropStatement" AST node with "dropIndex" child.
2173 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2174 */
2175 private function execute_drop_index_statement( WP_Parser_Node $node ): void {
2176 $this->information_schema_builder->record_drop_index( $node );
2177
2178 $drop_index = $node->get_first_child_node( 'dropIndex' );
2179 $table_name = $this->unquote_sqlite_identifier(
2180 $this->translate( $drop_index->get_first_child_node( 'tableRef' ) )
2181 );
2182 $index_name = $this->unquote_sqlite_identifier(
2183 $this->translate( $drop_index->get_first_child_node( 'indexRef' ) )
2184 );
2185
2186 /*
2187 * In MySQL, "DROP INDEX `PRIMARY` ON <table>" removes the PRIMARY KEY.
2188 * This is not supported in SQLite, so in such cases, we need to recreate
2189 * the table without the PRIMARY KEY using the updated information schema.
2190 */
2191 if ( 'PRIMARY' === strtoupper( $index_name ) ) {
2192 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2193 $this->recreate_table_from_information_schema( $table_is_temporary, $table_name );
2194 return;
2195 }
2196
2197 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $index_name );
2198 $this->execute_sqlite_query(
2199 sprintf(
2200 'DROP INDEX %s',
2201 $this->quote_sqlite_identifier( $sqlite_index_name )
2202 )
2203 );
2204 }
2205
2206 /**
2207 * Translate and execute a MySQL SHOW statement in SQLite.
2208 *
2209 * @param WP_Parser_Node $node The "showStatement" AST node.
2210 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2211 */
2212 private function execute_show_statement( WP_Parser_Node $node ): void {
2213 $tokens = $node->get_child_tokens();
2214 $keyword1 = $tokens[1];
2215 $keyword2 = $tokens[2] ?? null;
2216
2217 switch ( $keyword1->id ) {
2218 case WP_MySQL_Lexer::DATABASES_SYMBOL:
2219 $this->execute_show_databases_statement( $node );
2220 return;
2221 case WP_MySQL_Lexer::COLUMNS_SYMBOL:
2222 case WP_MySQL_Lexer::FIELDS_SYMBOL:
2223 $this->execute_show_columns_statement( $node );
2224 return;
2225 case WP_MySQL_Lexer::CREATE_SYMBOL:
2226 if ( WP_MySQL_Lexer::TABLE_SYMBOL === $keyword2->id ) {
2227 $table_name = $this->unquote_sqlite_identifier(
2228 $this->translate( $node->get_first_child_node( 'tableRef' ) )
2229 );
2230
2231 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2232
2233 $sql = $this->get_mysql_create_table_statement( $table_is_temporary, $table_name );
2234 if ( null === $sql ) {
2235 $this->set_results_from_fetched_data( array() );
2236 } else {
2237 $this->set_results_from_fetched_data(
2238 array(
2239 (object) array(
2240 'Create Table' => $sql,
2241 ),
2242 )
2243 );
2244 }
2245 return;
2246 }
2247 break;
2248 case WP_MySQL_Lexer::INDEX_SYMBOL:
2249 case WP_MySQL_Lexer::INDEXES_SYMBOL:
2250 case WP_MySQL_Lexer::KEYS_SYMBOL:
2251 $table_name = $this->unquote_sqlite_identifier(
2252 $this->translate( $node->get_first_child_node( 'tableRef' ) )
2253 );
2254 $this->execute_show_index_statement( $table_name );
2255 return;
2256 case WP_MySQL_Lexer::GRANTS_SYMBOL:
2257 $this->set_results_from_fetched_data(
2258 array(
2259 (object) array(
2260 'Grants for root@localhost' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION',
2261 ),
2262 )
2263 );
2264 return;
2265 case WP_MySQL_Lexer::TABLE_SYMBOL:
2266 $this->execute_show_table_status_statement( $node );
2267 return;
2268 case WP_MySQL_Lexer::TABLES_SYMBOL:
2269 $this->execute_show_tables_statement( $node );
2270 return;
2271 case WP_MySQL_Lexer::VARIABLES_SYMBOL:
2272 $this->last_result = true;
2273 return;
2274 }
2275
2276 throw $this->new_not_supported_exception(
2277 sprintf(
2278 'statement type: "%s" > "%s"',
2279 $node->rule_name,
2280 $keyword1->get_value()
2281 )
2282 );
2283 }
2284
2285 /**
2286 * Translate and execute a MySQL SHOW DATABASES statement in SQLite.
2287 *
2288 * @param WP_Parser_Node $node The "showStatement" AST node.
2289 */
2290 private function execute_show_databases_statement( WP_Parser_Node $node ): void {
2291 $schemata_table = $this->information_schema_builder->get_table_name( false, 'schemata' );
2292
2293 // LIKE and WHERE clauses.
2294 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
2295 if ( null !== $like_or_where ) {
2296 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'schema_name' );
2297 }
2298
2299 $databases = $this->execute_sqlite_query(
2300 sprintf(
2301 'SELECT SCHEMA_NAME AS Database FROM %s%s ORDER BY SCHEMA_NAME',
2302 $this->quote_sqlite_identifier( $schemata_table ),
2303 isset( $condition ) ? ( ' WHERE TRUE ' . $condition ) : ''
2304 )
2305 )->fetchAll( PDO::FETCH_OBJ );
2306
2307 $this->set_results_from_fetched_data( $databases );
2308 }
2309
2310 /**
2311 * Translate and execute a MySQL SHOW INDEX statement in SQLite.
2312 *
2313 * @param string $table_name The table name to show indexes for.
2314 */
2315 private function execute_show_index_statement( string $table_name ): void {
2316 // TODO: FROM/IN (multiple)
2317 // TODO: WHERE
2318
2319 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2320
2321 /*
2322 * TODO: Index naming.
2323 *
2324 * From the old driver:
2325 *
2326 * SQLite automatically assigns names to some indexes.
2327 * However, dbDelta in WordPress expects the name to be
2328 * the same as in the original CREATE TABLE. Let's
2329 * translate the name back.
2330 *
2331 * The old driver does the two following conversions:
2332 * 1)
2333 * $mysql_key_name = substr( $mysql_key_name, strlen( 'sqlite_autoindex_' ) );
2334 * $mysql_key_name = preg_replace( '/_[0-9]+$/', '', $mysql_key_name );
2335 * 2)
2336 * $mysql_key_name = substr( $mysql_key_name, strlen( "{$table_name}__" ) );
2337 */
2338
2339 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
2340 $index_info = $this->execute_sqlite_query(
2341 '
2342 SELECT
2343 TABLE_NAME AS `Table`,
2344 NON_UNIQUE AS `Non_unique`,
2345 INDEX_NAME AS `Key_name`,
2346 SEQ_IN_INDEX AS `Seq_in_index`,
2347 COLUMN_NAME AS `Column_name`,
2348 COLLATION AS `Collation`,
2349 CARDINALITY AS `Cardinality`,
2350 SUB_PART AS `Sub_part`,
2351 PACKED AS `Packed`,
2352 NULLABLE AS `Null`,
2353 INDEX_TYPE AS `Index_type`,
2354 COMMENT AS `Comment`,
2355 INDEX_COMMENT AS `Index_comment`,
2356 IS_VISIBLE AS `Visible`,
2357 EXPRESSION AS `Expression`
2358 FROM ' . $this->quote_sqlite_identifier( $statistics_table ) . "
2359 WHERE table_schema = ?
2360 AND table_name = ?
2361 ORDER BY
2362 INDEX_NAME = 'PRIMARY' DESC,
2363 NON_UNIQUE = '0' DESC,
2364 INDEX_TYPE = 'SPATIAL' DESC,
2365 INDEX_TYPE = 'BTREE' DESC,
2366 INDEX_TYPE = 'FULLTEXT' DESC,
2367 ROWID,
2368 SEQ_IN_INDEX
2369 ",
2370 array( $this->db_name, $table_name )
2371 )->fetchAll( PDO::FETCH_OBJ );
2372
2373 $this->set_results_from_fetched_data( $index_info );
2374 }
2375
2376 /**
2377 * Translate and execute a MySQL SHOW TABLE STATUS statement in SQLite.
2378 *
2379 * @param WP_Parser_Node $node The "showStatement" AST node.
2380 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2381 */
2382 private function execute_show_table_status_statement( WP_Parser_Node $node ): void {
2383 // FROM/IN database.
2384 $in_db = $node->get_first_child_node( 'inDb' );
2385 if ( null === $in_db ) {
2386 $database = $this->db_name;
2387 } else {
2388 $database = $this->unquote_sqlite_identifier(
2389 $this->translate( $in_db->get_first_child_node( 'identifier' ) )
2390 );
2391 }
2392
2393 // LIKE and WHERE clauses.
2394 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
2395 if ( null !== $like_or_where ) {
2396 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'table_name' );
2397 }
2398
2399 // Fetch table information.
2400 $tables_tables = $this->information_schema_builder->get_table_name(
2401 false, // SHOW TABLE STATUS lists only non-temporary tables.
2402 'tables'
2403 );
2404 $table_info = $this->execute_sqlite_query(
2405 sprintf(
2406 'SELECT * FROM %s WHERE table_schema = ? %s ORDER BY table_name',
2407 $this->quote_sqlite_identifier( $tables_tables ),
2408 $condition ?? ''
2409 ),
2410 array( $database )
2411 )->fetchAll( PDO::FETCH_ASSOC );
2412
2413 if ( false === $table_info ) {
2414 $this->set_results_from_fetched_data( array() );
2415 }
2416
2417 // Format the results.
2418 $tables = array();
2419 foreach ( $table_info as $value ) {
2420 $tables[] = (object) array(
2421 'Name' => $value['TABLE_NAME'],
2422 'Engine' => $value['ENGINE'],
2423 'Version' => $value['VERSION'],
2424 'Row_format' => $value['ROW_FORMAT'],
2425 'Rows' => $value['TABLE_ROWS'],
2426 'Avg_row_length' => $value['AVG_ROW_LENGTH'],
2427 'Data_length' => $value['DATA_LENGTH'],
2428 'Max_data_length' => $value['MAX_DATA_LENGTH'],
2429 'Index_length' => $value['INDEX_LENGTH'],
2430 'Data_free' => $value['DATA_FREE'],
2431 'Auto_increment' => $value['AUTO_INCREMENT'],
2432 'Create_time' => $value['CREATE_TIME'],
2433 'Update_time' => $value['UPDATE_TIME'],
2434 'Check_time' => $value['CHECK_TIME'],
2435 'Collation' => $value['TABLE_COLLATION'],
2436 'Checksum' => $value['CHECKSUM'],
2437 'Create_options' => $value['CREATE_OPTIONS'],
2438 'Comment' => $value['TABLE_COMMENT'],
2439 );
2440 }
2441
2442 $this->set_results_from_fetched_data( $tables );
2443 }
2444
2445 /**
2446 * Translate and execute a MySQL SHOW TABLES statement in SQLite.
2447 *
2448 * @param WP_Parser_Node $node The "showStatement" AST node.
2449 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2450 */
2451 private function execute_show_tables_statement( WP_Parser_Node $node ): void {
2452 // FROM/IN database.
2453 $in_db = $node->get_first_child_node( 'inDb' );
2454 if ( null === $in_db ) {
2455 $database = $this->db_name;
2456 } else {
2457 $database = $this->unquote_sqlite_identifier(
2458 $this->translate( $in_db->get_first_child_node( 'identifier' ) )
2459 );
2460 }
2461
2462 // LIKE and WHERE clauses.
2463 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
2464 if ( null !== $like_or_where ) {
2465 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'table_name' );
2466 }
2467
2468 // Fetch table information.
2469 $table_tables = $this->information_schema_builder->get_table_name(
2470 false, // SHOW TABLES lists only non-temporary tables.
2471 'tables'
2472 );
2473 $table_info = $this->execute_sqlite_query(
2474 sprintf(
2475 'SELECT * FROM %s WHERE table_schema = ? %s ORDER BY table_name',
2476 $this->quote_sqlite_identifier( $table_tables ),
2477 $condition ?? ''
2478 ),
2479 array( $database )
2480 )->fetchAll( PDO::FETCH_ASSOC );
2481
2482 if ( false === $table_info ) {
2483 $this->set_results_from_fetched_data( array() );
2484 }
2485
2486 // Handle the FULL keyword.
2487 $command_type = $node->get_first_child_node( 'showCommandType' );
2488 $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL );
2489
2490 // Format the results.
2491 $tables = array();
2492 foreach ( $table_info as $value ) {
2493 $table = array(
2494 "Tables_in_$database" => $value['TABLE_NAME'],
2495 );
2496 if ( true === $is_full ) {
2497 $table['Table_type'] = $value['TABLE_TYPE'];
2498 }
2499 $tables[] = (object) $table;
2500 }
2501
2502 $this->set_results_from_fetched_data( $tables );
2503 }
2504
2505 /**
2506 * Translate and execute a MySQL SHOW COLUMNS statement in SQLite.
2507 *
2508 * @param WP_Parser_Node $node The "showStatement" AST node.
2509 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2510 * @throws PDOException When given table doesn't exist.
2511 */
2512 private function execute_show_columns_statement( WP_Parser_Node $node ): void {
2513 // TODO: EXTENDED, FULL
2514 $table_name = $this->unquote_sqlite_identifier(
2515 $this->translate( $node->get_first_child_node( 'tableRef' ) )
2516 );
2517
2518 // FROM/IN database.
2519 $in_db = $node->get_first_child_node( 'inDb' );
2520 if ( null === $in_db ) {
2521 $database = $this->db_name;
2522 } else {
2523 $database = $this->unquote_sqlite_identifier(
2524 $this->translate( $in_db->get_first_child_node( 'identifier' ) )
2525 );
2526 }
2527
2528 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2529
2530 // Check if the table exists.
2531 $tables_tables = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
2532 $table_exists = $this->execute_sqlite_query(
2533 sprintf(
2534 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ?',
2535 $this->quote_sqlite_identifier( $tables_tables )
2536 ),
2537 array( $this->db_name, $table_name )
2538 )->fetchColumn();
2539
2540 if ( ! $table_exists ) {
2541 throw $this->new_driver_exception(
2542 sprintf( "Table '%s.%s' doesn't exist", $database, $table_name ),
2543 '42S02'
2544 );
2545 }
2546
2547 // LIKE and WHERE clauses.
2548 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
2549 if ( null !== $like_or_where ) {
2550 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'column_name' );
2551 }
2552
2553 // Fetch column information.
2554 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
2555 $column_info = $this->execute_sqlite_query(
2556 sprintf(
2557 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? %s ORDER BY ordinal_position',
2558 $this->quote_sqlite_identifier( $columns_table ),
2559 $condition ?? ''
2560 ),
2561 array( $database, $table_name )
2562 )->fetchAll( PDO::FETCH_ASSOC );
2563
2564 if ( false === $column_info ) {
2565 $this->set_results_from_fetched_data( array() );
2566 }
2567
2568 // Format the results.
2569 $columns = array();
2570 foreach ( $column_info as $value ) {
2571 $column = array(
2572 'Field' => $value['COLUMN_NAME'],
2573 'Type' => $value['COLUMN_TYPE'],
2574 'Null' => $value['IS_NULLABLE'],
2575 'Key' => $value['COLUMN_KEY'],
2576 'Default' => $value['COLUMN_DEFAULT'],
2577 'Extra' => $value['EXTRA'],
2578 );
2579 $columns[] = (object) $column;
2580 }
2581
2582 $this->set_results_from_fetched_data( $columns );
2583 }
2584
2585 /**
2586 * Translate and execute a MySQL DESCRIBE statement in SQLite.
2587 *
2588 * @param WP_Parser_Node $node The "describeStatement" AST node.
2589 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2590 */
2591 private function execute_describe_statement( WP_Parser_Node $node ): void {
2592 $table_name = $this->unquote_sqlite_identifier(
2593 $this->translate( $node->get_first_child_node( 'tableRef' ) )
2594 );
2595
2596 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2597
2598 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
2599 $column_info = $this->execute_sqlite_query(
2600 '
2601 SELECT
2602 column_name AS `Field`,
2603 column_type AS `Type`,
2604 is_nullable AS `Null`,
2605 column_key AS `Key`,
2606 column_default AS `Default`,
2607 extra AS Extra
2608 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
2609 WHERE table_schema = ?
2610 AND table_name = ?
2611 ORDER BY ordinal_position
2612 ',
2613 array( $this->db_name, $table_name )
2614 )->fetchAll( PDO::FETCH_OBJ );
2615
2616 $this->set_results_from_fetched_data( $column_info );
2617 }
2618
2619 /**
2620 * Translate and execute a MySQL USE statement in SQLite.
2621 *
2622 * @param WP_Parser_Node $node The "useStatement" AST node.
2623 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2624 */
2625 private function execute_use_statement( WP_Parser_Node $node ): void {
2626 $database_name = $this->unquote_sqlite_identifier(
2627 $this->translate( $node->get_first_child_node( 'identifier' ) )
2628 );
2629
2630 if ( 'information_schema' === strtolower( $database_name ) ) {
2631 $this->db_name = 'information_schema';
2632 } elseif ( $this->db_name === $database_name ) {
2633 $this->db_name = $database_name;
2634 } else {
2635 throw $this->new_not_supported_exception(
2636 sprintf(
2637 "can't use schema '%s', only '%s' and 'information_schema' are supported",
2638 $database_name,
2639 $this->db_name
2640 )
2641 );
2642 }
2643 }
2644
2645 /**
2646 * Translate and execute a MySQL SET statement in SQLite.
2647 *
2648 * @param WP_Parser_Node $node The "setStatement" AST node.
2649 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2650 */
2651 private function execute_set_statement( WP_Parser_Node $node ): void {
2652 /*
2653 * 1. Flatten the SET statement into a single array of definitions.
2654 *
2655 * The grammar is non-trivial, and supports multi-statements like:
2656 * SET @var = '...', SESSION sql_mode = '...', @@GLOBAL.time_zone = '...', @@debug = '...', ...
2657 *
2658 * This will be flattened into a single array of grammar node lists:
2659 * [
2660 * [ <userVariable>, <equal>, <expr> ],
2661 * [ <optionType>, <internalVariableName>, <equal>, <setExprOrDefault> ],
2662 * [ <setSystemVariable>, <equal>, <setExprOrDefault> ],
2663 * [ <setSystemVariable>, <equal>, <setExprOrDefault> ],
2664 * ]
2665 */
2666 $subnode = $node->get_first_child_node();
2667 if ( $subnode->has_child_node( 'optionValueNoOptionType' ) ) {
2668 $start_node = $subnode->get_first_child_node( 'optionValueNoOptionType' );
2669 $definitions = array( $start_node->get_children() );
2670 } elseif ( $subnode->has_child_node( 'startOptionValueListFollowingOptionType' ) ) {
2671 $start_node = $subnode
2672 ->get_first_child_node( 'startOptionValueListFollowingOptionType' )
2673 ->get_first_child_node( 'optionValueFollowingOptionType' ) ?? $node;
2674 $definitions = array(
2675 array_merge(
2676 array( $subnode->get_first_child_node( 'optionType' ) ),
2677 $start_node->get_children()
2678 ),
2679 );
2680 } else {
2681 $definitions = array( $subnode->get_children() );
2682 }
2683
2684 $continue_node = $subnode->get_first_child_node( 'optionValueListContinued' );
2685 if ( $continue_node ) {
2686 foreach ( $continue_node->get_child_nodes( 'optionValue' ) as $child ) {
2687 $node = $child->get_first_child_node( 'optionValueNoOptionType' ) ?? $child;
2688 $definitions[] = $node->get_child_nodes();
2689 }
2690 }
2691
2692 /*
2693 * 2. Iterate and process the SET definitions.
2694 *
2695 * When an "optionType" node is encountered (such as "SESSION var = ..."),
2696 * it's value is used for all following system variable assignments that
2697 * have no type keyword specified, until the next "optionType" is found.
2698 *
2699 * This doesn't apply to "@@" type prefixes (such as "@@SESSION.var_name"),
2700 * which always impact only the immediately following system variable.
2701 */
2702 $default_type = WP_MySQL_Lexer::SESSION_SYMBOL;
2703 foreach ( $definitions as $definition ) {
2704 // Check if the definition starts with an "optionType" node with
2705 // one of the SESSION, GLOBAL, PERSIST, or PERSIST_ONLY tokens.
2706 $part = array_shift( $definition );
2707 if ( $part instanceof WP_Parser_Node && 'optionType' === $part->rule_name ) {
2708 $default_type = $part->get_first_child_token()->id;
2709 $part = array_shift( $definition );
2710 }
2711
2712 if (
2713 $part instanceof WP_MySQL_Token
2714 && WP_MySQL_Lexer::NAMES_SYMBOL === $part->id
2715 ) {
2716 // "SET NAMES ..." is a no-op for now.
2717 // TODO: Validate charset compatibility with UTF-8.
2718 // See: https://github.com/WordPress/sqlite-database-integration/issues/192
2719 } elseif (
2720 $part instanceof WP_Parser_Node
2721 && 'charsetClause' === $part->rule_name
2722 ) {
2723 // "SET CHARACTER SET ..." is a no-op for now.
2724 // TODO: Validate charset compatibility with UTF-8.
2725 // See: https://github.com/WordPress/sqlite-database-integration/issues/192
2726 } elseif (
2727 $part instanceof WP_Parser_Node
2728 && (
2729 'internalVariableName' === $part->rule_name
2730 || 'setSystemVariable' === $part->rule_name
2731 )
2732 ) {
2733 // Set a system variable.
2734 array_shift( $definition ); // Remove the '='.
2735 $value = array_shift( $definition );
2736 $this->execute_set_system_variable_statement( $part, $value, $default_type );
2737 } elseif (
2738 $part instanceof WP_Parser_Node
2739 && 'userVariable' === $part->rule_name
2740 ) {
2741 // Set a user variable.
2742 array_shift( $definition ); // Remove the '='.
2743 $value = array_shift( $definition );
2744 $this->execute_set_user_variable_statement( $part, $value );
2745 } else {
2746 throw $this->new_not_supported_exception(
2747 sprintf( 'SET statement: %s', $node->rule_name )
2748 );
2749 }
2750 }
2751
2752 $this->last_result = 0;
2753 }
2754
2755 /**
2756 * Translate and execute a MySQL SET statement for system variables.
2757 *
2758 * @param WP_Parser_Node $set_var_node The "internalVariableName" or "setSystemVariable" AST node.
2759 * @param WP_Parser_Node $value_node The "setExprOrDefault" AST node.
2760 * @param int $default_type The currently active default variable type.
2761 * One of the SESSION, GLOBAL, PERSIST, PERSIST_ONLY tokens.
2762 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2763 */
2764 private function execute_set_system_variable_statement(
2765 WP_Parser_Node $set_var_node,
2766 WP_Parser_Node $value_node,
2767 int $default_type
2768 ): void {
2769 // Get the variable name.
2770 $internal_variable_name = 'setSystemVariable' === $set_var_node->rule_name
2771 ? $set_var_node->get_first_child_node( 'internalVariableName' )
2772 : $set_var_node;
2773
2774 $name = strtolower(
2775 $this->unquote_sqlite_identifier(
2776 $this->translate( $internal_variable_name )
2777 )
2778 );
2779
2780 // Get the type attribute (one of SESSION, GLOBAL, PERSIST, PERSIST_ONLY).
2781 $type = $default_type;
2782 if ( $set_var_node->has_child_node( 'setVarIdentType' ) ) {
2783 $var_ident_type = $set_var_node->get_first_child_node( 'setVarIdentType' );
2784 $type = $var_ident_type->get_first_child_token()->id;
2785 }
2786
2787 /*
2788 * Some MySQL system variables values can be set using an unquoted pure
2789 * identifier rather than a string literal. This includes non-reserved
2790 * keywords. This is equivalent to using a corresponding string literal.
2791 *
2792 * For example, the following statement pairs are equivalent:
2793 *
2794 * SET default_storage_engine = InnoDB
2795 * SET default_storage_engine = 'InnoDB'
2796 *
2797 * SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci
2798 * SET default_collation_for_utf8mb4 = 'utf8mb4_0900_ai_ci'
2799 *
2800 * In this cases, we need to use the value directly without attempting
2801 * to evaluate the expression, as that would result in a query error.
2802 * In the grammar, unquoted identifiers are captured by "columnRef".
2803 */
2804 $identifier = $this->translate( $value_node->get_first_descendant_node( 'columnRef' ) );
2805 if ( $identifier && $identifier === $this->translate( $value_node ) ) {
2806 $value = $this->unquote_sqlite_identifier( $identifier );
2807 } elseif ( ! $value_node->has_child_node( 'expr' ) ) {
2808 $value = $this->unquote_sqlite_identifier( $this->translate( $value_node ) );
2809 } else {
2810 $value = $this->evaluate_expression( $value_node );
2811 }
2812
2813 /*
2814 * Handle ON/OFF values. They are accepted as both strings and keywords.
2815 *
2816 * @TODO: This is actually variable-specific and depends on the its type.
2817 * For example:
2818 * SET autocommit = OFF; SELECT @@autocommit; -> 0
2819 * SET autocommit = false; SELECT @@autocommit; -> 0
2820 * SET session_track_gtids = OFF; SELECT @@session_track_gtids; -> OFF
2821 * SET session_track_gtids = false; SELECT @@session_track_gtids; -> OFF
2822 * SET updatable_views_with_limit = OFF; ERROR 1231 (42000)
2823 * SET updatable_views_with_limit = false; SELECT @@updatable_views_with_limit; -> NO
2824 */
2825 $lowercase_value = strtolower( $value );
2826 if ( 'on' === $lowercase_value || 'off' === $lowercase_value ) {
2827 $value = 'on' === $lowercase_value ? 1 : 0;
2828 }
2829
2830 if ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) {
2831 if ( 'sql_mode' === $name ) {
2832 $modes = explode( ',', strtoupper( $value ) );
2833 $this->active_sql_modes = $modes;
2834 } else {
2835 $this->session_system_variables[ $name ] = $value;
2836 }
2837 } elseif ( WP_MySQL_Lexer::GLOBAL_SYMBOL === $type ) {
2838 throw $this->new_not_supported_exception( "SET statement type: 'GLOBAL'" );
2839 } elseif ( WP_MySQL_Lexer::PERSIST_SYMBOL === $type ) {
2840 throw $this->new_not_supported_exception( "SET statement type: 'PERSIST'" );
2841 } elseif ( WP_MySQL_Lexer::PERSIST_ONLY_SYMBOL === $type ) {
2842 throw $this->new_not_supported_exception( "SET statement type: 'PERSIST_ONLY'" );
2843 }
2844
2845 // TODO: Handle GLOBAL, PERSIST, and PERSIST_ONLY types.
2846 }
2847
2848 /**
2849 * Translate and execute a MySQL SET statement for user variables.
2850 *
2851 * @param WP_Parser_Node $user_variable The "userVariable" AST node.
2852 * @param WP_Parser_Node $expr The "expr" AST node.
2853 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2854 */
2855 private function execute_set_user_variable_statement(
2856 WP_Parser_Node $user_variable,
2857 WP_Parser_Node $expr
2858 ): void {
2859 $name = $this->unquote_sqlite_identifier(
2860 $this->translate( $user_variable->get_first_child() )
2861 );
2862 $name = strtolower( substr( $name, 1 ) ); // Remove '@', normalize case.
2863 $value = $this->evaluate_expression( $expr );
2864
2865 $this->user_variables[ $name ] = $value;
2866 }
2867
2868 /**
2869 * Translate and execute a MySQL administration statement in SQLite.
2870 *
2871 * This emulates the following MySQL statements:
2872 * - ANALYZE TABLE
2873 * - CHECK TABLE
2874 * - OPTIMIZE TABLE
2875 * - REPAIR TABLE
2876 *
2877 * @param WP_Parser_Node $node A "tableAdministrationStatement" AST node.
2878 * @throws WP_SQLite_Driver_Exception When the query execution fails.
2879 */
2880 private function execute_administration_statement( WP_Parser_Node $node ): void {
2881 $first_token = $node->get_first_child_token();
2882 $table_ref_list = $node->get_first_child_node( 'tableRefList' );
2883 $results = array();
2884 foreach ( $table_ref_list->get_child_nodes( 'tableRef' ) as $table_ref ) {
2885 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
2886 $quoted_table_name = $this->quote_sqlite_identifier( $table_name );
2887 try {
2888 switch ( $first_token->id ) {
2889 case WP_MySQL_Lexer::ANALYZE_SYMBOL:
2890 $stmt = $this->execute_sqlite_query( sprintf( 'ANALYZE %s', $quoted_table_name ) );
2891 $errors = $stmt->fetchAll( PDO::FETCH_COLUMN );
2892 break;
2893 case WP_MySQL_Lexer::CHECK_SYMBOL:
2894 $stmt = $this->execute_sqlite_query(
2895 sprintf( 'PRAGMA integrity_check(%s)', $quoted_table_name )
2896 );
2897 $errors = $stmt->fetchAll( PDO::FETCH_COLUMN );
2898 if ( 'ok' === $errors[0] ) {
2899 array_shift( $errors );
2900 }
2901 break;
2902 case WP_MySQL_Lexer::OPTIMIZE_SYMBOL:
2903 case WP_MySQL_Lexer::REPAIR_SYMBOL:
2904 /*
2905 * SQLite doesn't support OPTIMIZE and REPAIR TABLE commands.
2906 * We will recreate the table and copy the data instead.
2907 * This corresponds to older MySQL OPTIMIZE TABLE behavior
2908 * and still applies to some storage engines in some cases.
2909 */
2910 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2911 $this->recreate_table_from_information_schema( $table_is_temporary, $table_name );
2912 $errors = array();
2913 break;
2914 default:
2915 throw $this->new_not_supported_exception(
2916 sprintf(
2917 'statement type: "%s" > "%s"',
2918 $node->rule_name,
2919 $first_token->get_value()
2920 )
2921 );
2922 }
2923 } catch ( PDOException $e ) {
2924 if ( 'HY000' === $e->getCode() ) {
2925 $errors = array( "Table '$table_name' doesn't exist" );
2926 } else {
2927 $errors = array( $e->getMessage() );
2928 }
2929 }
2930
2931 $operation = strtolower( $first_token->get_value() );
2932 foreach ( $errors as $error ) {
2933 $results[] = (object) array(
2934 'Table' => $this->db_name . '.' . $table_name,
2935 'Op' => $operation,
2936 'Msg_type' => 'Error',
2937 'Msg_text' => $error,
2938 );
2939 }
2940 $results[] = (object) array(
2941 'Table' => $this->db_name . '.' . $table_name,
2942 'Op' => $operation,
2943 'Msg_type' => 'status',
2944 'Msg_text' => count( $errors ) > 0 ? 'Operation failed' : 'OK',
2945 );
2946 }
2947 $this->set_results_from_fetched_data( $results );
2948 }
2949
2950 /**
2951 * Evaluate an expression and return the value, preserving its type.
2952 *
2953 * This is used to support expressions in SET statements for MySQL variables.
2954 *
2955 * @param WP_Parser_Node $node The "expr" AST node.
2956 * @return mixed The value of the expression.
2957 */
2958 public function evaluate_expression( WP_Parser_Node $node ) {
2959 // To support expressions, we'll use a SQLite query.
2960 $stmt = $this->execute_sqlite_query(
2961 sprintf( 'SELECT %s', $this->translate( $node ) )
2962 );
2963
2964 // MySQL variables are typed, so we need to preserve the value type.
2965 $value = $stmt->fetchColumn();
2966 $type = $stmt->getColumnMeta( 0 )['native_type'];
2967 if ( 'null' === $type ) {
2968 return null;
2969 } elseif ( 'integer' === $type ) {
2970 return (int) $value;
2971 } elseif ( 'double' === $type ) {
2972 return (float) $value;
2973 }
2974 return $value;
2975 }
2976
2977 /**
2978 * Translate a MySQL AST node or token to an SQLite query fragment.
2979 *
2980 * @param WP_Parser_Node|WP_MySQL_Token $node The AST node to translate.
2981 * @return string|null The translated query fragment.
2982 * @throws WP_SQLite_Driver_Exception When the translation fails.
2983 */
2984 private function translate( $node ): ?string {
2985 if ( null === $node ) {
2986 return null;
2987 }
2988
2989 if ( $node instanceof WP_MySQL_Token ) {
2990 return $this->translate_token( $node );
2991 }
2992
2993 if ( ! $node instanceof WP_Parser_Node ) {
2994 throw $this->new_driver_exception(
2995 sprintf(
2996 'Expected a WP_Parser_Node or WP_MySQL_Token instance, got: %s',
2997 gettype( $node )
2998 )
2999 );
3000 }
3001
3002 $rule_name = $node->rule_name;
3003 switch ( $rule_name ) {
3004 case 'queryExpression':
3005 return $this->translate_query_expression( $node );
3006 case 'querySpecification':
3007 return $this->translate_query_specification( $node );
3008 case 'qualifiedIdentifier':
3009 case 'tableRefWithWildcard':
3010 $parts = $node->get_descendant_nodes( 'identifier' );
3011 if ( count( $parts ) === 2 ) {
3012 return $this->translate_qualified_identifier( $parts[0], $parts[1] );
3013 }
3014 return $this->translate_qualified_identifier( null, $parts[0] );
3015 case 'fieldIdentifier':
3016 case 'simpleIdentifier':
3017 $parts = $node->get_descendant_nodes( 'identifier' );
3018 if ( count( $parts ) === 3 ) {
3019 return $this->translate_qualified_identifier( $parts[0], $parts[1], $parts[2] );
3020 } elseif ( count( $parts ) === 2 ) {
3021 return $this->translate_qualified_identifier( null, $parts[0], $parts[1] );
3022 }
3023 return $this->translate_qualified_identifier( null, null, $parts[0] );
3024 case 'tableWild':
3025 $parts = $node->get_descendant_nodes( 'identifier' );
3026 if ( count( $parts ) === 2 ) {
3027 return $this->translate_qualified_identifier( $parts[0], $parts[1] ) . '.*';
3028 }
3029 return $this->translate_qualified_identifier( null, $parts[0] ) . '.*';
3030 case 'dotIdentifier':
3031 return $this->translate_sequence( $node->get_children(), '' );
3032 case 'identifierKeyword':
3033 return '`' . $this->translate( $node->get_first_child() ) . '`';
3034 case 'pureIdentifier':
3035 $value = $this->translate_pure_identifier( $node );
3036
3037 /*
3038 * At the moment, we only support ASCII bytes in all identifiers.
3039 * This is because SQLite doesn't support case-insensitive Unicode
3040 * character matching: https://sqlite.org/faq.html#q18
3041 */
3042 for ( $i = 0; $i < strlen( $value ); $i++ ) {
3043 if ( ord( $value[ $i ] ) > 127 ) {
3044 throw $this->new_driver_exception(
3045 'The SQLite driver only supports ASCII characters in identifiers.'
3046 );
3047 }
3048 }
3049 return $value;
3050 case 'textStringLiteral':
3051 return $this->translate_string_literal( $node );
3052 case 'dataType':
3053 case 'nchar':
3054 $child = $node->get_first_child();
3055 if ( $child instanceof WP_Parser_Node ) {
3056 return $this->translate( $child );
3057 }
3058
3059 // Handle optional prefixes (data type is the second token):
3060 // 1. LONG VARCHAR, LONG CHAR(ACTER) VARYING, LONG VARBINARY.
3061 // 2. NATIONAL CHAR, NATIONAL VARCHAR, NATIONAL CHAR(ACTER) VARYING.
3062 if ( WP_MySQL_Lexer::LONG_SYMBOL === $child->id ) {
3063 $child = $node->get_child_tokens()[1] ?? null;
3064 } elseif ( WP_MySQL_Lexer::NATIONAL_SYMBOL === $child->id ) {
3065 $child = $node->get_child_tokens()[1] ?? null;
3066 }
3067
3068 if ( null === $child ) {
3069 throw $this->new_invalid_input_exception();
3070 }
3071
3072 $type_token = self::DATA_TYPE_MAP[ $child->id ] ?? null;
3073 if ( null !== $type_token ) {
3074 return $type_token;
3075 }
3076
3077 // SERIAL is an alias for BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE.
3078 if ( WP_MySQL_Lexer::SERIAL_SYMBOL === $child->id ) {
3079 return 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE';
3080 }
3081
3082 // @TODO: Handle SET and JSON.
3083 throw $this->new_not_supported_exception(
3084 sprintf( 'data type: %s', $child->get_value() )
3085 );
3086 case 'selectItem':
3087 return $this->translate_select_item( $node );
3088 case 'fromClause':
3089 // FROM DUAL is MySQL-specific syntax that means "FROM no tables"
3090 // and it is equivalent to omitting the FROM clause entirely.
3091 if ( $node->has_child_token( WP_MySQL_Lexer::DUAL_SYMBOL ) ) {
3092 return null;
3093 }
3094 return $this->translate_sequence( $node->get_children() );
3095 case 'insertUpdateList':
3096 // Translate "ON DUPLICATE KEY UPDATE" to "ON CONFLICT DO UPDATE SET".
3097 return sprintf(
3098 'ON CONFLICT DO UPDATE SET %s',
3099 $this->translate( $node->get_first_child_node( 'updateList' ) )
3100 );
3101 case 'simpleExpr':
3102 return $this->translate_simple_expr( $node );
3103 case 'predicateOperations':
3104 $token = $node->get_first_child_token();
3105 if ( WP_MySQL_Lexer::LIKE_SYMBOL === $token->id ) {
3106 return $this->translate_like( $node );
3107 } elseif ( WP_MySQL_Lexer::REGEXP_SYMBOL === $token->id ) {
3108 return $this->translate_regexp_functions( $node );
3109 }
3110 return $this->translate_sequence( $node->get_children() );
3111 case 'runtimeFunctionCall':
3112 return $this->translate_runtime_function_call( $node );
3113 case 'functionCall':
3114 return $this->translate_function_call( $node );
3115 case 'systemVariable':
3116 $var_ident_type = $node->get_first_child_node( 'varIdentType' );
3117 $type_token = $var_ident_type ? $var_ident_type->get_first_child_token() : null;
3118 $original_name = $this->unquote_sqlite_identifier(
3119 $this->translate( $node->get_first_child_node( 'textOrIdentifier' ) )
3120 );
3121
3122 $name = strtolower( $original_name );
3123 $type = $type_token ? $type_token->id : WP_MySQL_Lexer::SESSION_SYMBOL;
3124 if ( 'sql_mode' === $name ) {
3125 $value = implode( ',', $this->active_sql_modes );
3126 } elseif ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) {
3127 $value = $this->session_system_variables[ $name ] ?? null;
3128 } else {
3129 // When we have no value, it's reasonable to use NULL.
3130 $value = null;
3131 }
3132
3133 // @TODO: Emulate more system variables, or use reasonable defaults.
3134 // See: https://dev.mysql.com/doc/refman/8.4/en/server-system-variable-reference.html
3135 // See: https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html
3136 if ( null === $value ) {
3137 return 'NULL';
3138 }
3139 if ( is_string( $value ) ) {
3140 return $this->connection->quote( $value );
3141 }
3142 return (string) $value;
3143 case 'userVariable':
3144 $name = $this->unquote_sqlite_identifier( $this->translate( $node->get_first_child() ) );
3145 $name = strtolower( substr( $name, 1 ) ); // Remove '@', normalize case.
3146 $value = $this->user_variables[ $name ] ?? null;
3147 if ( null === $value ) {
3148 return 'NULL';
3149 }
3150 if ( is_string( $value ) ) {
3151 return $this->connection->quote( $value );
3152 }
3153 return (string) $value;
3154 case 'castType':
3155 // Translate "CAST(... AS BINARY)" to "CAST(... AS BLOB)".
3156 if ( $node->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) {
3157 return 'BLOB';
3158 }
3159 return $this->translate_sequence( $node->get_children() );
3160 case 'defaultCollation':
3161 // @TODO: Check and save in information schema.
3162 return null;
3163 case 'duplicateAsQueryExpression':
3164 // @TODO: How to handle IGNORE/REPLACE?
3165
3166 // The "AS" keyword is optional in MySQL, but required in SQLite.
3167 return 'AS ' . $this->translate( $node->get_first_child_node() );
3168 case 'indexHint':
3169 case 'indexHintList':
3170 return null;
3171 case 'lockingClause':
3172 // SQLite doesn't support locking clauses (SELECT ... FOR UPDATE).
3173 // They are not needed in SQLite due to the database file locking.
3174 return null;
3175 default:
3176 return $this->translate_sequence( $node->get_children() );
3177 }
3178 }
3179
3180 /**
3181 * Translate a MySQL token to SQLite.
3182 *
3183 * @param WP_MySQL_Token $token The MySQL token to translate.
3184 * @return string|null The translated value.
3185 */
3186 private function translate_token( WP_MySQL_Token $token ): ?string {
3187 switch ( $token->id ) {
3188 case WP_MySQL_Lexer::EOF:
3189 return null;
3190 case WP_MySQL_Lexer::BIN_NUMBER:
3191 /*
3192 * There are no binary literals in SQLite. We need to convert all
3193 * MySQL binary string values to HEX strings in SQLite (x'...').
3194 */
3195 $value = $token->get_value();
3196 if ( '0' === $value[0] ) {
3197 // 0b...
3198 $value = substr( $value, 2 );
3199 } else {
3200 // b'...' or B'...'
3201 $value = substr( $value, 2, -1 );
3202 }
3203
3204 // Convert the binary string to HEX.
3205 $hex = base_convert( $value, 2, 16 );
3206
3207 /*
3208 * The "base_convert()" function doesn't add or preserve padding.
3209 * Let's compute how many bytes we expect and pad the HEX value
3210 * to full bytes (SQLite requires HEX strings of even length).
3211 */
3212 $byte_count = (int) ceil( strlen( $value ) / 8 );
3213 $hex = str_pad( $hex, $byte_count * 2, '0', STR_PAD_LEFT );
3214 return sprintf( "x'%s'", $hex );
3215 case WP_MySQL_Lexer::HEX_NUMBER:
3216 /*
3217 * In MySQL, "0x" prefixed values represent binary literal values,
3218 * while in SQLite, that would be a hexadecimal number. Therefore,
3219 * we need to convert the 0x... syntax to x'...'.
3220 */
3221 $value = $token->get_value();
3222 if ( '0' === $value[0] && 'x' === $value[1] ) {
3223 return sprintf( "x'%s'", substr( $value, 2 ) );
3224 }
3225 return $value;
3226 case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL:
3227 return 'AUTOINCREMENT';
3228 case WP_MySQL_Lexer::BINARY_SYMBOL:
3229 /*
3230 * There is no "BINARY expr" equivalent in SQLite. We look for the
3231 * keyword from a higher level to respect it in particular cases
3232 * (REGEXP, LIKE, etc.) and then remove it from the output here.
3233 */
3234 return null;
3235 case WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL:
3236 /*
3237 * The "SQL_CALC_FOUND_ROWS" keyword is implemented in the select
3238 * statement translation and then removed from the output here.
3239 */
3240 return null;
3241 default:
3242 return $token->get_value();
3243 }
3244 }
3245
3246 /**
3247 * Translate a sequence of MySQL AST nodes to SQLite.
3248 *
3249 * @param array<WP_Parser_Node|WP_MySQL_Token> $nodes The MySQL token to translate.
3250 * @param string $separator The separator to use between fragments.
3251 * @return string|null The translated value.
3252 * @throws WP_SQLite_Driver_Exception When the translation fails.
3253 */
3254 private function translate_sequence( array $nodes, string $separator = ' ' ): ?string {
3255 $parts = array();
3256 foreach ( $nodes as $node ) {
3257 if ( null === $node ) {
3258 continue;
3259 }
3260
3261 $translated = $this->translate( $node );
3262 if ( null === $translated ) {
3263 continue;
3264 }
3265 $parts[] = $translated;
3266 }
3267 if ( 0 === count( $parts ) ) {
3268 return null;
3269 }
3270 return implode( $separator, $parts );
3271 }
3272
3273 /**
3274 * Translate a MySQL string literal to SQLite.
3275 *
3276 * @param WP_Parser_Node $node The "textStringLiteral" AST node.
3277 * @return string The translated value.
3278 */
3279 private function translate_string_literal( WP_Parser_Node $node ): string {
3280 $token = $node->get_first_child_token();
3281 $value = $token->get_value();
3282
3283 /*
3284 * 5. Translate datetime literals.
3285 *
3286 * Process only strings that could possibly represent a datetime
3287 * literal ("YYYY-MM-DDTHH:MM:SS", "YYYY-MM-DDTHH:MM:SSZ", etc.).
3288 */
3289 if ( strlen( $value ) >= 19 && is_numeric( $value[0] ) ) {
3290 $value = $this->translate_datetime_literal( $value );
3291 }
3292
3293 /*
3294 * 6. Handle null characters.
3295 *
3296 * SQLite doesn't fully support null characters (\u0000) in strings.
3297 * However, it can store them and read them, with some limitations.
3298 *
3299 * In PHP, null bytes are often produced by the serialize() function.
3300 * Removing them would damage the serialized data.
3301 *
3302 * There is no way to store null bytes using a string literal, so we
3303 * need to split the string and concatenate null bytes with its parts.
3304 * This will convert literals will null bytes to expressions.
3305 *
3306 * Alternatively, we could replace string literals with parameters and
3307 * pass them using prepared statements. However, that's not universally
3308 * applicable for all string literals (e.g., in default column values).
3309 *
3310 * See:
3311 * https://www.sqlite.org/nulinstr.html
3312 */
3313 $parts = array();
3314 foreach ( explode( "\0", $value ) as $segment ) {
3315 // Escape and quote each segment.
3316 $parts[] = "'" . str_replace( "'", "''", $segment ) . "'";
3317 }
3318 if ( count( $parts ) > 1 ) {
3319 return '(' . implode( ' || CHAR(0) || ', $parts ) . ')';
3320 }
3321 return $parts[0];
3322 }
3323
3324 /**
3325 * Translate a MySQL pure identifier to SQLite.
3326 *
3327 * @param WP_Parser_Node $node The "pureIdentifier" AST node.
3328 * @return string The translated value.
3329 */
3330 private function translate_pure_identifier( WP_Parser_Node $node ): string {
3331 $token = $node->get_first_child_token();
3332 $value = $token->get_value();
3333
3334 if ( str_starts_with( $value, self::RESERVED_PREFIX ) ) {
3335 throw $this->new_driver_exception(
3336 sprintf(
3337 "Invalid identifier '%s', prefix '%s' is reserved",
3338 $value,
3339 self::RESERVED_PREFIX
3340 )
3341 );
3342 }
3343
3344 return '`' . str_replace( '`', '``', $value ) . '`';
3345 }
3346
3347 /**
3348 * Translate a qualified MySQL identifier to SQLite.
3349 *
3350 * The identifier can be composed of 1 to 3 parts (schema, object, child).
3351 *
3352 * @param WP_Parser_Node|null $schema_node An identifier node representing a schema name (database).
3353 * @param WP_Parser_Node|null $object_node An identifier node representing a database-level object name
3354 * (table, view, procedure, trigger, etc.).
3355 * @param WP_Parser_Node|null $child_node An identifier node representing an object child name (column, index, etc.).
3356 * @return string The translated value.
3357 * @throws WP_SQLite_Driver_Exception When the translation fails.
3358 */
3359 private function translate_qualified_identifier(
3360 ?WP_Parser_Node $schema_node,
3361 ?WP_Parser_Node $object_node = null,
3362 ?WP_Parser_Node $child_node = null
3363 ): string {
3364 $parts = array();
3365
3366 // Database name.
3367 $is_information_schema = 'information_schema' === $this->db_name;
3368 if ( null !== $schema_node ) {
3369 $schema_name = $this->unquote_sqlite_identifier(
3370 $this->translate_sequence( $schema_node->get_children() )
3371 );
3372 if ( 'information_schema' === strtolower( $schema_name ) ) {
3373 $is_information_schema = true;
3374 } elseif ( $this->db_name === $schema_name ) {
3375 $is_information_schema = false;
3376 } else {
3377 throw $this->new_not_supported_exception(
3378 sprintf(
3379 "can't use schema '%s', only '%s' and 'information_schema' are supported",
3380 $schema_name,
3381 $this->db_name
3382 )
3383 );
3384 }
3385 }
3386
3387 /*
3388 * Make the 'information_schema' database read-only.
3389 *
3390 * This basic approach is rather restrictive, as it blocks the usage
3391 * of information schema tables in all data-modifying statements.
3392 *
3393 * Some of these statements can be valid, when the schema is only read:
3394 * DELETE t FROM t JOIN information_schema.columns c ON ...
3395 *
3396 * If needed, a more granular approach can be implemented in the future.
3397 */
3398 if ( true === $is_information_schema && false === $this->is_readonly ) {
3399 throw $this->new_driver_exception(
3400 "Access denied for user 'sqlite'@'%' to database 'information_schema'",
3401 '42000'
3402 );
3403 }
3404
3405 // Database-level object name (table, view, procedure, trigger, etc.).
3406 if ( null !== $object_node ) {
3407 if ( $is_information_schema ) {
3408 $object_name = $this->unquote_sqlite_identifier(
3409 $this->translate_sequence( $object_node->get_children() )
3410 );
3411 $parts[] = $this->information_schema_builder->get_table_name( false, $object_name );
3412 } else {
3413 $parts[] = $this->translate( $object_node );
3414 }
3415 }
3416
3417 // Object child name (column, index, etc.).
3418 if ( null !== $child_node ) {
3419 $parts[] = $this->translate( $child_node );
3420 }
3421
3422 return implode( '.', $parts );
3423 }
3424
3425 /**
3426 * Translate a MySQL query expression to SQLite.
3427 *
3428 * @param WP_Parser_Node $node The "queryExpression" AST node.
3429 * @return string The translated value.
3430 * @throws WP_SQLite_Driver_Exception When the translation fails.
3431 */
3432 private function translate_query_expression( WP_Parser_Node $node ): string {
3433 // Get the query expression subnode under which we need to look for the
3434 // SELECT item list node. This prevents searching under "withClause".
3435 $query_expr_main = (
3436 $node->get_first_child_node( 'queryExpressionBody' )
3437 ?? $node->get_first_child_node( 'queryExpressionParens' )
3438 );
3439 $query_term = $query_expr_main->get_first_descendant_node( 'queryTerm' );
3440 $has_union = $query_expr_main->has_child_token( WP_MySQL_Lexer::UNION_SYMBOL );
3441 $has_except = $query_expr_main->has_child_token( WP_MySQL_Lexer::EXCEPT_SYMBOL );
3442 $has_intersect = $query_term->has_child_token( WP_MySQL_Lexer::INTERSECT_SYMBOL );
3443
3444 /*
3445 * When the ORDER BY clause is present, we need to disambiguate the item
3446 * list and make sure they don't cause an "ambiguous column name" error.
3447 *
3448 * @see WP_SQLite_Driver::disambiguate_item()
3449 */
3450 $disambiguated_order_list = array();
3451 $order_clause = $node->get_first_child_node( 'orderClause' );
3452 if ( $order_clause && ! $has_union && ! $has_except && ! $has_intersect ) {
3453 /*
3454 * [GRAMMAR]
3455 * queryExpression: (withClause)? (
3456 * queryExpressionBody orderClause? limitClause?
3457 * | queryExpressionParens orderClause? limitClause?
3458 * ) (procedureAnalyseClause)?
3459 */
3460
3461 // Create the SELECT item disambiguation map.
3462 $select_item_list = $query_expr_main->get_first_descendant_node( 'selectItemList' );
3463 $disambiguation_map = $this->create_select_item_disambiguation_map( $select_item_list );
3464
3465 // For each "orderList" item, search for a matching SELECT item.
3466 $disambiguated_order_list = array();
3467 $order_list = $order_clause->get_first_child_node( 'orderList' );
3468 foreach ( $order_list->get_child_nodes() as $order_item ) {
3469 /*
3470 * [GRAMMAR]
3471 * orderExpression: expr direction?
3472 */
3473 $order_expr = $order_item->get_first_child_node( 'expr' );
3474 $order_direction = $order_item->get_first_child_node( 'direction' );
3475 $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $order_expr );
3476
3477 $disambiguated_order_list[] = sprintf(
3478 '%s%s',
3479 $disambiguated_item ?? $this->translate( $order_expr ),
3480 null !== $order_direction ? ( ' ' . $this->translate( $order_direction ) ) : ''
3481 );
3482 }
3483
3484 // Translate the query expression, replacing the ORDER BY list with
3485 // the one that was constructed using the disambiguation algorithm.
3486 $parts = array();
3487 foreach ( $node->get_children() as $child ) {
3488 if ( $child instanceof WP_Parser_Node && 'orderClause' === $child->rule_name ) {
3489 $parts[] = 'ORDER BY ' . implode( ', ', $disambiguated_order_list );
3490 } else {
3491 $parts[] = $this->translate( $child );
3492 }
3493 }
3494 return implode( ' ', $parts );
3495 }
3496
3497 return $this->translate_sequence( $node->get_children() );
3498 }
3499
3500 /**
3501 * Translate a MySQL query specification node to SQLite.
3502 *
3503 * @param WP_Parser_Node $node The "querySpecification" AST node.
3504 * @return string The translated value.
3505 * @throws WP_SQLite_Driver_Exception When the translation fails.
3506 * @return string|null
3507 */
3508 private function translate_query_specification( WP_Parser_Node $node ): string {
3509 $group_by = $node->get_first_child_node( 'groupByClause' );
3510 $having = $node->get_first_child_node( 'havingClause' );
3511
3512 /*
3513 * When the GROUP BY or HAVING clause is present, we need to disambiguate
3514 * the items to ensure they don't cause an "ambiguous column name" error.
3515 *
3516 * @see WP_SQLite_Driver::disambiguate_item()
3517 */
3518 $group_by_clause = null;
3519 $having_clause = null;
3520 if ( $group_by || $having ) {
3521 // Build a SELECT list disambiguation map for both GROUP BY and HAVING.
3522 $select_item_list = $node->get_first_child_node( 'selectItemList' );
3523 $disambiguation_map = $this->create_select_item_disambiguation_map( $select_item_list );
3524
3525 // Disambiguate the GROUP BY clause column references.
3526 $disambiguated_group_by_list = array();
3527 if ( $group_by ) {
3528 /*
3529 * [GRAMMAR]
3530 * groupByClause: GROUP_SYMBOL BY_SYMBOL orderList olapOption?
3531 */
3532 $group_by_list = $group_by->get_first_child_node( 'orderList' );
3533 foreach ( $group_by_list->get_child_nodes() as $group_by_item ) {
3534 $group_by_expr = $group_by_item->get_first_child_node( 'expr' );
3535 $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $group_by_expr );
3536 $disambiguated_group_by_list[] = $disambiguated_item ?? $this->translate( $group_by_expr );
3537 }
3538 $group_by_clause = 'GROUP BY ' . implode( ', ', $disambiguated_group_by_list );
3539 }
3540
3541 // Disambiguate the HAVING clause column references.
3542 $disambiguated_having_list = array();
3543 if ( $having ) {
3544 /*
3545 * [GRAMMAR]
3546 * havingClause: HAVING_SYMBOL expr
3547 */
3548 $having_expr = $having->get_first_child_node();
3549 $having_expr_children = $having_expr->get_children();
3550 foreach ( $having_expr_children as $having_item ) {
3551 if ( $having_item instanceof WP_Parser_Node ) {
3552 $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $having_item );
3553 $disambiguated_having_list[] = $disambiguated_item ?? $this->translate( $having_item );
3554 } else {
3555 $disambiguated_having_list[] = $this->translate( $having_item );
3556 }
3557 }
3558 $having_clause = 'HAVING ' . implode( ' ', $disambiguated_having_list );
3559 }
3560
3561 // Translate the query specification, replacing the ORDER BY/HAVING
3562 // items with the ones that were disambiguated using the SELECT list.
3563 $parts = array();
3564 foreach ( $node->get_children() as $child ) {
3565 if ( $child instanceof WP_Parser_Node && 'groupByClause' === $child->rule_name ) {
3566 $parts[] = $group_by_clause;
3567 } elseif ( $child instanceof WP_Parser_Node && 'havingClause' === $child->rule_name ) {
3568 // SQLite doesn't allow using the "HAVING" clause without "GROUP BY".
3569 // In such cases, let's prefix the "HAVING" clause with "GROUP BY 1".
3570 if ( ! $group_by ) {
3571 $parts[] = 'GROUP BY 1';
3572 }
3573 $parts[] = $having_clause;
3574 } else {
3575 $part = $this->translate( $child );
3576 if ( null !== $part ) {
3577 $parts[] = $part;
3578 }
3579 }
3580 }
3581 return implode( ' ', $parts );
3582 }
3583 return $this->translate_sequence( $node->get_children() );
3584 }
3585
3586 /**
3587 * Translate a MySQL simple expression to SQLite.
3588 *
3589 * @param WP_Parser_Node $node The "simpleExpr" AST node.
3590 * @return string The translated value.
3591 * @throws WP_SQLite_Driver_Exception When the translation fails.
3592 */
3593 private function translate_simple_expr( WP_Parser_Node $node ): string {
3594 $token = $node->get_first_child_token();
3595
3596 // Translate "VALUES(col)" to "excluded.col" in ON DUPLICATE KEY UPDATE.
3597 if ( null !== $token && WP_MySQL_Lexer::VALUES_SYMBOL === $token->id ) {
3598 return sprintf(
3599 '`excluded`.%s',
3600 $this->translate( $node->get_first_child_node( 'simpleIdentifier' ) )
3601 );
3602 }
3603
3604 return $this->translate_sequence( $node->get_children() );
3605 }
3606
3607 /**
3608 * Translate a MySQL LIKE expression to SQLite.
3609 *
3610 * @param WP_Parser_Node $node The "predicateOperations" AST node.
3611 * @return string The translated value.
3612 * @throws WP_SQLite_Driver_Exception When the translation fails.
3613 */
3614 private function translate_like( WP_Parser_Node $node ): string {
3615 $tokens = $node->get_descendant_tokens();
3616 $is_binary = isset( $tokens[1] ) && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[1]->id;
3617
3618 if ( true === $is_binary ) {
3619 $children = $node->get_children();
3620 return sprintf(
3621 'GLOB _helper_like_to_glob_pattern(%s)',
3622 $this->translate( $children[1] )
3623 );
3624 }
3625
3626 /*
3627 * @TODO: Implement the ESCAPE '...' clause.
3628 */
3629
3630 /*
3631 * @TODO: Implement more correct LIKE behavior.
3632 *
3633 * While SQLite supports the LIKE operator, it seems to differ from the
3634 * MySQL behavior in some ways:
3635 *
3636 * 1. In SQLite, LIKE is case-insensitive only for ASCII characters
3637 * ('a' LIKE 'A' is TRUE but 'æ' LIKE 'Æ' is FALSE)
3638 * 2. In MySQL, LIKE interprets some escape sequences. See the contents
3639 * of the "_helper_like_to_glob_pattern" function.
3640 *
3641 * We'll probably need to overload the like() function:
3642 * https://www.sqlite.org/lang_corefunc.html#like
3643 */
3644 $statement = $this->translate_sequence( $node->get_children() );
3645 if ( $this->is_sql_mode_active( 'NO_BACKSLASH_ESCAPES' ) ) {
3646 return $statement;
3647 }
3648 return $statement . " ESCAPE '\\'";
3649 }
3650
3651 /**
3652 * Translate MySQL REGEXP expression to SQLite.
3653 *
3654 * @param WP_Parser_Node $node The "predicateOperations" AST node.
3655 * @return string The translated value.
3656 * @throws WP_SQLite_Driver_Exception When the translation fails.
3657 */
3658 private function translate_regexp_functions( WP_Parser_Node $node ): string {
3659 $tokens = $node->get_descendant_tokens();
3660 $is_binary = isset( $tokens[1] ) && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[1]->id;
3661
3662 /*
3663 * If the query says REGEXP BINARY, the comparison is byte-by-byte
3664 * and letter casing matters – lowercase and uppercase letters are
3665 * represented using different byte codes.
3666 *
3667 * The REGEXP function can't be easily made to accept two
3668 * parameters, so we'll have to use a hack to get around this.
3669 *
3670 * If the first character of the pattern is a null byte, we'll
3671 * remove it and make the comparison case-sensitive. This should
3672 * be reasonably safe since PHP does not allow null bytes in
3673 * regular expressions anyway.
3674 */
3675 if ( true === $is_binary ) {
3676 return 'REGEXP CHAR(0) || ' . $this->translate( $node->get_first_child_node() );
3677 }
3678 return 'REGEXP ' . $this->translate( $node->get_first_child_node() );
3679 }
3680
3681 /**
3682 * Translate a MySQL runtime function call to SQLite.
3683 *
3684 * @param WP_Parser_Node $node The "runtimeFunctionCall" AST node.
3685 * @return string The translated value.
3686 * @throws WP_SQLite_Driver_Exception When the translation fails.
3687 */
3688 private function translate_runtime_function_call( WP_Parser_Node $node ): string {
3689 $child = $node->get_first_child();
3690 if ( $child instanceof WP_Parser_Node ) {
3691 return $this->translate( $child );
3692 }
3693
3694 switch ( $child->id ) {
3695 case WP_MySQL_Lexer::DATABASE_SYMBOL:
3696 return $this->connection->quote( $this->db_name );
3697 case WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL:
3698 case WP_MySQL_Lexer::NOW_SYMBOL:
3699 /*
3700 * 1) SQLite doesn't support CURRENT_TIMESTAMP() with parentheses.
3701 * 2) In MySQL, CURRENT_TIMESTAMP and CURRENT_TIMESTAMP() are an
3702 * alias of NOW(). In SQLite, there is no NOW() function.
3703 */
3704 return 'CURRENT_TIMESTAMP';
3705 case WP_MySQL_Lexer::DATE_ADD_SYMBOL:
3706 case WP_MySQL_Lexer::DATE_SUB_SYMBOL:
3707 $nodes = $node->get_child_nodes();
3708 $value = $this->translate( $nodes[1] );
3709 $unit = $this->translate( $nodes[2] );
3710 if ( 'WEEK' === $unit ) {
3711 $unit = 'DAY';
3712 $value = 7 * $value;
3713 }
3714 return sprintf(
3715 "DATETIME(%s, '%s' || %s || ' %s')",
3716 $this->translate( $nodes[0] ),
3717 WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id ? '-' : '+',
3718 $value,
3719 $unit
3720 );
3721 case WP_MySQL_Lexer::LEFT_SYMBOL:
3722 $nodes = $node->get_child_nodes();
3723 return sprintf(
3724 'SUBSTRING(%s, 1, %s)',
3725 $this->translate( $nodes[0] ),
3726 $this->translate( $nodes[1] )
3727 );
3728 default:
3729 return $this->translate_sequence( $node->get_children() );
3730 }
3731 }
3732
3733 /**
3734 * Translate a MySQL function call to SQLite.
3735 *
3736 * @param WP_Parser_Node $node The "functionCall" AST node.
3737 * @return string The translated value.
3738 * @throws WP_SQLite_Driver_Exception When the translation fails.
3739 */
3740 private function translate_function_call( WP_Parser_Node $node ): string {
3741 $nodes = $node->get_child_nodes();
3742 $name = strtoupper(
3743 $this->unquote_sqlite_identifier( $this->translate( $nodes[0] ) )
3744 );
3745
3746 $args = array();
3747 if ( isset( $nodes[1] ) ) {
3748 foreach ( $nodes[1]->get_child_nodes() as $child ) {
3749 $args[] = $this->translate( $child );
3750 }
3751 }
3752
3753 switch ( $name ) {
3754 case 'DATE_FORMAT':
3755 list ( $date, $mysql_format ) = $args;
3756
3757 $format = strtr( $mysql_format, self::MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAP );
3758 if ( ! $format ) {
3759 throw $this->new_driver_exception(
3760 sprintf(
3761 'Could not translate a DATE_FORMAT() format to STRFTIME format (%s)',
3762 $mysql_format
3763 )
3764 );
3765 }
3766
3767 /*
3768 * MySQL supports comparing strings and floats, e.g.
3769 *
3770 * > SELECT '00.42' = 0.4200
3771 * 1
3772 *
3773 * SQLite does not support that. At the same time,
3774 * WordPress likes to filter dates by comparing numeric
3775 * outputs of DATE_FORMAT() to floats, e.g.:
3776 *
3777 * -- Filter by hour and minutes
3778 * DATE_FORMAT(
3779 * STR_TO_DATE('2014-10-21 00:42:29', '%Y-%m-%d %H:%i:%s'),
3780 * '%H.%i'
3781 * ) = 0.4200;
3782 *
3783 * Let's cast the STRFTIME() output to a float if
3784 * the date format is typically used for string
3785 * to float comparisons.
3786 *
3787 * In the future, let's update WordPress to avoid comparing
3788 * strings and floats.
3789 */
3790 $cast_to_float = "'%H.%i'" === $mysql_format;
3791 if ( true === $cast_to_float ) {
3792 return sprintf( 'CAST(STRFTIME(%s, %s) AS FLOAT)', $format, $date );
3793 }
3794 return sprintf( 'STRFTIME(%s, %s)', $format, $date );
3795 case 'CHAR_LENGTH':
3796 // @TODO LENGTH and CHAR_LENGTH aren't always the same in MySQL for utf8 characters.
3797 return 'LENGTH(' . $args[0] . ')';
3798 case 'CONCAT':
3799 return '(' . implode( ' || ', $args ) . ')';
3800 case 'FOUND_ROWS':
3801 $found_rows = $this->last_sql_calc_found_rows;
3802 if ( null === $found_rows && is_array( $this->last_result ) ) {
3803 $found_rows = count( $this->last_result );
3804 }
3805 return $found_rows;
3806 default:
3807 return $this->translate_sequence( $node->get_children() );
3808 }
3809 }
3810
3811 /**
3812 * Translate a MySQL datetime literal to SQLite.
3813 *
3814 * @param string $value The MySQL datetime literal.
3815 * @return string The translated value.
3816 */
3817 private function translate_datetime_literal( string $value ): string {
3818 /*
3819 * The code below converts the date format to one preferred by SQLite.
3820 *
3821 * MySQL accepts ISO 8601 date strings: 'YYYY-MM-DDTHH:MM:SSZ'
3822 * SQLite prefers a slightly different format: 'YYYY-MM-DD HH:MM:SS'
3823 *
3824 * SQLite date and time functions can understand the ISO 8601 notation, but
3825 * lookups don't. To keep the lookups working, we need to store all dates
3826 * in UTC without the "T" and "Z" characters.
3827 *
3828 * Caveat: It will adjust every string that matches the pattern, not just dates.
3829 *
3830 * In theory, we could only adjust semantic dates, e.g. the data inserted
3831 * to a date column or compared against a date column.
3832 *
3833 * In practice, this is hard because dates are just text – SQLite has no separate
3834 * datetime field. We'd need to cache the MySQL data type from the original
3835 * CREATE TABLE query and then keep refreshing the cache after each ALTER TABLE query.
3836 *
3837 * That's a lot of complexity that's perhaps not worth it. Let's just convert
3838 * everything for now. The regexp assumes "Z" is always at the end of the string,
3839 * which is true in the unit test suite, but there could also be a timezone offset
3840 * like "+00:00" or "+01:00". We could add support for that later if needed.
3841 */
3842 if ( 1 === preg_match( '/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})Z$/', $value, $matches ) ) {
3843 $value = $matches[1] . ' ' . $matches[2];
3844 }
3845
3846 /*
3847 * Mimic MySQL's behavior and truncate invalid dates.
3848 *
3849 * "2020-12-41 14:15:27" becomes "0000-00-00 00:00:00"
3850 *
3851 * WARNING: We have no idea whether the truncated value should
3852 * be treated as a date in the first place.
3853 * In SQLite dates are just strings. This could be a perfectly
3854 * valid string that just happens to contain a date-like value.
3855 *
3856 * At the same time, WordPress seems to rely on MySQL's behavior
3857 * and even tests for it in Tests_Post_wpInsertPost::test_insert_empty_post_date.
3858 * Let's truncate the dates for now.
3859 *
3860 * In the future, let's update WordPress to do its own date validation
3861 * and stop relying on this MySQL feature,
3862 */
3863 if ( 1 === preg_match( '/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) {
3864 /*
3865 * Calling strtotime("0000-00-00 00:00:00") in 32-bit environments triggers
3866 * an "out of integer range" warning – let's avoid that call for the popular
3867 * case of "zero" dates.
3868 */
3869 if ( '0000-00-00 00:00:00' !== $value && false === strtotime( $value ) ) {
3870 $value = '0000-00-00 00:00:00';
3871 }
3872 }
3873 return $value;
3874 }
3875
3876 /**
3877 * Translate a select item to SQLite.
3878 *
3879 * In some cases, an explicit alias will be added to the select item, so that
3880 * the returned column name is always the same as it would be in MySQL.
3881 *
3882 * @param WP_Parser_Node $node The "selectItem" AST node.
3883 * @return string The translated expression.
3884 */
3885 public function translate_select_item( WP_Parser_Node $node ): string {
3886 /*
3887 * First, let's translate the select item subtree.
3888 *
3889 * [GRAMMAR]
3890 * selectItem: tableWild | (expr selectAlias?)
3891 */
3892 $item = $this->translate_sequence( $node->get_children() );
3893
3894 // A table wildcard (e.g., "SELECT *, t.*, ...") never has an alias.
3895 if ( $node->has_child_node( 'tableWild' ) ) {
3896 return $item;
3897 }
3898
3899 // When an explicit alias is provided, we can use it as is.
3900 $alias = $node->get_first_child_node( 'selectAlias' );
3901 if ( $alias ) {
3902 return $item;
3903 }
3904
3905 /*
3906 * When the select item contains only a column definition, we need to use
3907 * it without change, so that the returned column name reflects the real
3908 * column name in all cases, including when using a fully qualified name.
3909 *
3910 * For example, for "SELECT t.id", the column name in the result set will
3911 * only be "id", not "t.id", as it may appear based on the original query.
3912 *
3913 * In this case, SQLite uses the same logic as MySQL, so using the value
3914 * as is without adding an explicit alias will produce the correct result.
3915 */
3916 $column_ref = $node->get_first_descendant_node( 'columnRef' );
3917 $is_column_ref = $column_ref && $item === $this->translate( $column_ref );
3918 if ( $is_column_ref ) {
3919 return $item;
3920 }
3921
3922 /*
3923 * When the select item has no explicit alias, we need to ensure that the
3924 * returned column name is equivalent to what MySQL infers from the input.
3925 *
3926 * For example, if we translate "CONCAT('a', 'b')" to "('a' || 'b')", we
3927 * need to use the original "CONCAT('a', 'b')" string as the column name.
3928 * To achieve this, the select item will be translated as follows:
3929 *
3930 * SELECT CONCAT('a', 'b') -> SELECT ('a' || 'b') AS `CONCAT('a', 'b')`
3931 */
3932 $raw_alias = substr( $this->last_mysql_query, $node->get_start(), $node->get_length() );
3933 $alias = $this->quote_sqlite_identifier( $raw_alias );
3934 if ( $alias === $item || $raw_alias === $item ) {
3935 // For the simple case of selecting only columns ("SELECT id FROM t"),
3936 // let's avoid unnecessary aliases ("SELECT `id` AS `id` FROM t").
3937 return $item;
3938 }
3939 return sprintf( '%s AS %s', $item, $alias );
3940 }
3941
3942 /**
3943 * Recreate an existing table using data in the information schema.
3944 *
3945 * This is used for a generic support of ALTER TABLE queries, as well as
3946 * for some other statements like OPTIMIZE TABLE and REPAIR TABLE.
3947 *
3948 * See:
3949 * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
3950 *
3951 * @param bool $table_is_temporary Whether the table is temporary.
3952 * @param string $table_name The name of the table to recreate.
3953 * @param array $column_map Optional. A map of column names (old name -> new name)
3954 * to use when copying data from the original table.
3955 * When not provided, all columns are copied without renaming.
3956 * @throws WP_SQLite_Driver_Exception
3957 */
3958 private function recreate_table_from_information_schema(
3959 bool $table_is_temporary,
3960 string $table_name,
3961 ?array $column_map = null
3962 ): void {
3963 if ( null === $column_map ) {
3964 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
3965 $column_names = $this->execute_sqlite_query(
3966 sprintf(
3967 'SELECT COLUMN_NAME FROM %s WHERE table_schema = ? AND table_name = ?',
3968 $this->quote_sqlite_identifier( $columns_table )
3969 ),
3970 array( $this->db_name, $table_name )
3971 )->fetchAll( PDO::FETCH_COLUMN );
3972 $column_map = array_combine( $column_names, $column_names );
3973 }
3974
3975 // Preserve ROWIDs.
3976 // This also addresses a special case when all original columns are dropped
3977 // and there is nothing to copy. We'll always have at least the ROWID column.
3978 $column_map = array( 'rowid' => 'rowid' ) + $column_map;
3979
3980 /*
3981 * See:
3982 * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
3983 */
3984
3985 // 1. If foreign key constraints are enabled, disable them.
3986 $pragma_foreign_keys = $this->execute_sqlite_query( 'PRAGMA foreign_keys' )->fetchColumn();
3987 $this->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' );
3988
3989 // 2. Create a new table with the new schema.
3990 $tmp_table_name = self::RESERVED_PREFIX . "tmp_{$table_name}_" . uniqid();
3991 $quoted_table_name = $this->quote_sqlite_identifier( $table_name );
3992 $quoted_tmp_table_name = $this->quote_sqlite_identifier( $tmp_table_name );
3993 $queries = $this->get_sqlite_create_table_statement( $table_is_temporary, $table_name, $tmp_table_name );
3994 $create_table_query = $queries[0];
3995 $constraint_queries = array_slice( $queries, 1 );
3996 $this->execute_sqlite_query( $create_table_query );
3997
3998 // 3. Copy data from the original table to the new table.
3999 $this->execute_sqlite_query(
4000 sprintf(
4001 'INSERT INTO %s (%s) SELECT %s FROM %s',
4002 $quoted_tmp_table_name,
4003 implode(
4004 ', ',
4005 array_map( array( $this, 'quote_sqlite_identifier' ), $column_map )
4006 ),
4007 implode(
4008 ', ',
4009 array_map( array( $this, 'quote_sqlite_identifier' ), array_keys( $column_map ) )
4010 ),
4011 $quoted_table_name
4012 )
4013 );
4014
4015 // 4. Drop the original table.
4016 $this->execute_sqlite_query( sprintf( 'DROP TABLE %s', $quoted_table_name ) );
4017
4018 // 5. Rename the new table to the original table name.
4019 $this->execute_sqlite_query(
4020 sprintf(
4021 'ALTER TABLE %s RENAME TO %s',
4022 $quoted_tmp_table_name,
4023 $quoted_table_name
4024 )
4025 );
4026
4027 // 6. Reconstruct indexes, triggers, and views.
4028 foreach ( $constraint_queries as $query ) {
4029 $this->execute_sqlite_query( $query );
4030 }
4031
4032 // 7. If foreign key constraints were enabled, verify and enable them.
4033 if ( '1' === $pragma_foreign_keys ) {
4034 $this->execute_sqlite_query( 'PRAGMA foreign_key_check' );
4035 $this->execute_sqlite_query( 'PRAGMA foreign_keys = ON' );
4036 }
4037
4038 // @TODO: Triggers and views.
4039 }
4040
4041 /**
4042 * Translate a MySQL SHOW LIKE ... or SHOW WHERE ... condition to SQLite.
4043 *
4044 * @param WP_Parser_Node $like_or_where The "likeOrWhere" AST node.
4045 * @param string $like_column The column name to use in the LIKE clause ("table_name", "column_name", etc.).
4046 * @return string The translated value.
4047 * @throws WP_SQLite_Driver_Exception When the translation fails.
4048 */
4049 private function translate_show_like_or_where_condition( WP_Parser_Node $like_or_where, string $like_column ): string {
4050 $like_clause = $like_or_where->get_first_child_node( 'likeClause' );
4051 if ( null !== $like_clause ) {
4052 $value = $this->translate(
4053 $like_clause->get_first_child_node( 'textStringLiteral' )
4054 );
4055 return sprintf(
4056 "AND %s LIKE %s ESCAPE '\\'",
4057 $this->quote_sqlite_identifier( $like_column ),
4058 $value
4059 );
4060 }
4061
4062 $where_clause = $like_or_where->get_first_child_node( 'whereClause' );
4063 if ( null !== $where_clause ) {
4064 $value = $this->translate(
4065 $where_clause->get_first_child_node( 'expr' )
4066 );
4067 return sprintf( 'AND %s', $value );
4068 }
4069
4070 return '';
4071 }
4072
4073 /**
4074 * Translate INSERT or REPLACE statement body to SQLite, while emulating
4075 * the behavior of MySQL implicit default values in non-strict mode.
4076 *
4077 * Rewrites a statement body in the following form:
4078 * INSERT INTO table (optionally some columns) <select-or-values>
4079 * To a statement body with the following structure:
4080 * INSERT INTO table (all table columns)
4081 * SELECT <non-strict-mode-adjusted-values> FROM (<select-or-values>) WHERE true
4082 *
4083 * In MySQL, the behavior of INSERT and UPDATE statements depends on whether
4084 * the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
4085 *
4086 * By default, STRICT_TRANS_TABLES is enabled, which makes the InnoDB table
4087 * behavior correspond to the natural behavior of SQLite tables. However,
4088 * some applications, including WordPress, disable strict mode altogether.
4089 *
4090 * The strict SQL modes can be set per session, and can be changed at runtime.
4091 * In SQLite, we can emulate this using the knowledge of the table structure:
4092 * 1. Explicitly passed INSERT statement values are used without change.
4093 * 2. Values omitted from the INSERT statement are replaced with the column
4094 * DEFAULT or an IMPLICIT DEFAULT value based on their data type.
4095 *
4096 * Here's a summary of the strict vs. non-strict behaviors in MySQL:
4097 *
4098 * When STRICT_TRANS_TABLES or STRICT_ALL_TABLES is enabled:
4099 * 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
4100 * 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
4101 * 3. NOT NULL + NO DEFAULT: No value is rejected, NULL is rejected, DEFAULT is rejected.
4102 * 4. NOT NULL + DEFAULT: No value saves DEFAULT, NULL is rejected, DEFAULT saves DEFAULT.
4103 *
4104 * When STRICT_TRANS_TABLES and STRICT_ALL_TABLES are disabled:
4105 * 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
4106 * 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
4107 * 3. NOT NULL + NO DEFAULT: No value saves IMPLICIT DEFAULT.
4108 * NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
4109 * DEFAULT saves IMPLICIT DEFAULT.
4110 * 4. NOT NULL + DEFAULT: No value saves DEFAULT.
4111 * NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
4112 * DEFAULT saves DEFAULT.
4113 *
4114 * For more information about IMPLICIT DEFAULT values in MySQL, see:
4115 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
4116 *
4117 * @param string $table_name The name of the target table.
4118 * @param WP_Parser_Node $node The "insertQueryExpression" or "insertValues" AST node.
4119 * @return string The translated INSERT query body.
4120 */
4121 private function translate_insert_or_replace_body_in_non_strict_mode(
4122 string $table_name,
4123 WP_Parser_Node $node
4124 ): string {
4125 // 1. Get column metadata from information schema.
4126 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
4127 $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
4128 $columns = $this->execute_sqlite_query(
4129 '
4130 SELECT column_name, is_nullable, column_default, data_type, extra
4131 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
4132 WHERE table_schema = ?
4133 AND table_name = ?
4134 ORDER BY ordinal_position
4135 ',
4136 array( $this->db_name, $table_name )
4137 )->fetchAll( PDO::FETCH_ASSOC );
4138
4139 // 2. Get the list of fields explicitly defined in the INSERT statement.
4140 $insert_list = array();
4141 $fields_node = $node->get_first_child_node( 'fields' );
4142 if ( $fields_node ) {
4143 // This is the optional "INSERT INTO ... (field1, field2, ...)" list.
4144 foreach ( $fields_node->get_child_nodes() as $field ) {
4145 $insert_list[] = $this->unquote_sqlite_identifier( $this->translate( $field ) );
4146 }
4147 } else {
4148 // When no explicit field list is provided, all columns are required.
4149 foreach ( array_column( $columns, 'COLUMN_NAME' ) as $column_name ) {
4150 $insert_list[] = $column_name;
4151 }
4152 }
4153
4154 // 3. Filter out omitted columns that will get a value from the SQLite engine.
4155 // That is, nullable columns, columns with defaults, and generated columns.
4156 $columns = array_values(
4157 array_filter(
4158 $columns,
4159 function ( $column ) use ( $insert_list ) {
4160 $is_omitted = ! in_array( $column['COLUMN_NAME'], $insert_list, true );
4161 if ( ! $is_omitted ) {
4162 return true;
4163 }
4164 $is_nullable = 'YES' === $column['IS_NULLABLE'];
4165 $has_default = $column['COLUMN_DEFAULT'];
4166 $is_generated = str_contains( $column['EXTRA'], 'auto_increment' );
4167 return ! ( $is_nullable || $has_default || $is_generated );
4168 }
4169 )
4170 );
4171
4172 // 4. Get the list of column names returned by VALUES or SELECT clause.
4173 $select_list = array();
4174 if ( 'insertQueryExpression' === $node->rule_name ) {
4175 // When inserting from a SELECT query, we don't know the column names.
4176 // Let's wrap the query with a SELECT (...) LIMIT 0 to get obtain them.
4177 $expr = $node->get_first_child_node( 'queryExpressionOrParens' );
4178 $stmt = $this->execute_sqlite_query(
4179 'SELECT * FROM (' . $this->translate( $expr ) . ') LIMIT 1'
4180 );
4181 $stmt->execute();
4182
4183 for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
4184 $select_list[] = $stmt->getColumnMeta( $i )['name'];
4185 }
4186 } else {
4187 // When inserting from a VALUES list, SQLite uses "columnN" naming.
4188 foreach ( array_keys( $insert_list ) as $position ) {
4189 $select_list[] = 'column' . ( $position + 1 );
4190 }
4191 }
4192
4193 // 5. Compose a new INSERT field list with all columns from the table.
4194 $fragment = '(';
4195 foreach ( $columns as $i => $column ) {
4196 $fragment .= $i > 0 ? ', ' : '';
4197 $fragment .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
4198 }
4199 $fragment .= ')';
4200
4201 // 6. Compose a wrapper SELECT statement emulating IMPLICIT DEFAULT values.
4202 $fragment .= ' SELECT ';
4203 foreach ( $columns as $i => $column ) {
4204 $is_omitted = ! in_array( $column['COLUMN_NAME'], $insert_list, true );
4205 $fragment .= $i > 0 ? ', ' : '';
4206 if ( $is_omitted ) {
4207 /*
4208 * When a column is omitted from the INSERT list, we need to use
4209 * an IMPLICIT DEFAULT value. Note that at this point, all omitted
4210 * columns that will not get an implicit default are filtered out.
4211 * (That is, nullable, generated, and columns with true defaults.)
4212 */
4213 $default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null;
4214 $fragment .= null === $default ? 'NULL' : $this->connection->quote( $default );
4215 } else {
4216 // When a column value is included, we need to apply type casting.
4217 $position = array_search( $column['COLUMN_NAME'], $insert_list, true );
4218 $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] );
4219 $fragment .= sprintf(
4220 '%s AS %s',
4221 $this->cast_value_in_non_strict_mode( $column['DATA_TYPE'], $identifier ),
4222 $identifier
4223 );
4224 }
4225 }
4226
4227 // 6. Wrap the original insert VALUES or SELECT expression in a FROM clause.
4228 $values = 'insertFromConstructor' === $node->rule_name
4229 ? $node->get_first_child_node( 'insertValues' )
4230 : $node->get_first_child_node( 'queryExpressionOrParens' );
4231
4232 /*
4233 * The "WHERE true" suffix is used to avoid parsing ambiguity in SQLite.
4234 * When an "ON CONFLICT" clause is used and there is no "WHERE", SQLite
4235 * doesn't know if "ON" belongs to a "JOIN" or an "ON CONFLICT" clause.
4236 *
4237 * See: https://www.sqlite.org/lang_insert.html
4238 */
4239 $fragment .= ' FROM (' . $this->translate( $values ) . ') WHERE true';
4240
4241 return $fragment;
4242 }
4243
4244 /**
4245 * Translate UPDATE list, emulating MySQL implicit defaults in non-strict mode.
4246 *
4247 * Rewrites an UPDATE statement list in the following form:
4248 * UPDATE table SET <non-null-column> = <value>
4249 * To a list with the following structure:
4250 * UPDATE table SET <non-null-column> = COALESCE(<value>, <implicit-default>)
4251 *
4252 * In MySQL, the behavior of INSERT and UPDATE statements depends on whether
4253 * the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
4254 *
4255 * When the strict mode is not enabled, executing an UPDATE statement that
4256 * sets a NOT NULL column value to NULL saves an IMPLICIT DEFAULT instead.
4257 *
4258 * @param string $table_name The name of the target table.
4259 * @param WP_Parser_Node $node The "updateList" AST node.
4260 * @return string The translated UPDATE list.
4261 */
4262 private function translate_update_list_in_non_strict_mode( string $table_name, WP_Parser_Node $node ): string {
4263 // 1. Get column metadata from information schema.
4264 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
4265 $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
4266 $columns = $this->execute_sqlite_query(
4267 '
4268 SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, column_default
4269 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
4270 WHERE table_schema = ?
4271 AND table_name = ?
4272 ',
4273 array( $this->db_name, $table_name )
4274 )->fetchAll( PDO::FETCH_ASSOC );
4275 $column_map = array_combine( array_column( $columns, 'COLUMN_NAME' ), $columns );
4276
4277 // 2. Translate UPDATE list, emulating implicit defaults for NULLs values.
4278 $fragment = '';
4279 foreach ( $node->get_child_nodes() as $i => $update_element ) {
4280 $column_ref = $update_element->get_first_child_node( 'columnRef' );
4281 $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' );
4282 $expr = $update_element->get_first_child_node( 'expr' );
4283
4284 // Get column info.
4285 $column_name = $this->unquote_sqlite_identifier( $this->translate( end( $column_ref_parts ) ) );
4286 $column_info = $column_map[ strtolower( $column_name ) ];
4287 $data_type = $column_info['DATA_TYPE'];
4288 $is_nullable = 'YES' === $column_info['IS_NULLABLE'];
4289 $default = $column_info['COLUMN_DEFAULT'];
4290
4291 // Get the UPDATE value. It's either an expression or a DEFAULT keyword.
4292 if ( null === $expr ) {
4293 // Emulate "column = DEFAULT".
4294 $value = null === $default ? 'NULL' : $this->connection->quote( $default );
4295 } else {
4296 $value = $this->translate( $expr );
4297 }
4298
4299 // Apply type casting.
4300 $value = $this->cast_value_in_non_strict_mode( $data_type, $value );
4301
4302 // If the column is NOT NULL, a NULL value resolves to implicit default.
4303 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $data_type ] ?? null;
4304 if ( ! $is_nullable && null !== $implicit_default ) {
4305 $value = sprintf( 'COALESCE(%s, %s)', $value, $this->connection->quote( $implicit_default ) );
4306 }
4307
4308 // Compose the UPDATE list item.
4309 $fragment .= $i > 0 ? ', ' : '';
4310 $fragment .= $this->translate( end( $column_ref_parts ) );
4311 $fragment .= ' = ';
4312 $fragment .= $value;
4313 }
4314 return $fragment;
4315 }
4316
4317 /**
4318 * Unnest parenthesized MySQL expression node.
4319 *
4320 * In MySQL, extra parentheses around simple expressions are not considered.
4321 *
4322 * For example, the "SELECT (((id)))" clause is equivalent to "SELECT id".
4323 * This means that the "(((id)))" part will behave as a column name rather
4324 * than as an expression, and the resulting column name will be just "id".
4325 *
4326 * @param WP_Parser_Node $node The expression AST node.
4327 * @return WP_Parser_Node The unnested expression.
4328 */
4329 private function unnest_parenthesized_expression( WP_Parser_Node $node ): WP_Parser_Node {
4330 $children = $node->get_children();
4331
4332 // Descend the "expr -> boolPri -> predicate -> bitExpr -> simpleExpr" tree,
4333 // when on each level we have only a single child node (expression nesting).
4334 if (
4335 1 === count( $children )
4336 && $children[0] instanceof WP_Parser_Node
4337 && in_array( $children[0]->rule_name, array( 'expr', 'boolPri', 'predicate', 'bitExpr', 'simpleExpr' ), true )
4338 ) {
4339 $unnested = $this->unnest_parenthesized_expression( $children[0] );
4340 return $unnested === $children[0] ? $node : $unnested;
4341 }
4342
4343 // Unnest "OPEN_PAR_SYMBOL exprList CLOSE_PAR_SYMBOL" to "exprList".
4344 if (
4345 count( $children ) === 3
4346 && $children[0] instanceof WP_MySQL_Token && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $children[0]->id
4347 && $children[1] instanceof WP_Parser_Node && 'exprList' === $children[1]->rule_name
4348 && $children[2] instanceof WP_MySQL_Token && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $children[2]->id
4349 && 1 === count( $children[1]->get_children() )
4350 ) {
4351 return $this->unnest_parenthesized_expression( $children[1] );
4352 }
4353
4354 return $node;
4355 }
4356
4357 /**
4358 * Disambiguate and translate an expression with a simple or parenthesized
4359 * column reference for use within an ORDER BY, GROUP BY, or HAVING clause.
4360 *
4361 * In SQLite, columns that exist in multiple tables used within a query must
4362 * be fully qualified when used in the ORDER BY, GROUP BY, or HAVING clause.
4363 * In MySQL, these can be disambiguated using the SELECT item list.
4364 *
4365 * For example, when tables "t1" and "t2" both have a column called "name",
4366 * the following query will cause an "ambiguous column name" error in SQLite,
4367 * but it will succeed in MySQL, using the "t1.name" from the SELECT clause:
4368 *
4369 * SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY name
4370 *
4371 * This is because MySQL primarily considers the "name" column that was used
4372 * in the SELECT list - when it is unambiguous, it will be used in ORDER BY.
4373 *
4374 * To emulate this behavior in SQLite, we will search for unqualified column
4375 * references in the ORDER BY, GROUP BY, or HAVING item expression, and try
4376 * to qualify them using the SELECT item list.
4377 *
4378 * In other words, the above query will be rewritten as follows:
4379 *
4380 * SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY t1.name
4381 *
4382 * Note that the ORDER BY column was rewritten from "name" to "t1.name".
4383 *
4384 * @TODO: When multi-database support is implemented, we'll also need to
4385 * consider column references in forms like "db.table.column".
4386 *
4387 * @param array $disambiguation_map The SELECT item disambiguation map (column name => array of select items).
4388 * @see WP_SQLite_Driver::create_select_item_disambiguation_map()
4389 * @param WP_Parser_Node $expr The expression AST node or subnode.
4390 * @return string|null The disambiguated and translated expression;
4391 * null when the expression cannot be disambiguated.
4392 */
4393 private function disambiguate_item( array $disambiguation_map, WP_Parser_Node $expr ) {
4394 // Skip when there is no column in the expression (no "columnRef" node),
4395 // or when the column is already qualified (has a "dotIdentifier" node).
4396 $column_ref = $expr->get_first_descendant_node( 'columnRef' );
4397 if ( ! $column_ref || $column_ref->get_first_descendant_node( 'dotIdentifier' ) ) {
4398 return null;
4399 }
4400
4401 // Support also parenthesized column references (e.g. "(id)").
4402 $expr = $this->unnest_parenthesized_expression( $expr );
4403
4404 // Consider only simple and parenthesized column references (as per MySQL).
4405 $expr_value = $this->translate( $expr );
4406 $column_value = $this->translate( $column_ref );
4407 if ( $expr_value !== $column_value ) {
4408 return null;
4409 }
4410
4411 // Look for SELECT items that match the column reference.
4412 $column_name = $this->translate( $column_ref );
4413 $select_item_matches = $disambiguation_map[ $column_name ] ?? array();
4414
4415 // When we find exactly one matching SELECT list item, we can disambiguate
4416 // the column reference. Otherwise, fall back to the original expression.
4417 if ( 1 === count( $select_item_matches ) ) {
4418 return $select_item_matches[0];
4419 }
4420 return null;
4421 }
4422
4423 /**
4424 * Create a SELECT item disambiguation map from a SELECT item list for use
4425 * with the ORDER BY, GROUP BY, and HAVING clause disambiguation algorithm.
4426 *
4427 * @see WP_SQLite_Driver::disambiguate_item()
4428 *
4429 * @param WP_Parser_Node $select_item_list The "selectItemList" AST node.
4430 * @return array The SELECT item disambiguation map (column name => array of select items).
4431 */
4432 private function create_select_item_disambiguation_map( WP_Parser_Node $select_item_list ): array {
4433 // Create a map of SELECT item column names to their qualified values.
4434 $disambiguation_map = array();
4435 foreach ( $select_item_list->get_child_nodes() as $select_item ) {
4436 /*
4437 * [GRAMMAR]
4438 * selectItem: tableWild | (expr selectAlias?)
4439 */
4440
4441 // Skip when a "tableWild" node is used (no "expr" node).
4442 $select_item_expr = $select_item->get_first_child_node( 'expr' );
4443 if ( ! $select_item_expr ) {
4444 continue;
4445 }
4446
4447 // A SELECT item alias always needs to be preserved as-is.
4448 $alias = $select_item->get_first_child_node( 'selectAlias' );
4449 if ( $alias ) {
4450 $alias_value = $this->translate( $alias->get_first_child_node() );
4451 $disambiguation_map[ $alias_value ] = array( $alias_value );
4452 continue;
4453 }
4454
4455 // Skip when there is no column listed (no "columnRef" node).
4456 $select_column_ref = $select_item_expr->get_first_descendant_node( 'columnRef' );
4457 if ( ! $select_column_ref ) {
4458 continue;
4459 }
4460
4461 // Skip when the column reference is not qualified (no "dotIdentifier" node).
4462 $dot_identifiers = $select_column_ref->get_descendant_nodes( 'dotIdentifier' );
4463 if ( 0 === count( $dot_identifiers ) ) {
4464 continue;
4465 }
4466
4467 // Support also parenthesized column references (e.g. "(t.id)").
4468 $select_item_expr = $this->unnest_parenthesized_expression( $select_item_expr );
4469
4470 // Consider only simple and parenthesized column references (as per MySQL).
4471 $expr_value = $this->translate( $select_item_expr );
4472 $column_value = $this->translate( $select_column_ref );
4473 if ( $expr_value !== $column_value ) {
4474 continue;
4475 }
4476
4477 // The column name is the last "dotIdentifier" node.
4478 $key = $this->translate( end( $dot_identifiers )->get_first_child_node() );
4479
4480 $disambiguation_map[ $key ] = $disambiguation_map[ $key ] ?? array();
4481 $disambiguation_map[ $key ][] = $column_value;
4482 }
4483 return $disambiguation_map;
4484 }
4485
4486 /**
4487 * Analyze a "tableReferenceList" AST node and extract table data.
4488 *
4489 * This method extracts table data for all tables that are used at the root
4490 * level of a given query, including tables that are referenced using JOINs.
4491 *
4492 * The returned array maps table aliases to table names and additional data:
4493 * - key: table alias, or name if no alias is used
4494 * - value: an array of table data
4495 * - table_name: the real name of the table (null for derived tables)
4496 * - table_expr: the table expression for a derived table (null for regular tables)
4497 * - join_expr: the join expression used for the table (null when no join is used)
4498 *
4499 * MySQL has a non-stand ardsyntax extension where a comma-separated list of
4500 * table references is allowed as a table reference in itself, for instance:
4501 * SELECT * FROM (t1, t2) JOIN t3 ON 1
4502 *
4503 * Which is equivalent to:
4504 * SELECT * FROM (t1 CROSS JOIN t2) JOIN t3 ON 1
4505 *
4506 * @param WP_Parser_Node $node The "tableReferenceList" AST node.
4507 * @return array The table reference map (table alias => array of table data).
4508 */
4509 private function create_table_reference_map( WP_Parser_Node $node ): array {
4510 $table_map = array();
4511
4512 // Collect all table references, including the ones used in JOINs.
4513 $table_refs = array();
4514 foreach ( $node->get_child_nodes( 'tableReference' ) as $table_ref ) {
4515 $table_refs[] = $table_ref;
4516 foreach ( $table_ref->get_child_nodes( 'joinedTable' ) as $joined_table ) {
4517 $table_refs[] = $joined_table;
4518 }
4519 }
4520
4521 // Process each table reference, extracting table data.
4522 foreach ( $table_refs as $table_ref ) {
4523 $table_factor = $table_ref->get_first_descendant_node( 'tableFactor' );
4524 $join_expr = $table_ref->get_first_child_node( 'expr' );
4525 $child = $table_factor->get_first_child_node();
4526
4527 // Descend all "singleTableParens" nodes to get the "singleTable" node.
4528 if ( 'singleTableParens' === $child->rule_name ) {
4529 $child = $child->get_first_descendant_node( 'singleTable' );
4530 }
4531
4532 if ( 'singleTable' === $child->rule_name ) {
4533 // Extract data from the "singleTable" node.
4534 $name = $this->translate( $child->get_first_child_node( 'tableRef' ) );
4535 $alias_node = $child->get_first_child_node( 'tableAlias' );
4536 $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null;
4537
4538 $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array(
4539 'table_name' => $this->unquote_sqlite_identifier( $name ),
4540 'table_expr' => null,
4541 'join_expr' => $this->translate( $join_expr ),
4542 );
4543 } elseif ( 'derivedTable' === $child->rule_name ) {
4544 // Extract data from the "derivedTable" node.
4545 $subquery = $child->get_first_descendant_node( 'subquery' );
4546 $alias_node = $child->get_first_child_node( 'tableAlias' );
4547 $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null;
4548
4549 $table_map[ $this->unquote_sqlite_identifier( $alias ) ] = array(
4550 'table_name' => null,
4551 'table_expr' => $this->translate( $subquery ),
4552 'join_expr' => $this->translate( $join_expr ),
4553 );
4554 } elseif ( 'tableReferenceListParens' === $child->rule_name ) {
4555 // Recursively process the "tableReferenceListParens" node.
4556 $table_ref_list = $child->get_first_descendant_node( 'tableReferenceList' );
4557 $table_map = array_merge( $table_map, $this->create_table_reference_map( $table_ref_list ) );
4558 }
4559 }
4560 return $table_map;
4561 }
4562
4563 /**
4564 * Emulate MySQL type casting for INSERT or UPDATE value in non-strict mode.
4565 *
4566 * @param string $mysql_data_type The MySQL data type.
4567 * @param string $translated_value The original translated value.
4568 * @return string The translated value.
4569 */
4570 private function cast_value_in_non_strict_mode(
4571 string $mysql_data_type,
4572 string $translated_value
4573 ): string {
4574 $sqlite_data_type = self::DATA_TYPE_STRING_MAP[ $mysql_data_type ];
4575
4576 // Get and quote the IMPLICIT DEFAULT value.
4577 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $mysql_data_type ] ?? null;
4578 $quoted_implicit_default = null === $implicit_default
4579 ? 'NULL'
4580 : $this->connection->quote( $implicit_default );
4581
4582 /*
4583 * In MySQL, when saving a value via INSERT or UPDATE in non-strict mode,
4584 * 1. MySQL attempts to cast the value to the target column data type.
4585 * 2. When casting can't be done, MySQL saves an IMPLICIT DEFAULT.
4586 */
4587 switch ( $mysql_data_type ) {
4588 case 'date':
4589 case 'time':
4590 case 'datetime':
4591 case 'timestamp':
4592 case 'year':
4593 /*
4594 * MySQL supports date and time components without a zero padding,
4595 * but that doesn't work with date and time functions in SQLite.
4596 * E.g.: "2025-3-7 9:5:2" is a valid datetime/timestamp value in
4597 * in MySQL, but SQLite requires it to be "2025-03-07 09:05:02".
4598 *
4599 * A solution to this would need to be done on the SQL level to
4600 * address computed values, and it should be done for the strict
4601 * mode as well. This may require a user-defined function.
4602 *
4603 * TODO: Handle zero padding for date and time functions, while
4604 * supporting both strict and non-strict modes.
4605 */
4606
4607 if ( 'date' === $mysql_data_type ) {
4608 $function_call = sprintf( 'DATE(%s)', $translated_value );
4609 } elseif ( 'time' === $mysql_data_type ) {
4610 $function_call = sprintf( 'TIME(%s)', $translated_value );
4611 } elseif ( 'datetime' === $mysql_data_type || 'timestamp' === $mysql_data_type ) {
4612 $function_call = sprintf( 'DATETIME(%s)', $translated_value );
4613 } elseif ( 'year' === $mysql_data_type ) {
4614 $function_call = sprintf( "STRFTIME('%%Y', %s)", $translated_value );
4615 }
4616
4617 // When the function call evaluates to NULL (invalid date/time),
4618 // we need to fallback to the IMPLICIT DEFAULT value.
4619 return sprintf(
4620 'IIF(%s IS NULL, NULL, COALESCE(%s, %s))',
4621 $translated_value,
4622 $function_call,
4623 $quoted_implicit_default
4624 );
4625 default:
4626 // For all other data types, use SQLite-native CAST expression.
4627 $mysql_data_type = strtolower( $mysql_data_type );
4628 return sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type );
4629 }
4630 }
4631
4632 /**
4633 * Generate a SQLite CREATE TABLE statement from information schema data.
4634 *
4635 * @param bool $table_is_temporary Whether the table is temporary.
4636 * @param string $table_name The name of the table to create.
4637 * @param string|null $new_table_name Override the original table name for ALTER TABLE emulation.
4638 * @return string[] Queries to create the table, indexes, and constraints.
4639 * @throws WP_SQLite_Driver_Exception When the table information is missing.
4640 */
4641 private function get_sqlite_create_table_statement(
4642 bool $table_is_temporary,
4643 string $table_name,
4644 ?string $new_table_name = null
4645 ): array {
4646 // 1. Get table info.
4647 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
4648 $table_info = $this->execute_sqlite_query(
4649 '
4650 SELECT *
4651 FROM ' . $this->quote_sqlite_identifier( $tables_table ) . "
4652 WHERE table_type = 'BASE TABLE'
4653 AND table_schema = ?
4654 AND table_name = ?
4655 ",
4656 array( $this->db_name, $table_name )
4657 )->fetch( PDO::FETCH_ASSOC );
4658
4659 if ( false === $table_info ) {
4660 throw $this->new_driver_exception(
4661 sprintf( "Table '%s' doesn't exist", $table_name ),
4662 '42S02'
4663 );
4664 }
4665
4666 // 2. Get column info.
4667 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
4668 $column_info = $this->execute_sqlite_query(
4669 sprintf(
4670 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position',
4671 $this->quote_sqlite_identifier( $columns_table )
4672 ),
4673 array( $this->db_name, $table_name )
4674 )->fetchAll( PDO::FETCH_ASSOC );
4675
4676 // 3. Get index info, grouped by index name.
4677 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
4678 $constraint_info = $this->execute_sqlite_query(
4679 sprintf(
4680 "
4681 SELECT *
4682 FROM %s
4683 WHERE table_schema = ?
4684 AND table_name = ?
4685 ORDER BY
4686 INDEX_NAME = 'PRIMARY' DESC,
4687 NON_UNIQUE = '0' DESC,
4688 INDEX_TYPE = 'SPATIAL' DESC,
4689 INDEX_TYPE = 'BTREE' DESC,
4690 INDEX_TYPE = 'FULLTEXT' DESC,
4691 ROWID,
4692 SEQ_IN_INDEX
4693 ",
4694 $this->quote_sqlite_identifier( $statistics_table )
4695 ),
4696 array( $this->db_name, $table_name )
4697 )->fetchAll( PDO::FETCH_ASSOC );
4698
4699 $grouped_constraints = array();
4700 foreach ( $constraint_info as $constraint ) {
4701 $name = $constraint['INDEX_NAME'];
4702 $seq = $constraint['SEQ_IN_INDEX'];
4703 $grouped_constraints[ $name ][ $seq ] = $constraint;
4704 }
4705
4706 // 4. Get foreign key info.
4707 $referential_constraints_table = $this->information_schema_builder
4708 ->get_table_name( $table_is_temporary, 'referential_constraints' );
4709 $referential_constraints_info = $this->execute_sqlite_query(
4710 sprintf(
4711 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name',
4712 $this->quote_sqlite_identifier( $referential_constraints_table )
4713 ),
4714 array( $this->db_name, $table_name )
4715 )->fetchAll( PDO::FETCH_ASSOC );
4716
4717 $key_column_usage_map = array();
4718 if ( count( $referential_constraints_info ) > 0 ) {
4719 $key_column_usage_table = $this->information_schema_builder
4720 ->get_table_name( $table_is_temporary, 'key_column_usage' );
4721 $key_column_usage_info = $this->execute_sqlite_query(
4722 sprintf(
4723 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL',
4724 $this->quote_sqlite_identifier( $key_column_usage_table )
4725 ),
4726 array( $this->db_name, $table_name )
4727 )->fetchAll( PDO::FETCH_ASSOC );
4728
4729 $key_column_usage_map = array();
4730 foreach ( $key_column_usage_info as $key_column_usage ) {
4731 $constraint_name = $key_column_usage['CONSTRAINT_NAME'];
4732 if ( ! isset( $key_column_usage_map[ $constraint_name ] ) ) {
4733 $key_column_usage_map[ $constraint_name ] = array();
4734 }
4735 $key_column_usage_map[ $constraint_name ][] = array(
4736 $key_column_usage['COLUMN_NAME'],
4737 $key_column_usage['REFERENCED_COLUMN_NAME'],
4738 );
4739 }
4740 }
4741
4742 // 5. Generate CREATE TABLE statement columns.
4743 $rows = array();
4744 $on_update_queries = array();
4745 $has_autoincrement = false;
4746 foreach ( $column_info as $column ) {
4747 $query = ' ';
4748 $query .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
4749
4750 $type = self::DATA_TYPE_STRING_MAP[ $column['DATA_TYPE'] ];
4751
4752 /*
4753 * In SQLite, there is a PRIMARY KEY quirk for backward compatibility.
4754 * This applies to ROWID tables and single-column primary keys only:
4755 * 1. "INTEGER PRIMARY KEY" creates an alias of ROWID.
4756 * 2. "INT PRIMARY KEY" will not alias of ROWID.
4757 *
4758 * Therefore, we want to:
4759 * 1. Use "INT PRIMARY KEY" when we have a single-column integer
4760 * PRIMARY KEY without AUTOINCREMENT (to avoid the ROWID alias).
4761 * 2. Use "INTEGER PRIMARY KEY" otherwise.
4762 *
4763 * In SQLite, "AUTOINCREMENT" is only allowed on "INTEGER PRIMARY KEY",
4764 * and setting it changes the automatic ROWID assignment algorithm to
4765 * prevent the reuse of ROWIDs. Using "INT PRIMARY KEY" is not allowed.
4766 *
4767 * See:
4768 * - https://www.sqlite.org/autoinc.html
4769 * - https://www.sqlite.org/lang_createtable.html
4770 */
4771 if (
4772 'INTEGER' === $type
4773 && 'PRI' === $column['COLUMN_KEY']
4774 && 'auto_increment' !== $column['EXTRA']
4775 && count( $grouped_constraints['PRIMARY'] ) === 1
4776 ) {
4777 $type = 'INT';
4778 }
4779
4780 $query .= ' ' . $type;
4781
4782 // In MySQL, text fields are case-insensitive by default.
4783 // COLLATE NOCASE emulates the same behavior in SQLite.
4784 // @TODO: Respect the actual column and index collation.
4785 if ( 'TEXT' === $type ) {
4786 $query .= ' COLLATE NOCASE';
4787 }
4788 if ( 'NO' === $column['IS_NULLABLE'] ) {
4789 $query .= ' NOT NULL';
4790 }
4791 if ( 'auto_increment' === $column['EXTRA'] ) {
4792 $has_autoincrement = true;
4793 $query .= ' PRIMARY KEY AUTOINCREMENT';
4794 }
4795 if ( null !== $column['COLUMN_DEFAULT'] ) {
4796 // @TODO: Handle defaults with expression values (DEFAULT_GENERATED).
4797
4798 // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
4799 // and datetime columns. For other column types, it's just a string.
4800 if (
4801 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
4802 && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
4803 ) {
4804 $query .= ' DEFAULT CURRENT_TIMESTAMP';
4805 } else {
4806 $query .= ' DEFAULT ' . $this->connection->quote( $column['COLUMN_DEFAULT'] );
4807 }
4808 }
4809 $rows[] = $query;
4810
4811 if ( 'on update CURRENT_TIMESTAMP' === $column['EXTRA'] ) {
4812 $on_update_queries[] = $this->get_column_on_update_trigger_query(
4813 $table_name,
4814 $column['COLUMN_NAME']
4815 );
4816 }
4817 }
4818
4819 // 6. Generate CREATE TABLE statement constraints, collect indexes.
4820 $create_index_queries = array();
4821 foreach ( $grouped_constraints as $constraint ) {
4822 ksort( $constraint );
4823 $info = $constraint[1];
4824
4825 if ( 'PRIMARY' === $info['INDEX_NAME'] ) {
4826 if ( $has_autoincrement ) {
4827 if ( count( $constraint ) > 1 ) {
4828 throw $this->new_driver_exception(
4829 'Cannot combine AUTOINCREMENT and multiple primary keys in SQLite'
4830 );
4831 }
4832 continue;
4833 }
4834 $query = ' PRIMARY KEY (';
4835 $query .= implode(
4836 ', ',
4837 array_map(
4838 function ( $column ) {
4839 return $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
4840 },
4841 $constraint
4842 )
4843 );
4844 $query .= ')';
4845 $rows[] = $query;
4846 } else {
4847 $is_unique = '0' === $info['NON_UNIQUE'];
4848
4849 // Prefix the original index name with the table name.
4850 // This is to avoid conflicting index names in SQLite.
4851 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $info['INDEX_NAME'] );
4852
4853 $query = sprintf(
4854 'CREATE %sINDEX %s ON %s (',
4855 $is_unique ? 'UNIQUE ' : '',
4856 $this->quote_sqlite_identifier( $sqlite_index_name ),
4857 $this->quote_sqlite_identifier( $table_name )
4858 );
4859 $query .= implode(
4860 ', ',
4861 array_map(
4862 function ( $column ) {
4863 $fragment = $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
4864 if ( 'D' === $column['COLLATION'] ) {
4865 $fragment .= ' DESC';
4866 }
4867 return $fragment;
4868 },
4869 $constraint
4870 )
4871 );
4872 $query .= ')';
4873
4874 $create_index_queries[] = $query;
4875 }
4876 }
4877
4878 // 7. Add foreign key constraints.
4879 foreach ( $referential_constraints_info as $referential_constraint ) {
4880 $column_names = array();
4881 $referenced_column_names = array();
4882 foreach ( $key_column_usage_map[ $referential_constraint['CONSTRAINT_NAME'] ] as $info ) {
4883 $column_names[] = $this->quote_sqlite_identifier( $info[0] );
4884 $referenced_column_names[] = $this->quote_sqlite_identifier( $info[1] );
4885 }
4886 $query = sprintf(
4887 ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)',
4888 $this->quote_sqlite_identifier( $referential_constraint['CONSTRAINT_NAME'] ),
4889 implode( ', ', $column_names ),
4890 $this->quote_sqlite_identifier( $referential_constraint['REFERENCED_TABLE_NAME'] ),
4891 implode( ', ', $referenced_column_names )
4892 );
4893
4894 // ON DELETE
4895 $delete_rule = $referential_constraint['DELETE_RULE'];
4896 if ( 'NO ACTION' === $delete_rule ) {
4897 // In MySQL, NO ACTION is equivalent to RESTRICT with InnoDB.
4898 $delete_rule = 'RESTRICT';
4899 }
4900 $query .= sprintf( ' ON DELETE %s', $delete_rule );
4901
4902 // ON UPDATE
4903 $update_rule = $referential_constraint['UPDATE_RULE'];
4904 if ( 'NO ACTION' === $update_rule ) {
4905 // In MySQL, NO ACTION is equivalent to RESTRICT with InnoDB.
4906 $update_rule = 'RESTRICT';
4907 }
4908 $query .= sprintf( ' ON UPDATE %s', $update_rule );
4909
4910 $rows[] = $query;
4911 }
4912
4913 // 8. Compose the CREATE TABLE statement.
4914 $create_table_query = sprintf(
4915 "CREATE %sTABLE %s (\n",
4916 $table_is_temporary ? 'TEMPORARY ' : '',
4917 $this->quote_sqlite_identifier( $new_table_name ?? $table_name )
4918 );
4919 $create_table_query .= implode( ",\n", $rows );
4920 $create_table_query .= "\n) STRICT";
4921 return array_merge( array( $create_table_query ), $create_index_queries, $on_update_queries );
4922 }
4923
4924 /**
4925 * Generate a MySQL CREATE TABLE statement from information schema data.
4926 *
4927 * @param bool $table_is_temporary Whether the table is temporary.
4928 * @param string $table_name The name of the table to create.
4929 * @return string The CREATE TABLE statement.
4930 */
4931 private function get_mysql_create_table_statement( bool $table_is_temporary, string $table_name ): ?string {
4932 // 1. Get table info.
4933 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
4934 $table_info = $this->execute_sqlite_query(
4935 '
4936 SELECT *
4937 FROM ' . $this->quote_sqlite_identifier( $tables_table ) . "
4938 WHERE table_type = 'BASE TABLE'
4939 AND table_schema = ?
4940 AND table_name = ?
4941 ",
4942 array( $this->db_name, $table_name )
4943 )->fetch( PDO::FETCH_ASSOC );
4944
4945 if ( false === $table_info ) {
4946 return null;
4947 }
4948
4949 // 2. Get column info.
4950 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
4951 $column_info = $this->execute_sqlite_query(
4952 sprintf(
4953 '
4954 SELECT *
4955 FROM %s
4956 WHERE table_schema = ?
4957 AND table_name = ?
4958 ORDER BY ordinal_position
4959 ',
4960 $this->quote_sqlite_identifier( $columns_table )
4961 ),
4962 array( $this->db_name, $table_name )
4963 )->fetchAll( PDO::FETCH_ASSOC );
4964
4965 // 3. Get index info, grouped by index name.
4966 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
4967 $constraint_info = $this->execute_sqlite_query(
4968 sprintf(
4969 "
4970 SELECT *
4971 FROM %s
4972 WHERE table_schema = ?
4973 AND table_name = ?
4974 ORDER BY
4975 INDEX_NAME = 'PRIMARY' DESC,
4976 NON_UNIQUE = '0' DESC,
4977 INDEX_TYPE = 'SPATIAL' DESC,
4978 INDEX_TYPE = 'BTREE' DESC,
4979 INDEX_TYPE = 'FULLTEXT' DESC,
4980 ROWID,
4981 SEQ_IN_INDEX
4982 ",
4983 $this->quote_sqlite_identifier( $statistics_table )
4984 ),
4985 array( $this->db_name, $table_name )
4986 )->fetchAll( PDO::FETCH_ASSOC );
4987
4988 $grouped_constraints = array();
4989 foreach ( $constraint_info as $constraint ) {
4990 $name = $constraint['INDEX_NAME'];
4991 $seq = $constraint['SEQ_IN_INDEX'];
4992 $grouped_constraints[ $name ][ $seq ] = $constraint;
4993 }
4994
4995 // 4. Get foreign key info.
4996 $referential_constraints_table = $this->information_schema_builder
4997 ->get_table_name( $table_is_temporary, 'referential_constraints' );
4998 $referential_constraints_info = $this->execute_sqlite_query(
4999 sprintf(
5000 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name',
5001 $this->quote_sqlite_identifier( $referential_constraints_table )
5002 ),
5003 array( $this->db_name, $table_name )
5004 )->fetchAll( PDO::FETCH_ASSOC );
5005
5006 $key_column_usage_map = array();
5007 if ( count( $referential_constraints_info ) > 0 ) {
5008 $key_column_usage_table = $this->information_schema_builder
5009 ->get_table_name( $table_is_temporary, 'key_column_usage' );
5010 $key_column_usage_info = $this->execute_sqlite_query(
5011 sprintf(
5012 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL',
5013 $this->quote_sqlite_identifier( $key_column_usage_table )
5014 ),
5015 array( $this->db_name, $table_name )
5016 )->fetchAll( PDO::FETCH_ASSOC );
5017
5018 $key_column_usage_map = array();
5019 foreach ( $key_column_usage_info as $key_column_usage ) {
5020 $constraint_name = $key_column_usage['CONSTRAINT_NAME'];
5021 if ( ! isset( $key_column_usage_map[ $constraint_name ] ) ) {
5022 $key_column_usage_map[ $constraint_name ] = array();
5023 }
5024 $key_column_usage_map[ $constraint_name ][] = array(
5025 $key_column_usage['COLUMN_NAME'],
5026 $key_column_usage['REFERENCED_COLUMN_NAME'],
5027 );
5028 }
5029 }
5030
5031 // 5. Generate CREATE TABLE statement columns.
5032 $rows = array();
5033 foreach ( $column_info as $column ) {
5034 $sql = ' ';
5035 $sql .= $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
5036 $sql .= ' ' . $column['COLUMN_TYPE'];
5037 if ( 'NO' === $column['IS_NULLABLE'] ) {
5038 $sql .= ' NOT NULL';
5039 } elseif ( 'timestamp' === $column['COLUMN_TYPE'] ) {
5040 // Nullable "timestamp" columns dump NULL explicitly.
5041 $sql .= ' NULL';
5042 }
5043 if ( 'auto_increment' === $column['EXTRA'] ) {
5044 $sql .= ' AUTO_INCREMENT';
5045 }
5046
5047 // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
5048 // and datetime columns. For other column types, it's just a string.
5049 if (
5050 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
5051 && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
5052 ) {
5053 $sql .= ' DEFAULT CURRENT_TIMESTAMP';
5054 } elseif ( null !== $column['COLUMN_DEFAULT'] ) {
5055 $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] );
5056 } elseif ( 'YES' === $column['IS_NULLABLE'] ) {
5057 $sql .= ' DEFAULT NULL';
5058 }
5059
5060 // Handle ON UPDATE CURRENT_TIMESTAMP.
5061 if ( str_contains( $column['EXTRA'], 'on update CURRENT_TIMESTAMP' ) ) {
5062 $sql .= ' ON UPDATE CURRENT_TIMESTAMP';
5063 }
5064
5065 if ( '' !== $column['COLUMN_COMMENT'] ) {
5066 $sql .= sprintf(
5067 ' COMMENT %s',
5068 $this->quote_mysql_utf8_string_literal( $column['COLUMN_COMMENT'] )
5069 );
5070 }
5071
5072 $rows[] = $sql;
5073 }
5074
5075 // 6. Generate CREATE TABLE statement constraints, collect indexes.
5076 foreach ( $grouped_constraints as $constraint ) {
5077 ksort( $constraint );
5078 $info = $constraint[1];
5079
5080 if ( 'PRIMARY' === $info['INDEX_NAME'] ) {
5081 $sql = ' PRIMARY KEY (';
5082 $sql .= implode(
5083 ', ',
5084 array_map(
5085 function ( $column ) {
5086 return $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
5087 },
5088 $constraint
5089 )
5090 );
5091 $sql .= ')';
5092 } else {
5093 $is_unique = '0' === $info['NON_UNIQUE'];
5094
5095 $sql = sprintf(
5096 ' %s%s%sKEY ',
5097 $is_unique ? 'UNIQUE ' : '',
5098 'FULLTEXT' === $info['INDEX_TYPE'] ? 'FULLTEXT ' : '',
5099 'SPATIAL' === $info['INDEX_TYPE'] ? 'SPATIAL ' : ''
5100 );
5101 $sql .= $this->quote_mysql_identifier( $info['INDEX_NAME'] );
5102 $sql .= ' (';
5103 $sql .= implode(
5104 ', ',
5105 array_map(
5106 function ( $column ) {
5107 $definition = $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
5108 if ( null !== $column['SUB_PART'] ) {
5109 $definition .= sprintf( '(%d)', $column['SUB_PART'] );
5110 }
5111 if ( 'D' === $column['COLLATION'] ) {
5112 $definition .= ' DESC';
5113 }
5114 return $definition;
5115 },
5116 $constraint
5117 )
5118 );
5119 $sql .= ')';
5120 }
5121
5122 if ( '' !== $info['INDEX_COMMENT'] ) {
5123 $sql .= sprintf(
5124 ' COMMENT %s',
5125 $this->quote_mysql_utf8_string_literal( $info['INDEX_COMMENT'] )
5126 );
5127 }
5128
5129 $rows[] = $sql;
5130 }
5131
5132 // 7. Add foreign key constraints.
5133 foreach ( $referential_constraints_info as $referential_constraint ) {
5134 $column_names = array();
5135 $referenced_column_names = array();
5136 foreach ( $key_column_usage_map[ $referential_constraint['CONSTRAINT_NAME'] ] as $info ) {
5137 $column_names[] = $this->quote_mysql_identifier( $info[0] );
5138 $referenced_column_names[] = $this->quote_mysql_identifier( $info[1] );
5139 }
5140 $sql = sprintf(
5141 ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)',
5142 $this->quote_mysql_identifier( $referential_constraint['CONSTRAINT_NAME'] ),
5143 implode( ', ', $column_names ),
5144 $this->quote_mysql_identifier( $referential_constraint['REFERENCED_TABLE_NAME'] ),
5145 implode( ', ', $referenced_column_names )
5146 );
5147 if ( 'NO ACTION' !== $referential_constraint['DELETE_RULE'] ) {
5148 $sql .= sprintf( ' ON DELETE %s', $referential_constraint['DELETE_RULE'] );
5149 }
5150 if ( 'NO ACTION' !== $referential_constraint['UPDATE_RULE'] ) {
5151 $sql .= sprintf( ' ON UPDATE %s', $referential_constraint['UPDATE_RULE'] );
5152 }
5153 $rows[] = $sql;
5154 }
5155
5156 // 8. Compose the CREATE TABLE statement.
5157 $collation = $table_info['TABLE_COLLATION'];
5158 $charset = substr( $collation, 0, strpos( $collation, '_' ) );
5159
5160 $sql = sprintf(
5161 "CREATE %sTABLE %s (\n",
5162 $table_is_temporary ? 'TEMPORARY ' : '',
5163 $this->quote_mysql_identifier( $table_name )
5164 );
5165 $sql .= implode( ",\n", $rows );
5166 $sql .= "\n)";
5167 $sql .= sprintf( ' ENGINE=%s', $table_info['ENGINE'] );
5168 $sql .= sprintf( ' DEFAULT CHARSET=%s', $charset );
5169 $sql .= sprintf( ' COLLATE=%s', $collation );
5170 if ( '' !== $table_info['TABLE_COMMENT'] ) {
5171 $sql .= sprintf(
5172 ' COMMENT=%s',
5173 $this->quote_mysql_utf8_string_literal( $table_info['TABLE_COMMENT'] )
5174 );
5175 }
5176 return $sql;
5177 }
5178
5179 /**
5180 * Get an unique SQLite index name from a MySQL table name and index name.
5181 *
5182 * @param string $table_name The MySQL table name.
5183 * @param string $index_name The MySQL index name.
5184 * @return string The SQLite index name.
5185 */
5186 private function get_sqlite_index_name( string $mysql_table_name, string $mysql_index_name ): string {
5187 // Prefix the original index name with the table name.
5188 // This is to avoid conflicting index names in SQLite.
5189 return $mysql_table_name . '__' . $mysql_index_name;
5190 }
5191
5192 /**
5193 * Get an internal savepoint name.
5194 *
5195 * Internal savepoints are used to emulate MySQL transactions that are run
5196 * inside a wrapping SQLite transaction, as transactions can't be nested.
5197 *
5198 * @param int $level The transaction nesting level.
5199 * @return string The internal savepoint name.
5200 */
5201 private function get_internal_savepoint_name( int $level ): string {
5202 return sprintf( '%ssavepoint_%d', self::RESERVED_PREFIX, $level );
5203 }
5204
5205 /**
5206 * Get an SQLite query to emulate MySQL "ON UPDATE CURRENT_TIMESTAMP".
5207 *
5208 * In SQLite, "ON UPDATE CURRENT_TIMESTAMP" is not supported. We need to
5209 * create a trigger to emulate this behavior.
5210 *
5211 * @param string $table The table name.
5212 * @param string $column The column name.
5213 */
5214 private function get_column_on_update_trigger_query( string $table, string $column ): string {
5215 // The trigger wouldn't work for virtual and "WITHOUT ROWID" tables,
5216 // but currently that can't happen as we're not creating such tables.
5217 // See: https://www.sqlite.org/rowidtable.html
5218 $trigger_name = self::RESERVED_PREFIX . "{$table}_{$column}_on_update";
5219 return sprintf(
5220 '
5221 CREATE TRIGGER %s
5222 AFTER UPDATE ON %s
5223 FOR EACH ROW
5224 BEGIN
5225 UPDATE %s SET %s = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;
5226 END
5227 ',
5228 $this->quote_sqlite_identifier( $trigger_name ),
5229 $this->quote_sqlite_identifier( $table ),
5230 $this->quote_sqlite_identifier( $table ),
5231 $this->quote_sqlite_identifier( $column )
5232 );
5233 }
5234
5235 /**
5236 * Unquote a quoted SQLite identifier.
5237 *
5238 * Remove bounding quotes and replace escaped quotes with their values.
5239 *
5240 * @param string $quoted_identifier The quoted identifier value.
5241 * @return string The unquoted identifier value.
5242 */
5243 private function unquote_sqlite_identifier( string $quoted_identifier ): string {
5244 $first_byte = $quoted_identifier[0] ?? null;
5245 if ( '"' === $first_byte || '`' === $first_byte ) {
5246 $unquoted = substr( $quoted_identifier, 1, -1 );
5247 return str_replace( $first_byte . $first_byte, $first_byte, $unquoted );
5248 }
5249 return $quoted_identifier;
5250 }
5251
5252 /**
5253 * Quote an SQLite identifier.
5254 *
5255 * @param string $unquoted_identifier The unquoted identifier value.
5256 * @return string The quoted identifier value.
5257 */
5258 private function quote_sqlite_identifier( string $unquoted_identifier ): string {
5259 return $this->connection->quote_identifier( $unquoted_identifier );
5260 }
5261
5262 /**
5263 * Quote a MySQL identifier.
5264 *
5265 * Wrap the identifier in backticks and escape backtick values within.
5266 *
5267 * @param string $unquoted_identifier The unquoted identifier value.
5268 * @return string The quoted identifier value.
5269 */
5270 private function quote_mysql_identifier( string $unquoted_identifier ): string {
5271 return '`' . str_replace( '`', '``', $unquoted_identifier ) . '`';
5272 }
5273
5274 /**
5275 * Format a MySQL UTF-8 string literal for output in a CREATE TABLE statement.
5276 *
5277 * We expect UTF-8 strings coming from SQLite. The only characters that must
5278 * be escaped in a single-quoted string for a UTF-8 MySQL dump are ' and \.
5279 *
5280 * MySQL SHOW CREATE TABLE command additionally escapes "\0", "\n", and "\r",
5281 * for the mysql CLI, logs, and better readability. This applies to column
5282 * default values, and table, column, and index comments. Other values, such
5283 * as identifiers, don't have these extra characters escaped in the output.
5284 *
5285 * See:
5286 * - https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql/sql_show.cc#L1799
5287 * - https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql/table.cc#L3525
5288 *
5289 * Unfortunately, SQLite doesn't validate the UTF-8 encoding, so other byte
5290 * sequences may come from SQLite as well: https://www.sqlite.org/invalidutf.html
5291 *
5292 * TODO: We may consider stripping invalid UTF-8 characters, but that's likely
5293 * to be a bigger project, as these can appear also in other contexts.
5294 *
5295 * @param string $utf8_literal The UTF-8 string literal to escape.
5296 * @return string The escaped string literal.
5297 */
5298 private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
5299 /*
5300 * We can't use "addcslashes()" here, because it has an unusual handling
5301 * of the ASCII NULL character, escaping it to "\000" instead of "\0".
5302 *
5303 * It is important to use "strtr()" and not "str_replace()", because
5304 * "str_replace()" applies replacements one after another, modifying
5305 * intermediate changes rather than just the original string:
5306 *
5307 * - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
5308 * - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
5309 */
5310 $backslash = chr( 92 );
5311 $replacements = array(
5312 "'" => "''", // A single quote character (').
5313 $backslash => $backslash . $backslash, // A backslash character (\).
5314 chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0).
5315 chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n).
5316 chr( 13 ) => $backslash . 'r', // A carriage return character (\r).
5317 );
5318 return "'" . strtr( $utf8_literal, $replacements ) . "'";
5319 }
5320
5321 /**
5322 * Clear the state of the driver.
5323 */
5324 private function flush(): void {
5325 $this->last_mysql_query = '';
5326 $this->last_sqlite_queries = array();
5327 $this->last_result = null;
5328 $this->last_return_value = null;
5329 $this->last_column_meta = array();
5330 $this->is_readonly = false;
5331 }
5332
5333 /**
5334 * Set results of a query() call using fetched data.
5335 *
5336 * @param array $data The data to set.
5337 */
5338 private function set_results_from_fetched_data( array $data ): void {
5339 $this->last_result = $data;
5340 $this->last_return_value = $this->last_result;
5341 }
5342
5343 /**
5344 * Set results of a query() call using the number of affected rows.
5345 *
5346 * @param int|null $override Override the affected rows.
5347 */
5348 private function set_result_from_affected_rows( ?int $override = null ): void {
5349 /*
5350 * SELECT CHANGES() is a workaround for the fact that $stmt->rowCount()
5351 * returns "0" (zero) with the SQLite driver at all times.
5352 * See: https://www.php.net/manual/en/pdostatement.rowcount.php
5353 */
5354 if ( null === $override ) {
5355 $affected_rows = (int) $this->execute_sqlite_query( 'SELECT CHANGES()' )->fetch()[0];
5356 } else {
5357 $affected_rows = $override;
5358 }
5359 $this->last_result = $affected_rows;
5360 $this->last_return_value = $affected_rows;
5361 }
5362
5363 /**
5364 * Create a new SQLite driver exception.
5365 *
5366 * @param string $message The exception message.
5367 * @param int|string $code The exception code. For PDO errors, a string representing SQLSTATE.
5368 * @param Throwable|null $previous The previous exception.
5369 * @return WP_SQLite_Driver_Exception
5370 */
5371 private function new_driver_exception(
5372 string $message,
5373 $code = 0,
5374 ?Throwable $previous = null
5375 ): WP_SQLite_Driver_Exception {
5376 return new WP_SQLite_Driver_Exception( $this, $message, $code, $previous );
5377 }
5378
5379 /**
5380 * Create a new invalid input exception.
5381 *
5382 * This exception can be used to mark cases that should never occur according
5383 * to the MySQL grammar. It may serve as an assertion that should never fail.
5384 *
5385 * @return WP_SQLite_Driver_Exception
5386 */
5387 private function new_invalid_input_exception(): WP_SQLite_Driver_Exception {
5388 return new WP_SQLite_Driver_Exception( $this, 'MySQL query syntax error.' );
5389 }
5390
5391 /**
5392 * Create a new not supported exception.
5393 *
5394 * This exception can be used to mark MySQL constructs that are not supported.
5395 *
5396 * @param string $cause The cause, indicating which construct is not supported.
5397 * @return WP_SQLite_Driver_Exception
5398 */
5399 private function new_not_supported_exception( string $cause ): WP_SQLite_Driver_Exception {
5400 return new WP_SQLite_Driver_Exception(
5401 $this,
5402 sprintf( 'MySQL query not supported. Cause: %s', $cause )
5403 );
5404 }
5405
5406 /**
5407 * Convert an information schema exception to a MySQL-like driver exception.
5408 *
5409 * This method is used to convert some information schema exceptions to the
5410 * corresponding MySQL exceptions, as they would be generated by PDO MySQL.
5411 * This conversion mirrors PDO's error messages and SQLSTATE codes.
5412 *
5413 * @param WP_SQLite_Information_Schema_Exception $e The information schema exception.
5414 * @return Throwable The converted exception, or the original
5415 * exception if no conversion was done.
5416 */
5417 private function convert_information_schema_exception( WP_SQLite_Information_Schema_Exception $e ): Throwable {
5418 switch ( $e->get_type() ) {
5419 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_TABLE_NAME:
5420 return $this->new_driver_exception(
5421 sprintf(
5422 "SQLSTATE[42S01]: Base table or view already exists: 1050 Table '%s' already exists",
5423 $e->get_data()['table_name']
5424 ),
5425 '42S01'
5426 );
5427 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_COLUMN_NAME:
5428 return $this->new_driver_exception(
5429 sprintf(
5430 "SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name '%s'",
5431 $e->get_data()['column_name']
5432 ),
5433 '42S21'
5434 );
5435 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_KEY_NAME:
5436 return $this->new_driver_exception(
5437 sprintf(
5438 "SQLSTATE[42000]: Syntax error or access violation: 1061 Duplicate key name '%s'",
5439 $e->get_data()['key_name']
5440 ),
5441 '42S21'
5442 );
5443 case WP_SQLite_Information_Schema_Exception::TYPE_KEY_COLUMN_NOT_FOUND:
5444 return $this->new_driver_exception(
5445 sprintf(
5446 "SQLSTATE[42000]: Syntax error or access violation: 1072 Key column '%s' doesn't exist in table",
5447 $e->get_data()['column_name']
5448 ),
5449 '42000'
5450 );
5451 case WP_SQLite_Information_Schema_Exception::TYPE_CONSTRAINT_DOES_NOT_EXIST:
5452 return $this->new_driver_exception(
5453 sprintf(
5454 "SQLSTATE[HY000]: General error: 3940 Constraint '%s' does not exist.",
5455 $e->get_data()['name']
5456 ),
5457 'HY000'
5458 );
5459 case WP_SQLite_Information_Schema_Exception::TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME:
5460 return $this->new_driver_exception(
5461 sprintf(
5462 "SQLSTATE[HY000]: General error: 3939 Table has multiple constraints with the name '%s'. Please use constraint specific 'DROP' clause.",
5463 $e->get_data()['name']
5464 ),
5465 'HY000'
5466 );
5467 default:
5468 return $e;
5469 }
5470 }
5471 }
5472