PluginProbe
SQLite Database Integration / 3.1.0
SQLite Database Integration v3.1.0
3.1.0 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 All 33 releases
sqlite-database-integration / wp-includes / database / sqlite / class-wp-mysql-on-sqlite.php

class-wp-mysql-on-sqlite.php in SQLite Database Integration 3.1.0, at wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php

8,026 lines 281.3 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 * PDO uses camel case naming, enable non-snake case:
8 * phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
9 * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
10 *
11 * PDO uses $string as a parameter name, enable it:
12 * phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
13 *
14 * We conditionally define a trait for PHP-version-specific PDO methods:
15 * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
16 */
17
18 /*
19 * The "PDO::connect()" method in PHP 8.4 uses a "static" return type declaration,
20 * which PHP 7 cannot parse. Therefore, a conditional file import is needed.
21 */
22 if ( PHP_VERSION_ID >= 80400 ) {
23 require_once __DIR__ . '/trait-wp-mysql-on-sqlite-pdo-compat-php-84.php';
24 } else {
25 /**
26 * @access private
27 */
28 trait WP_MySQL_On_SQLite_PDO_Compat {}
29 }
30
31 /**
32 * SQLite driver for MySQL.
33 *
34 * This class emulates a MySQL database server on top of an SQLite database.
35 * It translates queries written in MySQL SQL dialect to an SQLite SQL dialect,
36 * maintains necessary metadata, and executes the translated queries in SQLite.
37 *
38 * The driver requires PDO with the SQLite driver, and the PCRE engine.
39 */
40 class WP_MySQL_On_SQLite extends PDO {
41 use WP_MySQL_On_SQLite_PDO_Compat;
42
43 /**
44 * The path to the MySQL SQL grammar file.
45 */
46 private const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php';
47
48 /**
49 * The minimum supported MySQL version.
50 */
51 private const MINIMUM_MYSQL_VERSION = 50700;
52
53 /**
54 * The default MySQL version to emulate.
55 */
56 const DEFAULT_MYSQL_VERSION = 80038;
57
58 /**
59 * The minimum required version of SQLite.
60 *
61 * Currently, we require SQLite >= 3.37.0 due to the STRICT table support:
62 * https://www.sqlite.org/stricttables.html
63 */
64 const MINIMUM_SQLITE_VERSION = '3.37.0';
65
66 /**
67 * An identifier prefix for internal database objects.
68 *
69 * @access private
70 *
71 * @TODO: Do not allow accessing objects with this prefix.
72 */
73 const RESERVED_PREFIX = '_wp_sqlite_';
74
75 /**
76 * The name of a global variables table.
77 *
78 * This special table is used to emulate MySQL global variables and to store
79 * some internal configuration values.
80 *
81 * @access private
82 */
83 const GLOBAL_VARIABLES_TABLE_NAME = self::RESERVED_PREFIX . 'global_variables';
84
85 /**
86 * Name of the connection-private TEMP table used to build empty result sets
87 * without acquiring a write lock on the database. See create_result_statement_from_data().
88 */
89 private const EMPTY_RESULT_TABLE_NAME = self::RESERVED_PREFIX . 'empty_result';
90
91 /**
92 * The name of the SQLite driver version variable.
93 *
94 * This internal variable is used to store the latest version of the SQLite
95 * driver that was used to initialize and configure the SQLite database.
96 *
97 * @access private
98 */
99 const DRIVER_VERSION_VARIABLE_NAME = self::RESERVED_PREFIX . 'driver_version';
100
101 /**
102 * MySQL SQL modes mapped to their bitmask values.
103 *
104 * The modes are ordered by their bit position, matching MySQL's canonical
105 * serialization order.
106 *
107 * See:
108 * https://github.com/mysql/mysql-server/blob/8.4/sql/system_variables.h
109 * https://github.com/mysql/mysql-server/blob/5.7/sql/sys_vars.cc
110 */
111 private const SQL_MODES = array(
112 'REAL_AS_FLOAT' => 1 << 0,
113 'PIPES_AS_CONCAT' => 1 << 1,
114 'ANSI_QUOTES' => 1 << 2,
115 'IGNORE_SPACE' => 1 << 3,
116 'NOT_USED' => 1 << 4,
117 'ONLY_FULL_GROUP_BY' => 1 << 5,
118 'NO_UNSIGNED_SUBTRACTION' => 1 << 6,
119 'NO_DIR_IN_CREATE' => 1 << 7,
120 'POSTGRESQL' => 1 << 8,
121 'ORACLE' => 1 << 9,
122 'MSSQL' => 1 << 10,
123 'DB2' => 1 << 11,
124 'MAXDB' => 1 << 12,
125 'NO_KEY_OPTIONS' => 1 << 13,
126 'NO_TABLE_OPTIONS' => 1 << 14,
127 'NO_FIELD_OPTIONS' => 1 << 15,
128 'MYSQL323' => 1 << 16,
129 'MYSQL40' => 1 << 17,
130 'ANSI' => 1 << 18,
131 'NO_AUTO_VALUE_ON_ZERO' => 1 << 19,
132 'NO_BACKSLASH_ESCAPES' => 1 << 20,
133 'STRICT_TRANS_TABLES' => 1 << 21,
134 'STRICT_ALL_TABLES' => 1 << 22,
135 'NO_ZERO_IN_DATE' => 1 << 23,
136 'NO_ZERO_DATE' => 1 << 24,
137 'ALLOW_INVALID_DATES' => 1 << 25,
138 'ERROR_FOR_DIVISION_BY_ZERO' => 1 << 26,
139 'TRADITIONAL' => 1 << 27,
140 'NO_AUTO_CREATE_USER' => 1 << 28,
141 'HIGH_NOT_PRECEDENCE' => 1 << 29,
142 'NO_ENGINE_SUBSTITUTION' => 1 << 30,
143 'PAD_CHAR_TO_FULL_LENGTH' => 1 << 31,
144
145 // Modes below require 64-bit PHP.
146 // TODO: Consider supporting these values on 32-bit PHP as well.
147 'TIME_TRUNCATE_FRACTIONAL' => 1 << 32,
148 );
149
150 /**
151 * The default SQL modes shared by MySQL 5.7 and 8.0.
152 *
153 * MySQL 5.7 additionally enables NO_AUTO_CREATE_USER.
154 */
155 private const DEFAULT_SQL_MODES = array(
156 'ERROR_FOR_DIVISION_BY_ZERO',
157 'NO_ENGINE_SUBSTITUTION',
158 'NO_ZERO_DATE',
159 'NO_ZERO_IN_DATE',
160 'ONLY_FULL_GROUP_BY',
161 'STRICT_TRANS_TABLES',
162 );
163
164 /**
165 * A map of MySQL tokens to SQLite data types.
166 *
167 * This is used to translate a MySQL data type to an SQLite data type.
168 */
169 private const DATA_TYPE_MAP = array(
170 // Numeric data types:
171 WP_MySQL_Lexer::BIT_SYMBOL => 'INTEGER',
172 WP_MySQL_Lexer::BOOL_SYMBOL => 'INTEGER',
173 WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'INTEGER',
174 WP_MySQL_Lexer::TINYINT_SYMBOL => 'INTEGER',
175 WP_MySQL_Lexer::SMALLINT_SYMBOL => 'INTEGER',
176 WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'INTEGER',
177 WP_MySQL_Lexer::INT_SYMBOL => 'INTEGER',
178 WP_MySQL_Lexer::INTEGER_SYMBOL => 'INTEGER',
179 WP_MySQL_Lexer::BIGINT_SYMBOL => 'INTEGER',
180 WP_MySQL_Lexer::FLOAT_SYMBOL => 'REAL',
181 WP_MySQL_Lexer::DOUBLE_SYMBOL => 'REAL',
182 WP_MySQL_Lexer::REAL_SYMBOL => 'REAL',
183 WP_MySQL_Lexer::DECIMAL_SYMBOL => 'REAL',
184 WP_MySQL_Lexer::DEC_SYMBOL => 'REAL',
185 WP_MySQL_Lexer::FIXED_SYMBOL => 'REAL',
186 WP_MySQL_Lexer::NUMERIC_SYMBOL => 'REAL',
187
188 // String data types:
189 WP_MySQL_Lexer::CHAR_SYMBOL => 'TEXT',
190 WP_MySQL_Lexer::VARCHAR_SYMBOL => 'TEXT',
191 WP_MySQL_Lexer::NCHAR_SYMBOL => 'TEXT',
192 WP_MySQL_Lexer::NVARCHAR_SYMBOL => 'TEXT',
193 WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'TEXT',
194 WP_MySQL_Lexer::TEXT_SYMBOL => 'TEXT',
195 WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'TEXT',
196 WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'TEXT',
197 WP_MySQL_Lexer::ENUM_SYMBOL => 'TEXT',
198
199 // Date and time data types:
200 WP_MySQL_Lexer::DATE_SYMBOL => 'TEXT',
201 WP_MySQL_Lexer::TIME_SYMBOL => 'TEXT',
202 WP_MySQL_Lexer::DATETIME_SYMBOL => 'TEXT',
203 WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'TEXT',
204 WP_MySQL_Lexer::YEAR_SYMBOL => 'TEXT',
205
206 // Binary data types:
207 WP_MySQL_Lexer::BINARY_SYMBOL => 'BLOB',
208 WP_MySQL_Lexer::VARBINARY_SYMBOL => 'BLOB',
209 WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB',
210 WP_MySQL_Lexer::BLOB_SYMBOL => 'BLOB',
211 WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB',
212 WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB',
213
214 // Spatial data types:
215 WP_MySQL_Lexer::GEOMETRY_SYMBOL => 'TEXT',
216 WP_MySQL_Lexer::POINT_SYMBOL => 'TEXT',
217 WP_MySQL_Lexer::LINESTRING_SYMBOL => 'TEXT',
218 WP_MySQL_Lexer::POLYGON_SYMBOL => 'TEXT',
219 WP_MySQL_Lexer::MULTIPOINT_SYMBOL => 'TEXT',
220 WP_MySQL_Lexer::MULTILINESTRING_SYMBOL => 'TEXT',
221 WP_MySQL_Lexer::MULTIPOLYGON_SYMBOL => 'TEXT',
222 WP_MySQL_Lexer::GEOMCOLLECTION_SYMBOL => 'TEXT',
223 WP_MySQL_Lexer::GEOMETRYCOLLECTION_SYMBOL => 'TEXT',
224
225 // SERIAL, SET, and JSON types are handled in the translation process.
226 );
227
228 /**
229 * A map of normalized MySQL data types to SQLite data types.
230 *
231 * This is used to generate SQLite CREATE TABLE statements from the MySQL
232 * INFORMATION_SCHEMA tables. They keys are MySQL data types normalized
233 * as they appear in the INFORMATION_SCHEMA. Values are SQLite data types.
234 */
235 private const DATA_TYPE_STRING_MAP = array(
236 // Numeric data types:
237 'bit' => 'INTEGER',
238 'bool' => 'INTEGER',
239 'boolean' => 'INTEGER',
240 'tinyint' => 'INTEGER',
241 'smallint' => 'INTEGER',
242 'mediumint' => 'INTEGER',
243 'int' => 'INTEGER',
244 'integer' => 'INTEGER',
245 'bigint' => 'INTEGER',
246 'float' => 'REAL',
247 'double' => 'REAL',
248 'real' => 'REAL',
249 'decimal' => 'REAL',
250 'dec' => 'REAL',
251 'fixed' => 'REAL',
252 'numeric' => 'REAL',
253
254 // String data types:
255 'char' => 'TEXT',
256 'varchar' => 'TEXT',
257 'nchar' => 'TEXT',
258 'nvarchar' => 'TEXT',
259 'tinytext' => 'TEXT',
260 'text' => 'TEXT',
261 'mediumtext' => 'TEXT',
262 'longtext' => 'TEXT',
263 'enum' => 'TEXT',
264 'set' => 'TEXT',
265 'json' => 'TEXT',
266
267 // Date and time data types:
268 'date' => 'TEXT',
269 'time' => 'TEXT',
270 'datetime' => 'TEXT',
271 'timestamp' => 'TEXT',
272 'year' => 'TEXT',
273
274 // Binary data types:
275 'binary' => 'BLOB',
276 'varbinary' => 'BLOB',
277 'tinyblob' => 'BLOB',
278 'blob' => 'BLOB',
279 'mediumblob' => 'BLOB',
280 'longblob' => 'BLOB',
281
282 // Spatial data types:
283 'geometry' => 'TEXT',
284 'point' => 'TEXT',
285 'linestring' => 'TEXT',
286 'polygon' => 'TEXT',
287 'multipoint' => 'TEXT',
288 'multilinestring' => 'TEXT',
289 'multipolygon' => 'TEXT',
290 'geomcollection' => 'TEXT',
291 'geometrycollection' => 'TEXT',
292 );
293
294 /**
295 * A map of MySQL to SQLite date format translation.
296 *
297 * It maps MySQL DATE_FORMAT() formats to SQLite STRFTIME() formats.
298 *
299 * For MySQL formats, see:
300 * https://dev.mysql.com/doc/refman/5.7/en/date-and-time-functions.html#function_date-format
301 *
302 * For SQLite formats, see:
303 * https://www.sqlite.org/lang_datefunc.html
304 * https://strftime.org/
305 */
306 private const MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAP = array(
307 '%a' => '%D',
308 '%b' => '%M',
309 '%c' => '%n',
310 '%D' => '%jS',
311 '%d' => '%d',
312 '%e' => '%j',
313 '%H' => '%H',
314 '%h' => '%h',
315 '%I' => '%h',
316 '%i' => '%M',
317 '%j' => '%z',
318 '%k' => '%G',
319 '%l' => '%g',
320 '%M' => '%F',
321 '%m' => '%m',
322 '%p' => '%A',
323 '%r' => '%h:%i:%s %A',
324 '%S' => '%s',
325 '%s' => '%s',
326 '%T' => '%H:%i:%s',
327 '%U' => '%W',
328 '%u' => '%W',
329 '%V' => '%W',
330 '%v' => '%W',
331 '%W' => '%l',
332 '%w' => '%w',
333 '%X' => '%Y',
334 '%x' => '%o',
335 '%Y' => '%Y',
336 '%y' => '%y',
337 );
338
339 /**
340 * A map of MySQL data types to implicit default values for non-strict mode.
341 *
342 * In MySQL, when STRICT_TRANS_TABLES and STRICT_ALL_TABLES modes are disabled,
343 * columns get IMPLICIT DEFAULT values that are used under some circumstances.
344 *
345 * See:
346 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
347 */
348 private const DATA_TYPE_IMPLICIT_DEFAULT_MAP = array(
349 // Numeric data types:
350 'bit' => '0',
351 'bool' => '0',
352 'boolean' => '0',
353 'tinyint' => '0',
354 'smallint' => '0',
355 'mediumint' => '0',
356 'int' => '0',
357 'integer' => '0',
358 'bigint' => '0',
359 'float' => '0',
360 'double' => '0',
361 'real' => '0',
362 'decimal' => '0',
363 'dec' => '0',
364 'fixed' => '0',
365 'numeric' => '0',
366
367 // String data types:
368 'char' => '',
369 'varchar' => '',
370 'nchar' => '',
371 'nvarchar' => '',
372 'tinytext' => '',
373 'text' => '',
374 'mediumtext' => '',
375 'longtext' => '',
376 'enum' => '', // TODO: Implement (first enum value).
377 'set' => '',
378 'json' => 'null', // String value 'null' (valid JSON)
379
380 // Date and time data types:
381 'date' => '0000-00-00',
382 'time' => '00:00:00',
383 'datetime' => '0000-00-00 00:00:00',
384 'timestamp' => '0000-00-00 00:00:00',
385 'year' => '0000',
386
387 // Binary data types:
388 'binary' => '',
389 'varbinary' => '',
390 'tinyblob' => '',
391 'blob' => '',
392 'mediumblob' => '',
393 'longblob' => '',
394
395 // Spatial data types (no implicit defaults):
396 'geometry' => null,
397 'point' => null,
398 'linestring' => null,
399 'polygon' => null,
400 'multipoint' => null,
401 'multilinestring' => null,
402 'multipolygon' => null,
403 'geomcollection' => null,
404 'geometrycollection' => null,
405 );
406
407 /**
408 * A map of MySQL column data types to native types in MySQL column meta.
409 *
410 * This maps normalized MySQL column data types (as per information schema)
411 * to MySQL "PDOStatement::getColumnMeta()" data types in the "native_type"
412 * field, as well as the "len" and "precision" fields, where applicable:
413 *
414 * <mysql-column-type> => array( <native_type>, <mysqli_type>, <len>, <precision> )
415 *
416 * This is used to compute the column metadata from the information schema.
417 */
418 private const COLUMN_INFO_MYSQL_TO_NATIVE_TYPES_MAP = array(
419 // Numeric data types:
420 'bit' => array( 'BIT', 16, 1, 0 ),
421 'tinyint' => array( 'TINY', 1, 4, 0 ),
422 'smallint' => array( 'SHORT', 2, 6, 0 ),
423 'mediumint' => array( 'INT24', 9, 9, 0 ),
424 'int' => array( 'LONG', 3, 11, 0 ),
425 'bigint' => array( 'LONGLONG', 8, 20, 0 ),
426 'float' => array( 'FLOAT', 4, 12, 31 ),
427 'double' => array( 'DOUBLE', 5, 22, 31 ),
428 'decimal' => array( 'NEWDECIMAL', 246, null, null ),
429
430 // String data types:
431 'char' => array( 'STRING', 254, null, 0 ),
432 'varchar' => array( 'VAR_STRING', 253, null, 0 ),
433 'tinytext' => array( 'BLOB', 252, null, 0 ),
434 'text' => array( 'BLOB', 252, null, 0 ),
435 'mediumtext' => array( 'BLOB', 252, null, 0 ),
436 'longtext' => array( 'BLOB', 252, null, 0 ),
437 'enum' => array( 'STRING', 254, null, 0 ),
438 'set' => array( 'STRING', 254, null, 0 ),
439 'json' => array( 'BLOB', 245, 4294967295, 0 ),
440
441 // Date and time data types:
442 'date' => array( 'DATE', 10, 10, 0 ),
443 'time' => array( 'TIME', 11, 10, 0 ),
444 'datetime' => array( 'DATETIME', 12, 19, 0 ),
445 'timestamp' => array( 'TIMESTAMP', 7, 19, 0 ),
446 'year' => array( 'YEAR', 13, 4, 0 ),
447
448 // Binary data types:
449 'binary' => array( 'BLOB', 254, null, 0 ),
450 'varbinary' => array( 'BLOB', 253, null, 0 ),
451 'tinyblob' => array( 'BLOB', 252, null, 0 ),
452 'blob' => array( 'BLOB', 252, null, 0 ),
453 'mediumblob' => array( 'BLOB', 252, null, 0 ),
454 'longblob' => array( 'BLOB', 252, null, 0 ),
455
456 // Spatial data types:
457 'geometry' => array( 'GEOMETRY', 255, 4294967295, 0 ),
458 'point' => array( 'GEOMETRY', 255, 4294967295, 0 ),
459 'linestring' => array( 'GEOMETRY', 255, 4294967295, 0 ),
460 'polygon' => array( 'GEOMETRY', 255, 4294967295, 0 ),
461 'multipoint' => array( 'GEOMETRY', 255, 4294967295, 0 ),
462 'multilinestring' => array( 'GEOMETRY', 255, 4294967295, 0 ),
463 'multipolygon' => array( 'GEOMETRY', 255, 4294967295, 0 ),
464 'geomcollection' => array( 'GEOMETRY', 255, 4294967295, 0 ),
465 );
466
467 /**
468 * A map of SQLite column definition data types and SQLite column meta data
469 * types to native types in MySQL column meta.
470 *
471 * This maps both SQLite column definition data types and SQLite column meta
472 * data types (as per "PDOStatement::getColumnMeta()") to MySQL column meta
473 * "native_type" field, as per "PDOStatement::getColumnMeta()", as well as
474 * the "len" and "precision" fields, where applicable:
475 *
476 * <sqlite-column-definition-type> => array( <native_type>, <mysqli_type>, <len>, <precision> )
477 * <sqlite-column-meta-type> => array( <native_type>, <mysqli_type>, <len>, <precision> )
478 *
479 * This is used to compute the MySQL column metadata for non-column fields
480 * that have no records in the information schema (i.e., expressions).
481 */
482 private const COLUMN_INFO_SQLITE_TO_NATIVE_TYPES_MAP = array(
483 'NULL' => array( 'NULL', 6, 0, 0 ),
484 'INT' => array( 'LONGLONG', 8, 21, 0 ),
485 'INTEGER' => array( 'LONGLONG', 8, 21, 0 ),
486 'STRING' => array( 'VAR_STRING', 253, 65535, 31 ),
487 'TEXT' => array( 'BLOB', 252, null, 0 ),
488 'REAL' => array( 'DOUBLE', 5, 22, 31 ),
489 'DOUBLE' => array( 'DOUBLE', 5, 23, 31 ),
490 'BLOB' => array( 'BLOB', 252, null, 0 ),
491 );
492
493 /**
494 * The version of the MySQL server that the driver is configured for.
495 *
496 * @var int
497 */
498 private $mysql_version;
499
500 /**
501 * The emulated MySQL client library version.
502 *
503 * This is a mysqli-like property that is needed to avoid a PHP warning in
504 * the WordPress health info. The "WP_Debug_Data::get_wp_database()" method
505 * calls "$wpdb->dbh->client_info" - a mysqli-specific abstraction leak.
506 *
507 * @TODO: This should be fixed in WordPress core.
508 *
509 * See:
510 * https://github.com/WordPress/wordpress-develop/blob/bcdca3f9925f1d3eca7b78d231837c0caf0c8c24/src/wp-admin/includes/class-wp-debug-data.php#L1579
511 *
512 * @var string
513 */
514 public $client_info;
515
516 /**
517 * A MySQL query parser grammar.
518 *
519 * @var WP_Parser_Grammar
520 */
521 private static $mysql_grammar;
522
523 /**
524 * A reusable parser instance for MySQL queries.
525 *
526 * @var WP_MySQL_Parser|null
527 */
528 private $mysql_parser = null;
529
530 /**
531 * The main database name.
532 *
533 * The name of the main database that is used by the driver.
534 *
535 * @var string|null
536 */
537 private $main_db_name;
538
539 /**
540 * The name of the current database in use.
541 *
542 * This can be set with the USE statement. At the moment, we support only
543 * the main driver database and the INFORMATION_SCHEMA database.
544 *
545 * @var string
546 */
547 private $db_name;
548
549 /**
550 * An instance of the SQLite connection.
551 *
552 * @var WP_SQLite_Connection
553 */
554 private $connection;
555
556 /**
557 * Caller-visible PDO error mode.
558 *
559 * The underlying SQLite connection always uses exceptions so internal
560 * operations can handle errors reliably.
561 *
562 * @var int
563 */
564 private $error_mode = PDO::ERRMODE_EXCEPTION;
565
566 /**
567 * Whether fetched scalar values should be converted to strings.
568 *
569 * PDO SQLite cannot report PDO::ATTR_STRINGIFY_FETCHES on PHP 7.2–8.1,
570 * so the wrapper tracks its value on those versions.
571 *
572 * @var bool
573 */
574 private $stringify_fetches = false;
575
576 /**
577 * SQLSTATE associated with the last PDO operation.
578 *
579 * @var string|null
580 */
581 private $error_code;
582
583 /**
584 * Error information associated with the last PDO operation.
585 *
586 * @var array
587 */
588 private $error_info = array( '', null, null );
589
590 /**
591 * ID generated by the last user-issued INSERT or REPLACE statement.
592 *
593 * @var string
594 */
595 private $last_insert_id = '0';
596
597 /**
598 * User-defined functions registered on the SQLite connection.
599 *
600 * @var WP_SQLite_PDO_User_Defined_Functions
601 */
602 private $user_defined_functions;
603
604 /**
605 * A service for managing MySQL INFORMATION_SCHEMA tables in SQLite.
606 *
607 * @var WP_SQLite_Information_Schema_Builder
608 */
609 private $information_schema_builder;
610
611 /**
612 * Last executed MySQL query.
613 *
614 * @var string
615 */
616 private $last_mysql_query;
617
618 /**
619 * A list of SQLite queries executed for the last MySQL query.
620 *
621 * @var array{ sql: string, params: array }[]
622 */
623 private $last_sqlite_queries = array();
624
625 /**
626 * A PDO SQLite statement that represents the result of the last emulated query.
627 *
628 * @var PDOStatement|null
629 */
630 private $last_result_statement;
631
632 /**
633 * Override for the number of affected rows by the last emulated query.
634 *
635 * By default, the number of affected rows is carried by the row count value
636 * of "$this->last_result_statement". This property serves as an override for
637 * when the row count of the emulated query and statement don't match.
638 *
639 * @var int|null
640 */
641 private $last_affected_rows;
642
643 /**
644 * SQLite column metadata for the last emulated query.
645 *
646 * @var array
647 */
648 private $last_column_meta = array();
649
650 /**
651 * Data for emulating the "FOUND_ROWS()" function.
652 *
653 * When "SQL_CALC_FOUND_ROWS" is used, the appropriate value is stored here.
654 * Otherwise, it's used to store the last number of found rows, or a query
655 * that returns the rows that need to be counted for usage in "FOUND_ROWS()".
656 *
657 * From MySQL documentation:
658 * In the absence of the SQL_CALC_FOUND_ROWS option in the most recent
659 * successful SELECT statement, FOUND_ROWS() returns the number of rows
660 * in the result set returned by that statement.
661 *
662 * In reality, this applies to SHOW and DESCRIBE statements as well.
663 *
664 * The value can be:
665 * - integer: The number of rows to be directly returned by "FOUND_ROWS()".
666 * - string: A SQLite query whose result set rows need to be counted.
667 * - array: A tuple of a SQLite query and its parameters whose result
668 * set rows need to be counted.
669 *
670 * @var int|string|array{0: string, 1: array}
671 */
672 private $found_rows = 0;
673
674 /**
675 * Whether the current MySQL query is read-only.
676 *
677 * @var bool
678 */
679 private $is_readonly;
680
681 /**
682 * Whether the TEMP table backing empty result sets has been created on this
683 * connection. See create_result_statement_from_data().
684 *
685 * @var bool
686 */
687 private $empty_result_table_ready = false;
688
689 /**
690 * Type of wrapper transaction that is active for the MySQL query emulation.
691 *
692 * Possible values:
693 * - null: No wrapper transaction is active.
694 * - 'transaction': A top-level transaction is active.
695 * - 'savepoint': A nested savepoint is active.
696 *
697 * @var null|'transaction'|'savepoint'
698 */
699 private $wrapper_transaction_type = null;
700
701 /**
702 * Whether an SQLite transaction is active in the current session.
703 *
704 * This is a polyfill of the "PDO::inTransaction()" method for PHP < 8.4,
705 * where the "PDO::inTransaction()" method is not reliable with SQLite.
706 *
707 * @see https://bugs.php.net/bug.php?id=81227
708 * @see https://github.com/php/php-src/pull/14268
709 *
710 * @var bool
711 */
712 private $in_transaction = false;
713
714 /**
715 * Names of active user savepoints, outermost first.
716 *
717 * MySQL replaces a savepoint with the same name, while SQLite only shadows it.
718 * Tracking normalized names prevents shadowed SQLite savepoints from becoming
719 * visible again and supports case-insensitive lookup.
720 *
721 * @var string[]
722 */
723 private $savepoint_names = array();
724
725 /**
726 * Whether a MySQL table lock is active.
727 *
728 * Set to "true" when a lock is acquired using the MySQL LOCK statement.
729 * Set to "false" when locks are released using the MySQL UNLOCK statement.
730 *
731 * @var bool
732 */
733 private $table_lock_active = false;
734
735 /**
736 * The PDO fetch mode used for the emulated query.
737 *
738 * @var mixed
739 */
740 private $pdo_fetch_mode;
741
742 /**
743 * The currently active MySQL SQL modes.
744 *
745 * The default value reflects the default SQL modes for MySQL 8.0.
746 *
747 * TODO: This may be represented using a temporary table in the future,
748 * together with GLOBAL SQL mode (a non-temporary table).
749 *
750 * @var int
751 */
752 private $active_sql_modes;
753
754 /**
755 * A name-to-value map of MySQL system variables for the current session.
756 *
757 * MySQL session system variables are session-specific, so we can store them
758 * in-memory. In SQL queries, they are combined with global system variables.
759 *
760 * See:
761 * https://dev.mysql.com/doc/refman/8.4/en/using-system-variables.html
762 *
763 * @var array<string, string>
764 */
765 private $session_system_variables = array();
766
767 /**
768 * A name-to-value map of MySQL user variables.
769 *
770 * MySQL user variables are session-specific, so we can store them in-memory.
771 *
772 * See:
773 * https://dev.mysql.com/doc/refman/8.4/en/user-variables.html
774 *
775 * @var array<string, string>
776 */
777 private $user_variables = array();
778
779 /**
780 * PDO API: Constructor.
781 *
782 * Set up an SQLite connection and the MySQL-on-SQLite driver.
783 *
784 * @param string $dsn MySQL-on-SQLite DSN containing the SQLite path and database name.
785 * @param string|null $username Optional. Ignored by this driver.
786 * @param string|null $password Optional. Ignored by this driver.
787 * @param array|null $options {
788 * Optional PDO attributes and WP_MySQL_On_SQLite options.
789 *
790 * Numeric keys are handled as standard PDO constructor options.
791 * Driver-specific PDO options are not supported.
792 *
793 * @type int $mysql_version Optional. MySQL version to emulate. Default 80038.
794 * @type PDO|null $sqlite_pdo Optional. Existing PDO SQLite connection.
795 * @type string|null $sqlite_journal_mode Optional. SQLite journal mode. Default 'WAL'.
796 * @type string|int|null $sqlite_synchronous Optional. SQLite synchronous setting.
797 * }
798 *
799 * @throws InvalidArgumentException When the MySQL version is invalid.
800 * @throws WP_MySQL_On_SQLite_Exception When the driver initialization fails.
801 */
802 public function __construct(
803 string $dsn,
804 ?string $username = null,
805 ?string $password = null,
806 ?array $options = null
807 ) {
808 $options = $options ?? array();
809
810 // PDO DSN can't include "\0" bytes; parsing stops at the first one.
811 $first_null_byte_index = strpos( $dsn, "\0" );
812 if ( false !== $first_null_byte_index ) {
813 $dsn = substr( $dsn, 0, $first_null_byte_index );
814 }
815
816 // Parse the DSN.
817 $dsn_parts = explode( ':', $dsn, 2 );
818 if ( count( $dsn_parts ) < 2 ) {
819 throw new PDOException( 'invalid data source name' );
820 }
821
822 $driver = $dsn_parts[0];
823 if ( 'mysql-on-sqlite' !== $driver ) {
824 throw new PDOException( 'could not find driver' );
825 }
826
827 // PDO DSN supports semicolon escaping using double semicolon sequences.
828 // Replace ";;" with "\0" to preserve escaped semicolons in "explode()".
829 $args_string = str_replace( ';;', "\0", $dsn_parts[1] );
830 $args = array();
831 foreach ( explode( ';', $args_string ) as $arg ) {
832 // Restore escaped semicolons that were replaced with "\0".
833 $arg = str_replace( "\0", ';', $arg );
834
835 // PDO DSN allows whitespace before argument name. Trim characters
836 // as per the "isspace()" C function (in the default "C" locale).
837 $arg = ltrim( $arg, " \n\r\t\v\f" );
838
839 if ( '' === $arg ) {
840 continue;
841 }
842 $arg_parts = explode( '=', $arg, 2 );
843 $args[ $arg_parts[0] ] = $arg_parts[1] ?? null;
844 }
845
846 $path = $args['path'] ?? ':memory:';
847 $db_name = $args['dbname'] ?? 'sqlite_database';
848
849 $mysql_version = $options['mysql_version'] ?? self::DEFAULT_MYSQL_VERSION;
850 if ( ! is_int( $mysql_version ) || $mysql_version < self::MINIMUM_MYSQL_VERSION ) {
851 throw new InvalidArgumentException(
852 sprintf(
853 'The "mysql_version" option must be an integer greater than or equal to %d.',
854 self::MINIMUM_MYSQL_VERSION
855 )
856 );
857 }
858
859 // Create a new SQLite connection.
860 $pdo_options = array_filter(
861 $options,
862 function ( $key ) {
863 return is_int( $key );
864 },
865 ARRAY_FILTER_USE_KEY
866 );
867
868 $connection_pdo_options = array();
869 if ( array_key_exists( PDO::ATTR_PERSISTENT, $pdo_options ) ) {
870 // Persistence is connection-time-only. Do not apply it again after initialization.
871 $connection_pdo_options[ PDO::ATTR_PERSISTENT ] = $pdo_options[ PDO::ATTR_PERSISTENT ];
872 unset( $pdo_options[ PDO::ATTR_PERSISTENT ] );
873 }
874
875 $connection_options = array(
876 'journal_mode' => $options['sqlite_journal_mode'] ?? null,
877 'synchronous' => $options['sqlite_synchronous'] ?? null,
878 'pdo_options' => $connection_pdo_options,
879 );
880 if ( isset( $options['sqlite_pdo'] ) ) {
881 $connection_options['pdo'] = $options['sqlite_pdo'];
882 } else {
883 $connection_options['path'] = $path;
884 }
885 $this->connection = new WP_SQLite_Connection( $connection_options );
886
887 $this->mysql_version = $mysql_version;
888 $this->main_db_name = $db_name;
889 $this->db_name = $db_name;
890 $this->set_sql_modes( $this->get_default_sql_modes() );
891
892 // Check the database name.
893 if ( '' === $this->db_name ) {
894 throw $this->new_driver_exception( 'The database name cannot be empty.' );
895 }
896
897 // Check the SQLite version.
898 $sqlite_version = $this->get_sqlite_version();
899 if ( version_compare( $sqlite_version, self::MINIMUM_SQLITE_VERSION, '<' ) ) {
900 if ( defined( 'WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS' ) && WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS ) {
901 // When "WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS" is enabled,
902 // allow using legacy SQLite versions, but not older than 3.27.0.
903 if ( version_compare( $sqlite_version, '3.27.0', '<' ) ) {
904 throw $this->new_driver_exception(
905 sprintf(
906 'The SQLite version %s is not supported. Minimum required version is %s.'
907 . ' With "WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS" enabled, you must use 3.27.0 or newer.',
908 $sqlite_version,
909 self::MINIMUM_SQLITE_VERSION
910 )
911 );
912 }
913
914 /*
915 * SQLite versions prior to 3.37.0 do not support STRICT tables.
916 *
917 * However, a database created with SQLite >= 3.37.0 can be used
918 * with SQLite versions < 3.37.0 when "PRAGMA writable_schema" is
919 * set to "ON", which also enables error-tolerant schema parsing.
920 *
921 * This is an unsafe opt-in feature for special backward compatibility
922 * use cases, as it can corrupt the database by allowing incorrect
923 * types into STRICT tables. Additionally, depending on the legacy
924 * SQLite version used, there is no guarantee that all features of
925 * the SQLite driver will work as expected. Use this with caution.
926 *
927 * See: https://www.sqlite.org/stricttables.html#accessing_strict_tables_in_earlier_versions_of_sqlite
928 *
929 * TODO: Remove this flag when we drop support for PHP 8.0.
930 * From PHP 8.1, SQLite 3.46.1 is used by default.
931 */
932 $this->execute_sqlite_query( 'PRAGMA writable_schema=ON' );
933 } else {
934 throw $this->new_driver_exception(
935 sprintf(
936 'The SQLite version %s is not supported. Minimum required version is %s.',
937 $sqlite_version,
938 self::MINIMUM_SQLITE_VERSION
939 )
940 );
941 }
942 }
943
944 // Load MySQL client information to a property used by WordPress health info.
945 $this->client_info = $this->getAttribute( PDO::ATTR_CLIENT_VERSION );
946
947 // Enable foreign keys. By default, they are off.
948 $this->connection->query( 'PRAGMA foreign_keys = ON' );
949
950 // Register SQLite functions.
951 $this->user_defined_functions = WP_SQLite_PDO_User_Defined_Functions::register_for( $this->connection->get_pdo() );
952
953 // Load MySQL grammar.
954 if ( null === self::$mysql_grammar ) {
955 self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH );
956 }
957
958 // Initialize information schema builder.
959 $this->information_schema_builder = new WP_SQLite_Information_Schema_Builder(
960 self::RESERVED_PREFIX,
961 $this->connection
962 );
963
964 // Ensure that the database is configured.
965 $migrator = new WP_SQLite_Configurator( $this, $this->information_schema_builder );
966 $migrator->ensure_database_configured();
967
968 $this->connection->set_query_logger(
969 function ( string $sql, array $params ) {
970 $this->last_sqlite_queries[] = array(
971 'sql' => $sql,
972 'params' => $params,
973 );
974 }
975 );
976
977 foreach ( $pdo_options as $attribute => $value ) {
978 $this->setAttribute( $attribute, $value );
979 }
980 }
981
982 /**
983 * PDO API: Prepare a MySQL statement for execution.
984 *
985 * Prepared statements are not implemented yet. Report the standard PDO
986 * unsupported-function diagnostic instead of using uninitialized parent
987 * PDO state.
988 *
989 * @param string $query The MySQL statement to prepare.
990 * @param array|null $options Optional statement options.
991 * @return PDOStatement|false False when exceptions are disabled.
992 *
993 * @throws WP_MySQL_On_SQLite_Exception When exception mode is enabled.
994 */
995 #[ReturnTypeWillChange]
996 public function prepare( $query, $options = null ) {
997 $driver_message = 'driver does not support prepared statements';
998 $exception = $this->new_driver_exception(
999 'SQLSTATE[IM001]: Driver does not support this function: ' . $driver_message,
1000 'IM001',
1001 null,
1002 array( 'IM001', 0, $driver_message )
1003 );
1004 return $this->handle_pdo_error( $exception );
1005 }
1006
1007 /**
1008 * PDO API: Translate and execute a MySQL query in SQLite.
1009 *
1010 * A single MySQL query can be translated into zero or more SQLite queries.
1011 *
1012 * @param string $query Full SQL statement string.
1013 * @param int|null $fetch_mode Optional. PDO fetch mode. Defaults to the configured
1014 * PDO::ATTR_DEFAULT_FETCH_MODE.
1015 * @param mixed ...$fetch_mode_args Additional fetch mode arguments.
1016 *
1017 * @return PDOStatement|false PDO statement, or false when the fetch mode is invalid on PHP < 8.1.
1018 *
1019 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
1020 */
1021 #[ReturnTypeWillChange]
1022 public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_args ) {
1023 // Validate and parse the fetch mode and arguments.
1024 $arg_count = func_num_args();
1025 $arg_colno = 0;
1026 $arg_class = null;
1027 $arg_constructor_args = array();
1028 $arg_into = null;
1029
1030 $get_type = function ( $value ) {
1031 $type = gettype( $value );
1032 if ( 'boolean' === $type ) {
1033 return 'bool';
1034 } elseif ( 'integer' === $type ) {
1035 return 'int';
1036 } elseif ( 'double' === $type ) {
1037 return 'float';
1038 }
1039 return $type;
1040 };
1041
1042 if ( null === $fetch_mode ) {
1043 if ( PHP_VERSION_ID < 80100 && func_num_args() > 1 ) {
1044 trigger_error(
1045 'PDO::query(): SQLSTATE[HY000]: General error: mode must be an integer',
1046 E_USER_WARNING
1047 );
1048 return false;
1049 }
1050 } elseif ( PDO::FETCH_COLUMN === $fetch_mode ) {
1051 if ( 3 !== $arg_count ) {
1052 throw new ArgumentCountError(
1053 sprintf( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, %d given', $arg_count )
1054 );
1055 }
1056 if ( ! is_int( $fetch_mode_args[0] ) ) {
1057 throw new TypeError(
1058 sprintf( 'PDO::query(): Argument #3 must be of type int, %s given', $get_type( $fetch_mode_args[0] ) )
1059 );
1060 }
1061 $arg_colno = $fetch_mode_args[0];
1062 } elseif ( PDO::FETCH_CLASS === $fetch_mode ) {
1063 if ( $arg_count < 3 ) {
1064 throw new ArgumentCountError(
1065 sprintf( 'PDO::query() expects at least 3 arguments for the fetch mode provided, %d given', $arg_count )
1066 );
1067 }
1068 if ( $arg_count > 4 ) {
1069 throw new ArgumentCountError(
1070 sprintf( 'PDO::query() expects at most 4 arguments for the fetch mode provided, %d given', $arg_count )
1071 );
1072 }
1073 if ( ! is_string( $fetch_mode_args[0] ) ) {
1074 throw new TypeError(
1075 sprintf( 'PDO::query(): Argument #3 must be of type string, %s given', $get_type( $fetch_mode_args[0] ) )
1076 );
1077 }
1078 if ( ! class_exists( $fetch_mode_args[0] ) ) {
1079 throw new TypeError( 'PDO::query(): Argument #3 must be a valid class' );
1080 }
1081 if ( 4 === $arg_count && ! is_array( $fetch_mode_args[1] ) ) {
1082 throw new TypeError(
1083 sprintf( 'PDO::query(): Argument #4 must be of type ?array, %s given', $get_type( $fetch_mode_args[1] ) )
1084 );
1085 }
1086 $arg_class = $fetch_mode_args[0];
1087 $arg_constructor_args = $fetch_mode_args[1] ?? array();
1088 } elseif ( PDO::FETCH_INTO === $fetch_mode ) {
1089 if ( 3 !== $arg_count ) {
1090 throw new ArgumentCountError(
1091 sprintf( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, %d given', $arg_count )
1092 );
1093 }
1094 if ( ! is_object( $fetch_mode_args[0] ) ) {
1095 throw new TypeError(
1096 sprintf( 'PDO::query(): Argument #3 must be of type object, %s given', $get_type( $fetch_mode_args[0] ) )
1097 );
1098 }
1099 $arg_into = $fetch_mode_args[0];
1100 } elseif ( $arg_count > 2 ) {
1101 throw new ArgumentCountError(
1102 sprintf( 'PDO::query() expects exactly 2 arguments for the fetch mode provided, %d given', $arg_count )
1103 );
1104 }
1105
1106 $this->flush();
1107 $this->last_mysql_query = $query;
1108
1109 try {
1110 // Parse the MySQL query.
1111 $parser = $this->create_parser( $query );
1112 $parser->next_query();
1113 $ast = $parser->get_query_ast();
1114 if ( null === $ast ) {
1115 throw $this->new_driver_exception( 'Failed to parse the MySQL query.' );
1116 }
1117
1118 if ( $parser->next_query() ) {
1119 throw $this->new_driver_exception( 'Multi-query is not supported.' );
1120 }
1121
1122 /*
1123 * Determine if we need to wrap the translated queries in a transaction.
1124 *
1125 * [GRAMMAR]
1126 * query:
1127 * EOF
1128 * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF)
1129 */
1130 $child_node = $ast->get_first_child_node();
1131 if (
1132 null === $child_node
1133 || 'beginWork' === $child_node->rule_name
1134 || $child_node->has_child_node( 'transactionOrLockingStatement' )
1135 || $child_node->has_child_node( 'selectStatement' )
1136 ) {
1137 $wrap_in_transaction = false;
1138 } else {
1139 $wrap_in_transaction = true;
1140 }
1141
1142 /*
1143 * Detect read-only statements before opening the wrapper transaction.
1144 *
1145 * [GRAMMAR]
1146 * simpleStatement: selectStatement | showStatement | utilityStatement | ...
1147 */
1148 if ( null !== $child_node && $child_node->has_child_node() ) {
1149 $statement_node = $child_node->get_first_child_node();
1150 if (
1151 'selectStatement' === $statement_node->rule_name
1152 || 'showStatement' === $statement_node->rule_name
1153 // Supported SET statements mutate only connection-local driver state
1154 // (sql_mode, user variables, etc.) or fail before touching SQLite.
1155 // They do not write to the SQLite database, so they must not take a
1156 // write lock. Use a deferred BEGIN instead of BEGIN IMMEDIATE.
1157 // This mirrors the SHOW/DESCRIBE handling.
1158 || 'setStatement' === $statement_node->rule_name
1159 ) {
1160 $this->is_readonly = true;
1161 } elseif ( 'utilityStatement' === $statement_node->rule_name ) {
1162 $utility_subnode = $statement_node->get_first_child_node();
1163 if ( null !== $utility_subnode && 'describeStatement' === $utility_subnode->rule_name ) {
1164 $this->is_readonly = true;
1165 }
1166 }
1167 }
1168
1169 if ( $wrap_in_transaction ) {
1170 $this->begin_wrapper_transaction();
1171 }
1172
1173 $this->execute_mysql_query( $ast );
1174
1175 if ( $wrap_in_transaction ) {
1176 $this->commit_wrapper_transaction();
1177 }
1178
1179 if ( null === $this->last_result_statement ) {
1180 $this->last_result_statement = $this->create_result_statement_from_data( array(), array() );
1181 }
1182
1183 $stmt = new WP_MySQL_On_SQLite_Statement(
1184 $this->last_result_statement,
1185 $query,
1186 $this->create_column_meta_resolver( $this->last_column_meta ),
1187 $this->last_affected_rows
1188 );
1189 if ( null !== $fetch_mode ) {
1190 $stmt->setFetchMode( $fetch_mode, ...$fetch_mode_args );
1191 }
1192 $this->error_code = '00000';
1193 $this->error_info = array( '00000', null, null );
1194 return $stmt;
1195 } catch ( Throwable $e ) {
1196 // MySQL error 1305 reports a missing savepoint without ending the transaction.
1197 $preserves_transaction = $e instanceof WP_MySQL_On_SQLite_Exception
1198 && 1305 === ( $e->errorInfo[1] ?? null );
1199 if ( ! $preserves_transaction ) {
1200 try {
1201 $this->rollback_user_transaction();
1202 $this->table_lock_active = false;
1203 } catch ( Throwable $rollback_exception ) {
1204 // Ignore rollback errors.
1205 }
1206 }
1207 if ( $e instanceof WP_SQLite_Information_Schema_Exception ) {
1208 $e = $this->convert_information_schema_exception( $e );
1209 }
1210 if ( $e instanceof WP_MySQL_On_SQLite_Exception ) {
1211 $driver_exception = $e;
1212 } elseif ( $e instanceof PDOException ) {
1213 $driver_exception = $this->convert_sqlite_exception( $e );
1214 } else {
1215 $driver_exception = $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e );
1216 }
1217
1218 return $this->handle_pdo_error( $driver_exception );
1219 } finally {
1220 // A query that doesn't return any rows or fails sets found rows to 0.
1221 if ( ! $this->is_readonly || isset( $e ) ) {
1222 $this->found_rows = 0;
1223 }
1224 }
1225 }
1226
1227 /**
1228 * PDO API: Execute a MySQL statement and return the number of affected rows.
1229 *
1230 * @return int|false The number of affected rows or false on failure.
1231 */
1232 #[ReturnTypeWillChange]
1233 public function exec( $query ) {
1234 $stmt = $this->query( $query );
1235 if ( false === $stmt ) {
1236 return false;
1237 }
1238 return $stmt->rowCount();
1239 }
1240
1241 /**
1242 * PDO API: Return the ID of the last inserted row.
1243 *
1244 * @param string|null $name Optional sequence name. Ignored by SQLite.
1245 * @return string|false The last insert ID, or false on failure.
1246 */
1247 #[ReturnTypeWillChange]
1248 public function lastInsertId( $name = null ) {
1249 if (
1250 is_array( $name )
1251 || is_resource( $name )
1252 || ( is_object( $name ) && ! method_exists( $name, '__toString' ) )
1253 ) {
1254 if ( PHP_VERSION_ID >= 80000 ) {
1255 throw new TypeError(
1256 sprintf(
1257 'PDO::lastInsertId(): Argument #1 ($name) must be of type ?string, %s given',
1258 get_debug_type( $name )
1259 )
1260 );
1261 }
1262 trigger_error(
1263 sprintf( 'PDO::lastInsertId() expects parameter 1 to be string, %s given', strtolower( gettype( $name ) ) ),
1264 E_USER_WARNING
1265 );
1266 return false;
1267 }
1268
1269 return $this->last_insert_id;
1270 }
1271
1272 /**
1273 * PDO API: Fetch the SQLSTATE associated with the last operation.
1274 *
1275 * @return string|null The SQLSTATE error code, or null when unavailable.
1276 */
1277 public function errorCode(): ?string {
1278 return $this->error_code;
1279 }
1280
1281 /**
1282 * PDO API: Fetch error information associated with the last operation.
1283 *
1284 * @return array Error information from the last PDO operation.
1285 */
1286 public function errorInfo(): array {
1287 return $this->error_info;
1288 }
1289
1290 /**
1291 * PDO API: Quote a string for use in a MySQL query.
1292 *
1293 * @param string $string The string to quote.
1294 * @param int $type The PDO parameter type.
1295 * @return string The quoted string.
1296 */
1297 #[ReturnTypeWillChange]
1298 public function quote( $string, $type = PDO::PARAM_STR ) {
1299 // Mirror value validation from the PDO MySQL driver.
1300 if (
1301 is_array( $string )
1302 || is_resource( $string )
1303 || ( is_object( $string ) && ! method_exists( $string, '__toString' ) )
1304 ) {
1305 $given_type = is_object( $string ) ? get_class( $string ) : gettype( $string );
1306 throw new TypeError(
1307 sprintf(
1308 'WP_MySQL_On_SQLite::quote(): Argument #1 ($string) must be of type string, %s given',
1309 $given_type
1310 )
1311 );
1312 }
1313 $string = (string) $string;
1314
1315 // Handle binary and national character prefixes.
1316 $prefix = '';
1317 if ( PDO::PARAM_LOB === ( $type & PDO::PARAM_LOB ) ) {
1318 $prefix = '_binary';
1319 } elseif (
1320 PDO::PARAM_STR_NATL === ( $type & PDO::PARAM_STR_NATL )
1321 && PDO::PARAM_STR_CHAR !== ( $type & PDO::PARAM_STR_CHAR )
1322 ) {
1323 $prefix = 'N';
1324 }
1325
1326 /*
1327 * PDO uses mysqlnd by default and can alternatively use libmysqlclient.
1328 * This escaped character mapping matches the escaping of both drivers.
1329 * Their malformed multibyte sequence handling is not needed for UTF-8.
1330 *
1331 * @see https://github.com/php/php-src/blob/dd6e76cce27aaa0ed9f7520648ed1081dfb6af36/ext/mysqlnd/mysqlnd_charset.c#L905
1332 * @see https://github.com/mysql/mysql-server/blob/dc86e412f18b36ce271f791026714e8caa0ec919/mysys/charset.cc#L413
1333 *
1334 * We can't use "addcslashes()" here, because it has an unusual handling
1335 * of the ASCII NULL and Control+Z characters, escaping them to "\000"
1336 * and "\032" instead of "\0" and "\Z", respectively.
1337 *
1338 * It is important to use "strtr()" and not "str_replace()", because
1339 * "str_replace()" applies replacements one after another, modifying
1340 * intermediate changes rather than just the original string:
1341 *
1342 * - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
1343 * - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
1344 */
1345 $backslash = chr( 92 );
1346 $replacements = array(
1347 chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0).
1348 chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n).
1349 chr( 13 ) => $backslash . 'r', // A carriage return character (\r).
1350 $backslash => $backslash . $backslash, // A backslash character (\).
1351 "'" => $backslash . "'", // A single quote character (').
1352 '"' => $backslash . '"', // A double quote character (").
1353 chr( 26 ) => $backslash . 'Z', // An ASCII 26 (Control+Z) character.
1354 );
1355 return $prefix . "'" . strtr( $string, $replacements ) . "'";
1356 }
1357
1358 /**
1359 * PDO API: Begin a transaction.
1360 *
1361 * @return bool True on success, false on failure.
1362 */
1363 public function beginTransaction(): bool {
1364 if ( $this->inTransaction() ) {
1365 throw $this->new_driver_exception( 'There is already an active transaction' );
1366 }
1367 $this->flush();
1368 $this->begin_user_transaction();
1369 return true;
1370 }
1371
1372 /**
1373 * PDO API: Commit a transaction.
1374 *
1375 * @return bool True on success, false on failure.
1376 */
1377 public function commit(): bool {
1378 if ( ! $this->inTransaction() ) {
1379 throw $this->new_driver_exception( 'There is no active transaction' );
1380 }
1381 $this->flush();
1382 $this->commit_user_transaction();
1383 return true;
1384 }
1385
1386 /**
1387 * PDO API: Rollback a transaction.
1388 *
1389 * @return bool True on success, false on failure.
1390 */
1391 public function rollBack(): bool {
1392 if ( ! $this->inTransaction() ) {
1393 throw $this->new_driver_exception( 'There is no active transaction' );
1394 }
1395 $this->flush();
1396 $this->rollback_user_transaction();
1397 return true;
1398 }
1399
1400 /**
1401 * PDO API: Check if a transaction is active.
1402 *
1403 * @return bool True if a transaction is active, false otherwise.
1404 */
1405 public function inTransaction(): bool {
1406 if ( PHP_VERSION_ID < 80400 ) {
1407 /*
1408 * On PHP < 8.4, the "PDO::inTransaction()" method is not reliable.
1409 *
1410 * @see https://bugs.php.net/bug.php?id=81227
1411 * @see https://github.com/php/php-src/pull/14268
1412 */
1413 return $this->in_transaction;
1414 }
1415 return $this->connection->get_pdo()->inTransaction();
1416 }
1417
1418 /**
1419 * PDO API: Set a PDO attribute.
1420 *
1421 * TODO: Evaluate whether we should pass all PDO attributes to the underlying
1422 * PDO instance, or whether some of them require special handling.
1423 * See: https://github.com/php/php-src/blob/b391c28f903536e3bc6a0021ae0976ddbc2745f8/ext/pdo/php_pdo_driver.h#L103
1424 *
1425 * @param int $attribute The attribute to set.
1426 * @param mixed $value The value of the attribute.
1427 * @return bool True on success, false on failure.
1428 */
1429 public function setAttribute( $attribute, $value ): bool {
1430 // PDO reserves IDs starting at 1000 for driver-specific attributes.
1431 // These differ for MySQL and SQLite and are currently not supported.
1432 if ( is_int( $attribute ) && 1000 <= $attribute ) {
1433 return false;
1434 }
1435
1436 // Track the caller's error mode while keeping internal SQLite operations in exception mode.
1437 if ( PDO::ATTR_ERRMODE === $attribute ) {
1438 $pdo = $this->connection->get_pdo();
1439 $result = $pdo->setAttribute( $attribute, $value );
1440 if ( ! $result ) {
1441 return false;
1442 }
1443 $this->error_mode = $pdo->getAttribute( PDO::ATTR_ERRMODE );
1444 $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
1445 return true;
1446 }
1447
1448 $result = $this->connection->get_pdo()->setAttribute( $attribute, $value );
1449 if ( $result && PDO::ATTR_STRINGIFY_FETCHES === $attribute ) {
1450 $this->stringify_fetches = (bool) $value;
1451 }
1452 return $result;
1453 }
1454
1455 /**
1456 * PDO API: Get a PDO attribute.
1457 *
1458 * TODO: Evaluate whether we should get all PDO attributes from the underlying
1459 * PDO instance, or whether some of them require special handling.
1460 * See: https://github.com/php/php-src/blob/b391c28f903536e3bc6a0021ae0976ddbc2745f8/ext/pdo/php_pdo_driver.h#L103
1461 *
1462 * @param int $attribute The attribute to get.
1463 * @return mixed The value of the attribute.
1464 */
1465 #[ReturnTypeWillChange]
1466 public function getAttribute( $attribute ) {
1467 if ( PDO::ATTR_DRIVER_NAME === $attribute ) {
1468 return 'mysql';
1469 } elseif ( PDO::ATTR_CLIENT_VERSION === $attribute ) {
1470 return 'mysqlnd ' . $this->get_mysql_server_version_string();
1471 } elseif ( PDO::ATTR_SERVER_VERSION === $attribute ) {
1472 return $this->get_mysql_server_version_string();
1473 } elseif ( PDO::ATTR_ERRMODE === $attribute ) {
1474 // Return the caller's error mode instead of the exception mode used internally.
1475 return $this->error_mode;
1476 } elseif ( PDO::ATTR_STRINGIFY_FETCHES === $attribute && PHP_VERSION_ID < 80200 ) {
1477 // PDO SQLite cannot report this attribute before PHP 8.2.
1478 return $this->stringify_fetches;
1479 }
1480 return $this->connection->get_pdo()->getAttribute( $attribute );
1481 }
1482
1483 /**
1484 * Get the underlying PDO SQLite instance.
1485 *
1486 * Exposes the PDO SQLite connection for advanced use, bypassing MySQL emulation.
1487 * Do not retain it across reconnections or modify driver-owned state.
1488 *
1489 * @return PDO The underlying PDO SQLite instance.
1490 */
1491 public function get_sqlite_pdo(): PDO {
1492 return $this->connection->get_pdo();
1493 }
1494
1495 /**
1496 * Get the SQLite connection instance.
1497 *
1498 * @access private
1499 *
1500 * @return WP_SQLite_Connection
1501 */
1502 public function get_connection(): WP_SQLite_Connection {
1503 return $this->connection;
1504 }
1505
1506 /**
1507 * Get the version of the SQLite engine.
1508 *
1509 * @return string SQLite engine version as a string.
1510 */
1511 public function get_sqlite_version(): string {
1512 return $this->connection->get_pdo()->getAttribute( PDO::ATTR_SERVER_VERSION );
1513 }
1514
1515 /**
1516 * Get the SQLite driver version saved in the database.
1517 *
1518 * The saved driver version corresponds to the latest version of the SQLite
1519 * driver that was used to initialize and configure the SQLite database.
1520 *
1521 * @access private
1522 *
1523 * @return string SQLite driver version as a string.
1524 * @throws PDOException When the query execution fails.
1525 */
1526 public function get_saved_driver_version(): string {
1527 $default_version = '0.0.0';
1528 try {
1529 $stmt = $this->execute_sqlite_query(
1530 sprintf(
1531 'SELECT value FROM %s WHERE name = ?',
1532 $this->quote_sqlite_identifier( self::GLOBAL_VARIABLES_TABLE_NAME )
1533 ),
1534 array( self::DRIVER_VERSION_VARIABLE_NAME )
1535 );
1536 return $stmt->fetchColumn() ?? $default_version;
1537 } catch ( PDOException $e ) {
1538 if ( str_contains( $e->getMessage(), 'no such table' ) ) {
1539 return $default_version;
1540 }
1541 throw $e;
1542 }
1543 }
1544
1545 /**
1546 * Check if a specific SQL mode is active.
1547 *
1548 * @access private
1549 *
1550 * @param string $mode The SQL mode to check.
1551 * @return bool True if the SQL mode is active, false otherwise.
1552 */
1553 public function is_sql_mode_active( string $mode ): bool {
1554 $mode = strtoupper( $mode );
1555 if ( 'NOT_USED' === $mode && $this->mysql_version < 80000 ) {
1556 return false;
1557 }
1558 return isset( self::SQL_MODES[ $mode ] )
1559 && ( $this->active_sql_modes & self::SQL_MODES[ $mode ] ) !== 0;
1560 }
1561
1562 /**
1563 * Get the last executed MySQL query.
1564 *
1565 * @access private
1566 *
1567 * @return string|null
1568 */
1569 public function get_last_mysql_query(): ?string {
1570 return $this->last_mysql_query;
1571 }
1572
1573 /**
1574 * Get SQLite queries executed for the last MySQL query.
1575 *
1576 * @access private
1577 *
1578 * @return array{ sql: string, params: array }[]
1579 */
1580 public function get_last_sqlite_queries(): array {
1581 return $this->last_sqlite_queries;
1582 }
1583
1584 /**
1585 * Tokenize a MySQL query and initialize a parser.
1586 *
1587 * @access private
1588 *
1589 * @param string $query The MySQL query to parse.
1590 * @return WP_MySQL_Parser A parser initialized for the MySQL query.
1591 */
1592 public function create_parser( string $query ): WP_MySQL_Parser {
1593 $lexer = new WP_MySQL_Lexer(
1594 $query,
1595 $this->mysql_version,
1596 $this->get_active_sql_mode_names()
1597 );
1598 $tokens = $lexer instanceof WP_MySQL_Native_Lexer
1599 ? $lexer->native_token_stream()
1600 : $lexer->remaining_tokens();
1601 return $this->reset_or_create_parser( $tokens );
1602 }
1603
1604 /**
1605 * Reset the reusable parser with new tokens or create it on first use.
1606 *
1607 * @param array<WP_Parser_Token>|object $tokens Parser tokens.
1608 * @return WP_MySQL_Parser A parser initialized for the token stream.
1609 */
1610 private function reset_or_create_parser( $tokens ): WP_MySQL_Parser {
1611 if ( null === $this->mysql_parser || ! method_exists( $this->mysql_parser, 'reset_tokens' ) ) {
1612 $this->mysql_parser = new WP_MySQL_Parser( self::$mysql_grammar, $tokens );
1613 } else {
1614 $this->mysql_parser->reset_tokens( $tokens );
1615 }
1616
1617 return $this->mysql_parser;
1618 }
1619
1620 /**
1621 * Execute a query in SQLite.
1622 *
1623 * @access private
1624 *
1625 * @param string $sql The query to execute.
1626 * @param array $params The query parameters.
1627 * @throws PDOException When the query execution fails.
1628 * @return PDOStatement The PDO statement object.
1629 */
1630 public function execute_sqlite_query( string $sql, array $params = array() ): PDOStatement {
1631 return $this->connection->query( $sql, $params );
1632 }
1633
1634 /**
1635 * Create a lazy MySQL-compatible column metadata resolver.
1636 *
1637 * Only raw SQLite metadata and database context are snapshotted here.
1638 * The INFORMATION_SCHEMA metadata is resolved lazily and may reflect
1639 * schema changes made after statement execution. This is a trade-off
1640 * that avoids schema queries when column metadata is not requested.
1641 *
1642 * @param array $raw_column_meta Raw SQLite result column metadata.
1643 * @return callable The column metadata resolver.
1644 */
1645 private function create_column_meta_resolver( array $raw_column_meta ): callable {
1646 $db_name = $this->db_name;
1647 return function ( $column ) use ( $raw_column_meta, $db_name ) {
1648 if ( ! array_key_exists( $column, $raw_column_meta ) ) {
1649 return false;
1650 }
1651
1652 $last_sqlite_queries = $this->last_sqlite_queries;
1653 try {
1654 return $this->resolve_column_meta( $raw_column_meta[ $column ], $db_name );
1655 } finally {
1656 $this->last_sqlite_queries = $last_sqlite_queries;
1657 }
1658 };
1659 }
1660
1661 /**
1662 * Resolve raw SQLite column metadata into MySQL-compatible metadata.
1663 *
1664 * @param array $meta Raw SQLite column metadata.
1665 * @param string $db_name Database selected when the query was executed.
1666 * @return array MySQL-compatible column metadata.
1667 */
1668 private function resolve_column_meta( array $meta, string $db_name ): array {
1669 $table = $meta['table'] ?? null;
1670 $name = $meta['name'];
1671 $type = strtoupper( $meta['sqlite:decl_type'] ?? $meta['native_type'] ?? '' );
1672
1673 // When table is known, we can get data from the information schema.
1674 $column_info = null;
1675 if ( null !== $table ) {
1676 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table );
1677 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
1678 $column_info = $this->execute_sqlite_query(
1679 sprintf(
1680 '
1681 SELECT
1682 IS_NULLABLE,
1683 DATA_TYPE,
1684 COLUMN_TYPE,
1685 COLUMN_KEY,
1686 CHARACTER_MAXIMUM_LENGTH,
1687 NUMERIC_PRECISION,
1688 NUMERIC_SCALE
1689 FROM %s
1690 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
1691 ',
1692 $this->quote_sqlite_identifier( $columns_table )
1693 ),
1694 array( $this->get_saved_db_name( $db_name ), $table, $name )
1695 )->fetch( PDO::FETCH_ASSOC );
1696
1697 if ( false === $column_info ) {
1698 $column_info = null;
1699 }
1700 }
1701
1702 // If we have information schema data, we can use it.
1703 if ( null !== $column_info ) {
1704 $type_info = self::COLUMN_INFO_MYSQL_TO_NATIVE_TYPES_MAP[ $column_info['DATA_TYPE'] ] ?? null;
1705 if ( null === $type_info ) {
1706 $type_info = self::COLUMN_INFO_SQLITE_TO_NATIVE_TYPES_MAP[ $type ] ?? null;
1707 }
1708 $native_type = $type_info[0];
1709 $mysqli_type = $type_info[1];
1710 $len = $type_info[2];
1711 $precision = $type_info[3];
1712
1713 if ( 'tinyint(1)' === $column_info['COLUMN_TYPE'] ) {
1714 $len = 1;
1715 }
1716
1717 if ( 'decimal' === $column_info['DATA_TYPE'] ) {
1718 $len = (int) $column_info['NUMERIC_PRECISION'] + (int) $column_info['NUMERIC_SCALE'];
1719 $precision = (int) $column_info['NUMERIC_SCALE'];
1720 }
1721
1722 if (
1723 str_contains( $column_info['COLUMN_TYPE'], 'unsigned' )
1724 && ! str_contains( $column_info['COLUMN_TYPE'], 'bigint' )
1725 ) {
1726 $len -= 1;
1727 }
1728
1729 // If set, length can be taken from the information schema.
1730 if ( isset( $column_info['CHARACTER_MAXIMUM_LENGTH'] ) ) {
1731 $len = (int) $column_info['CHARACTER_MAXIMUM_LENGTH'];
1732 }
1733
1734 // For string types, the length is multiplied by the maximum number
1735 // of bytes per character for the used connection encoding. In our
1736 // case, it's always "utf8mb4" and therefore 4 bytes per character.
1737 if (
1738 str_contains( $column_info['DATA_TYPE'], 'text' )
1739 || str_contains( $column_info['DATA_TYPE'], 'char' )
1740 || 'enum' === $column_info['DATA_TYPE']
1741 || 'set' === $column_info['DATA_TYPE']
1742 ) {
1743 // Except for "longtext" - this might be a MySQL bug.
1744 if ( 'longtext' !== $column_info['DATA_TYPE'] ) {
1745 $len = 4 * $len;
1746 }
1747 }
1748
1749 // Flags.
1750 $flags = array();
1751 if ( 'NO' === $column_info['IS_NULLABLE'] ) {
1752 $flags[] = 'not_null';
1753 }
1754 if ( 'PRI' === $column_info['COLUMN_KEY'] ) {
1755 $flags[] = 'primary_key';
1756 } elseif ( 'UNI' === $column_info['COLUMN_KEY'] ) {
1757 $flags[] = 'unique_key';
1758 } elseif ( 'MUL' === $column_info['COLUMN_KEY'] ) {
1759 $flags[] = 'multiple_key';
1760 }
1761 } else {
1762 $type_info = self::COLUMN_INFO_SQLITE_TO_NATIVE_TYPES_MAP[ $type ];
1763 $native_type = $type_info[0];
1764 $mysqli_type = $type_info[1];
1765 $len = $type_info[2] ?? 0;
1766 $precision = $type_info[3];
1767
1768 // Flags.
1769 $flags = array();
1770 if ( 'NULL' !== $type ) {
1771 $flags[] = 'not_null';
1772 }
1773 }
1774
1775 if ( 'BLOB' === $native_type || 'GEOMETRY' === $native_type ) {
1776 $flags[] = 'blob';
1777 }
1778
1779 // PDO type.
1780 if ( 'INT' === $type || 'INTEGER' === $type ) {
1781 $pdo_type = PDO::PARAM_INT;
1782 } else {
1783 $pdo_type = PDO::PARAM_STR;
1784 }
1785
1786 // MySQLi charset number.
1787 $is_string = 'STRING' === $type || 'TEXT' === $type;
1788 $is_binary = 'BLOB' === $type || 'GEOMETRY' === $native_type;
1789 $is_datetime = str_contains( $native_type, 'DATE' ) || str_contains( $native_type, 'TIME' ) || 'YEAR' === $native_type;
1790 if ( $is_string && ! $is_binary && ! $is_datetime ) {
1791 $mysqli_charsetnr = 255; // utf8mb4_0900_ai_ci
1792 } else {
1793 $mysqli_charsetnr = 63; // binary
1794 }
1795
1796 return array(
1797 'native_type' => $native_type,
1798 'pdo_type' => $pdo_type,
1799 'flags' => $flags,
1800 'table' => $meta['table'] ?? '',
1801 'name' => $meta['name'],
1802 'len' => $len,
1803 'precision' => $precision,
1804 'sqlite:decl_type' => $meta['sqlite:decl_type'] ?? '',
1805
1806 /*
1807 * The MySQLi PHP extension exposes more MySQL column metadata than PDO.
1808 * We'll add the data here for use cases such as "wpdb::get_col_info()".
1809 */
1810 'mysqli:orgname' => $meta['name'], // TODO: Use correct original name when alias is used.
1811 'mysqli:orgtable' => $meta['table'] ?? '', // TODO: Use correct original name when table alias is used.
1812 'mysqli:db' => $db_name, // TODO: Use correct DB for queries to information schema.
1813 'mysqli:charsetnr' => $mysqli_charsetnr,
1814 'mysqli:flags' => 0, // TODO: We can compute correct MySQL flags.
1815 'mysqli:type' => $mysqli_type,
1816 );
1817 }
1818
1819 /**
1820 * Record and report a PDO operation error according to the configured mode.
1821 *
1822 * @param WP_MySQL_On_SQLite_Exception $exception The operation error.
1823 * @return false Always false when the error is not thrown.
1824 * @throws WP_MySQL_On_SQLite_Exception When exception mode is enabled.
1825 */
1826 private function handle_pdo_error( WP_MySQL_On_SQLite_Exception $exception ): bool {
1827 $this->error_info = $exception->errorInfo;
1828 $this->error_code = $this->error_info[0];
1829
1830 if ( PDO::ERRMODE_EXCEPTION === $this->error_mode ) {
1831 throw $exception;
1832 }
1833 if ( PDO::ERRMODE_WARNING === $this->error_mode ) {
1834 trigger_error( $exception->getMessage(), E_USER_WARNING );
1835 }
1836 return false;
1837 }
1838
1839 /**
1840 * Translate and execute a MySQL query in SQLite.
1841 *
1842 * @param WP_Parser_Node $node The "query" AST node with "simpleStatement" child.
1843 * @throws WP_MySQL_On_SQLite_Exception When the query is not supported.
1844 */
1845 private function execute_mysql_query( WP_Parser_Node $node ): void {
1846 if ( 'query' !== $node->rule_name ) {
1847 throw $this->new_driver_exception(
1848 sprintf( 'Expected "query" node, got: "%s"', $node->rule_name )
1849 );
1850 }
1851
1852 /*
1853 * [GRAMMAR]
1854 * query:
1855 * EOF
1856 * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF)
1857 */
1858 $children = $node->get_child_nodes();
1859 if ( count( $children ) !== 1 ) {
1860 throw $this->new_driver_exception(
1861 sprintf( 'Expected 1 child node, got: %d', count( $children ) )
1862 );
1863 }
1864
1865 if ( 'beginWork' === $children[0]->rule_name ) {
1866 $this->begin_user_transaction();
1867 return;
1868 }
1869
1870 if ( 'simpleStatement' !== $children[0]->rule_name ) {
1871 throw $this->new_driver_exception(
1872 sprintf( 'Expected "simpleStatement" node, got: "%s"', $children[0]->rule_name )
1873 );
1874 }
1875
1876 // Process the "simpleStatement" AST node.
1877 $node = $children[0]->get_first_child_node();
1878 switch ( $node->rule_name ) {
1879 case 'transactionOrLockingStatement':
1880 $this->execute_transaction_or_locking_statement( $node );
1881 break;
1882 case 'selectStatement':
1883 $this->execute_select_statement( $node );
1884 break;
1885 case 'insertStatement':
1886 case 'replaceStatement':
1887 $this->execute_insert_or_replace_statement( $node );
1888 break;
1889 case 'updateStatement':
1890 $this->execute_update_statement( $node );
1891 break;
1892 case 'deleteStatement':
1893 $this->execute_delete_statement( $node );
1894 break;
1895 case 'createStatement':
1896 $subtree = $node->get_first_child_node();
1897 switch ( $subtree->rule_name ) {
1898 case 'createDatabase':
1899 /*
1900 * TODO:
1901 * We could support this by creating a new SQLite database
1902 * file (e.g., $slugified_db_name.sqlite).
1903 *
1904 * Alternatively, it could be a no-op, in combination with
1905 * DROP DATABASE deleting the data file and recreating it.
1906 */
1907 case 'createTable':
1908 $this->execute_create_table_statement( $node );
1909 break;
1910 case 'createIndex':
1911 $this->execute_create_index_statement( $node );
1912 break;
1913 default:
1914 throw $this->new_not_supported_exception(
1915 sprintf(
1916 'statement type: "%s" > "%s"',
1917 $node->rule_name,
1918 $subtree->rule_name
1919 )
1920 );
1921 }
1922 break;
1923 case 'alterStatement':
1924 $subtree = $node->get_first_child_node();
1925 switch ( $subtree->rule_name ) {
1926 case 'alterTable':
1927 $this->execute_alter_table_statement( $node );
1928 break;
1929 default:
1930 throw $this->new_not_supported_exception(
1931 sprintf(
1932 'statement type: "%s" > "%s"',
1933 $node->rule_name,
1934 $subtree->rule_name
1935 )
1936 );
1937 }
1938 break;
1939 case 'dropStatement':
1940 $subtree = $node->get_first_child_node();
1941 switch ( $subtree->rule_name ) {
1942 case 'dropTable':
1943 $this->execute_drop_table_statement( $node );
1944 break;
1945 case 'dropIndex':
1946 $this->execute_drop_index_statement( $node );
1947 break;
1948 default:
1949 $query = $this->translate( $node );
1950 $this->last_result_statement = $this->execute_sqlite_query( $query );
1951 }
1952 break;
1953 case 'truncateTableStatement':
1954 $this->execute_truncate_table_statement( $node );
1955 break;
1956 case 'setStatement':
1957 $this->execute_set_statement( $node );
1958 break;
1959 case 'showStatement':
1960 $this->execute_show_statement( $node );
1961 break;
1962 case 'utilityStatement':
1963 $subtree = $node->get_first_child_node();
1964 switch ( $subtree->rule_name ) {
1965 case 'describeStatement':
1966 $this->execute_describe_statement( $subtree );
1967 break;
1968 case 'useCommand':
1969 $this->execute_use_statement( $subtree );
1970 break;
1971 default:
1972 throw $this->new_not_supported_exception(
1973 sprintf(
1974 'statement type: "%s" > "%s"',
1975 $node->rule_name,
1976 $subtree->rule_name
1977 )
1978 );
1979 }
1980 break;
1981 case 'tableAdministrationStatement':
1982 $this->execute_administration_statement( $node );
1983 break;
1984 default:
1985 throw $this->new_not_supported_exception(
1986 sprintf( 'statement type: "%s"', $node->rule_name )
1987 );
1988 }
1989 }
1990
1991 /**
1992 * Begin a wrapper transaction.
1993 *
1994 * A wrapper transaction is used to ensure consistency by encapsulating SQLite
1995 * statements that are executed during a single MySQL query emulation process.
1996 *
1997 * TOP-LEVEL TRANSACTION vs. SAVEPOINT:
1998 *
1999 * When no transaction is active, we can use a top-level TRANSACTION to wrap
2000 * the emulated MySQL statement. However, if a transaction is already active,
2001 * we must use a SAVEPOINT, as SQLite doesn't support transaction nesting.
2002 *
2003 * BEGIN vs. BEGIN IMMEDIATE:
2004 *
2005 * When we're executing a statement that will need to write to the database,
2006 * we must use "BEGIN IMMEDIATE" to immediately open a write transaction.
2007 *
2008 * This is needed to avoid the "database is locked" error (SQLITE_BUSY) when
2009 * SQLite can't upgrade a read transaction to a write transaction, because
2010 * another connection is already modifying the database.
2011 *
2012 * From the SQLite documentation:
2013 *
2014 * ## Read transactions versus write transactions
2015 *
2016 * If a write statement occurs while a read transaction is active,
2017 * then the read transaction is upgraded to a write transaction if
2018 * possible. If some other database connection has already modified
2019 * the database or is already in the process of modifying the database,
2020 * then upgrading to a write transaction is not possible and the write
2021 * statement will fail with SQLITE_BUSY.
2022 *
2023 * ## DEFERRED, IMMEDIATE, and EXCLUSIVE transactions
2024 *
2025 * Transactions can be DEFERRED, IMMEDIATE, or EXCLUSIVE. The default
2026 * transaction behavior is DEFERRED.
2027 *
2028 * DEFERRED means that the transaction does not actually start until
2029 * the database is first accessed.
2030 *
2031 * IMMEDIATE causes the database connection to start a new write
2032 * immediately, without waiting for a write statement. The BEGIN
2033 * IMMEDIATE might fail with SQLITE_BUSY if another write transaction
2034 * is already active on another database connection.
2035 *
2036 * See:
2037 * - https://www.sqlite.org/lang_transaction.html
2038 * - https://www.sqlite.org/rescode.html#busy
2039 *
2040 * For better performance, we could also consider opening the write
2041 * transaction later in the session - just before the first write.
2042 */
2043 private function begin_wrapper_transaction(): void {
2044 if ( null !== $this->wrapper_transaction_type ) {
2045 return;
2046 }
2047
2048 $wrapper_transaction_type = $this->wrapper_transaction_type;
2049 if ( $this->inTransaction() ) {
2050 $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' );
2051 $stmt = $this->connection->prepare( sprintf( 'SAVEPOINT %s', $savepoint_name ) );
2052 $wrapper_transaction_type = 'savepoint';
2053 } else {
2054 // For write transactions, we must use "BEGIN IMMEDIATE".
2055 // @see self::begin_user_transaction() method comments.
2056 $stmt = $this->connection->prepare( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' );
2057 $wrapper_transaction_type = 'transaction';
2058 }
2059
2060 if ( ! $stmt->execute() ) {
2061 throw $this->new_driver_exception( 'Failed to begin wrapper transaction.' );
2062 }
2063 $this->wrapper_transaction_type = $wrapper_transaction_type;
2064 $this->in_transaction = true;
2065 }
2066
2067 /**
2068 * Commit a wrapper transaction.
2069 */
2070 private function commit_wrapper_transaction(): void {
2071 if ( null === $this->wrapper_transaction_type ) {
2072 return;
2073 }
2074
2075 $in_transaction = $this->in_transaction;
2076 if ( 'savepoint' === $this->wrapper_transaction_type ) {
2077 $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' );
2078 $stmt = $this->connection->prepare( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) );
2079 } else {
2080 $stmt = $this->connection->prepare( 'COMMIT' );
2081 $in_transaction = false;
2082 }
2083
2084 if ( ! $stmt->execute() ) {
2085 throw $this->new_driver_exception( 'Failed to commit wrapper transaction.' );
2086 }
2087 $this->wrapper_transaction_type = null;
2088 $this->in_transaction = $in_transaction;
2089 }
2090
2091 /**
2092 * Execute the "BEGIN" or "START TRANSACTION" MySQL statement in SQLite.
2093 */
2094 private function begin_user_transaction(): void {
2095 // MySQL implicitly commits previous transaction when starting a new one.
2096 if ( $this->inTransaction() ) {
2097 $this->commit_user_transaction();
2098 }
2099
2100 /*
2101 * Since we don't know whether the user will write to the database, we
2102 * must use "BEGIN IMMEDIATE" to immediately open a write transaction.
2103 *
2104 * This is needed to avoid the "database is locked" error (SQLITE_BUSY)
2105 * when SQLite can't upgrade a read transaction to a write transaction,
2106 * because another connection is already modifying the database.
2107 *
2108 * @see self::begin_wrapper_transaction()
2109 */
2110 $this->connection->query( 'BEGIN IMMEDIATE' );
2111 $this->in_transaction = true;
2112 $this->savepoint_names = array();
2113 }
2114
2115 /**
2116 * Execute the "COMMIT" MySQL statement in SQLite.
2117 */
2118 private function commit_user_transaction(): void {
2119 // MySQL doesn't throw an error if there is no active transaction.
2120 if ( ! $this->inTransaction() ) {
2121 return;
2122 }
2123 $this->connection->query( 'COMMIT' );
2124 $this->in_transaction = false;
2125 $this->savepoint_names = array();
2126 }
2127
2128 /**
2129 * Execute the "ROLLBACK" MySQL statement in SQLite.
2130 */
2131 private function rollback_user_transaction(): void {
2132 // MySQL doesn't throw an error if there is no active transaction.
2133 if ( ! $this->inTransaction() ) {
2134 return;
2135 }
2136 $this->connection->query( 'ROLLBACK' );
2137 $this->in_transaction = false;
2138 $this->savepoint_names = array();
2139 }
2140
2141 /**
2142 * Execute a MySQL transaction or locking statement in SQLite.
2143 *
2144 * @param WP_Parser_Node $node The "transactionOrLockingStatement" AST node.
2145 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2146 */
2147 private function execute_transaction_or_locking_statement( WP_Parser_Node $node ): void {
2148 $subnode = $node->get_first_child_node();
2149 $token = $node->get_first_descendant_token();
2150
2151 switch ( $subnode->rule_name ) {
2152 case 'transactionStatement':
2153 // START TRANSACTION.
2154 if ( WP_MySQL_Lexer::START_SYMBOL === $token->id ) {
2155 $this->begin_user_transaction();
2156 return;
2157 }
2158
2159 // COMMIT.
2160 if ( WP_MySQL_Lexer::COMMIT_SYMBOL === $token->id ) {
2161 $this->commit_user_transaction();
2162 return;
2163 }
2164
2165 break;
2166 case 'savepointStatement':
2167 $savepoint_name = $this->translate( $subnode->get_first_child_node( 'identifier' ) );
2168 $savepoint_key = null === $savepoint_name
2169 ? null
2170 : strtolower( $this->unquote_sqlite_identifier( $savepoint_name ) );
2171
2172 // ROLLBACK/ROLLBACK TO SAVEPOINT <identifier>.
2173 if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $token->id ) {
2174 if ( null === $savepoint_name ) {
2175 $this->rollback_user_transaction();
2176 } else {
2177 // ROLLBACK TO keeps the named savepoint and deletes those created after it.
2178 $index = array_search( $savepoint_key, $this->savepoint_names, true );
2179 if ( false === $index ) {
2180 throw $this->new_savepoint_does_not_exist_exception( $savepoint_name );
2181 }
2182 $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) );
2183 array_splice( $this->savepoint_names, $index + 1 );
2184 }
2185 return;
2186 }
2187
2188 // SAVEPOINT.
2189 if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $token->id ) {
2190 // In MySQL with autocommit enabled, a standalone savepoint is discarded
2191 // immediately without starting a transaction.
2192 if ( ! $this->inTransaction() ) {
2193 return;
2194 }
2195 $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) );
2196
2197 /*
2198 * MySQL deletes an existing savepoint when its name is reused, while
2199 * SQLite keeps it on the stack, shadowed by the new one. Drop the old
2200 * name so that it can no longer be referenced. The shadowed SQLite
2201 * savepoint is harmless; it is discarded when the transaction ends.
2202 */
2203 $index = array_search( $savepoint_key, $this->savepoint_names, true );
2204 if ( false !== $index ) {
2205 array_splice( $this->savepoint_names, $index, 1 );
2206 }
2207 $this->savepoint_names[] = $savepoint_key;
2208 return;
2209 }
2210
2211 // RELEASE SAVEPOINT.
2212 if ( WP_MySQL_Lexer::RELEASE_SYMBOL === $token->id ) {
2213 // RELEASE deletes the named savepoint and those created after it.
2214 $index = array_search( $savepoint_key, $this->savepoint_names, true );
2215 if ( false === $index ) {
2216 throw $this->new_savepoint_does_not_exist_exception( $savepoint_name );
2217 }
2218 $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) );
2219 array_splice( $this->savepoint_names, $index );
2220 return;
2221 }
2222
2223 break;
2224 case 'lockStatement':
2225 // LOCK TABLE/LOCK TABLES.
2226 if (
2227 WP_MySQL_Lexer::LOCK_SYMBOL === $token->id
2228 && $subnode->has_child_node( 'lockItem' )
2229 ) {
2230 // Check if the table(s) exists.
2231 $lock_items = $subnode->get_child_nodes( 'lockItem' );
2232 foreach ( $lock_items as $lock_item ) {
2233 $table_ref = $lock_item->get_first_child_node( 'tableRef' );
2234 $database = $this->get_database_name( $table_ref );
2235 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
2236 if ( 'information_schema' === strtolower( $database ) ) {
2237 throw $this->new_access_denied_to_information_schema_exception();
2238 }
2239
2240 try {
2241 /*
2242 * Attempt to query the table directly rather than checking
2243 * SQLite schema or information schema tables, so that we
2244 * can handle persistent and temporary tables in one query.
2245 */
2246 $this->execute_sqlite_query(
2247 sprintf( 'SELECT 1 FROM %s LIMIT 0', $table_name )
2248 );
2249 } catch ( PDOException $e ) {
2250 throw $this->new_table_not_found_exception(
2251 sprintf( '%s.%s', $this->db_name, $table_name ),
2252 $e
2253 );
2254 }
2255 }
2256
2257 $this->begin_user_transaction();
2258 $this->table_lock_active = true;
2259 return;
2260 }
2261
2262 // UNLOCK TABLES/UNLOCK TABLE.
2263 if (
2264 WP_MySQL_Lexer::UNLOCK_SYMBOL === $token->id
2265 && (
2266 $subnode->has_child_token( WP_MySQL_Lexer::TABLE_SYMBOL )
2267 || $subnode->has_child_token( WP_MySQL_Lexer::TABLES_SYMBOL )
2268 )
2269 ) {
2270 // Commit the transaction when created by the LOCK statement.
2271 if ( $this->table_lock_active && $this->inTransaction() ) {
2272 $this->commit_user_transaction();
2273 $this->table_lock_active = false;
2274 }
2275 return;
2276 }
2277
2278 break;
2279 }
2280
2281 throw $this->new_not_supported_exception(
2282 sprintf(
2283 'statement type: "%s" > "%s"',
2284 $node->rule_name,
2285 $subnode->rule_name
2286 )
2287 );
2288 }
2289
2290 /**
2291 * Translate and execute a MySQL SELECT statement in SQLite.
2292 *
2293 * @param WP_Parser_Node $node The "selectStatement" AST node.
2294 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2295 */
2296 private function execute_select_statement( WP_Parser_Node $node ): void {
2297 /*
2298 * [GRAMMAR]
2299 * selectStatement:
2300 * queryExpression lockingClauseList?
2301 * | selectStatementWithInto
2302 */
2303
2304 // First, translate the query, before we modify last found rows count.
2305 $query = $this->translate( $node->get_first_child() );
2306
2307 $has_sql_calc_found_rows = null !== $node->get_first_descendant_token(
2308 WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL
2309 );
2310
2311 // Handle SQL_CALC_FOUND_ROWS.
2312 if ( true === $has_sql_calc_found_rows ) {
2313 // Recursively find a query expression with the first LIMIT or SELECT.
2314 $query_expr = $node->get_first_descendant_node( 'queryExpression' );
2315 while ( true ) {
2316 if ( $query_expr->has_child_node( 'limitClause' ) ) {
2317 break;
2318 }
2319
2320 $query_expr_parens = $query_expr->get_first_child_node( 'queryExpressionParens' );
2321 if ( null !== $query_expr_parens ) {
2322 $query_expr = $query_expr_parens->get_first_child_node( 'queryExpression' );
2323 continue;
2324 }
2325
2326 $query_expr_body = $query_expr->get_first_child_node( 'queryExpressionBody' );
2327 if ( count( $query_expr_body->get_children() ) > 1 ) {
2328 break;
2329 }
2330
2331 $query_term = $query_expr_body->get_first_child_node( 'queryTerm' );
2332 if (
2333 count( $query_term->get_children() ) === 1
2334 && $query_term->has_child_node( 'queryExpressionParens' )
2335 ) {
2336 $query_expr = $query_term->get_first_child_node( 'queryExpressionParens' )->get_first_child_node( 'queryExpression' );
2337 continue;
2338 }
2339
2340 break;
2341 }
2342
2343 // Exclude the limit clause from the expression.
2344 $count_expr = new WP_Parser_Node( $query_expr->rule_id, $query_expr->rule_name );
2345 foreach ( $query_expr->get_children() as $child ) {
2346 if ( ! ( $child instanceof WP_Parser_Node && 'limitClause' === $child->rule_name ) ) {
2347 $count_expr->append_child( $child );
2348 }
2349 }
2350
2351 // Get count of all the rows.
2352 $result = $this->execute_sqlite_query(
2353 'SELECT COUNT(*) AS cnt FROM (' . $this->translate( $count_expr ) . ')'
2354 );
2355
2356 $this->found_rows = (int) $result->fetchColumn();
2357 } else {
2358 $this->found_rows = $query;
2359 }
2360
2361 // Execute the query.
2362 $stmt = $this->execute_sqlite_query( $query );
2363
2364 // Store column meta info. This must be done before fetching data, which
2365 // seems to erase type information for expressions in the SELECT clause.
2366 $this->store_last_column_meta_from_statement( $stmt );
2367 $this->last_result_statement = $stmt;
2368 }
2369
2370 /**
2371 * Translate and execute a MySQL INSERT or REPLACE statement in SQLite.
2372 *
2373 * @param WP_Parser_Node $node The "insertStatement" or "replaceStatement" AST node.
2374 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2375 */
2376 private function execute_insert_or_replace_statement( WP_Parser_Node $node ): void {
2377 $parts = array();
2378 $on_conflict_update_list = null;
2379 foreach ( $node->get_children() as $child ) {
2380 $is_token = $child instanceof WP_MySQL_Token;
2381 $is_node = $child instanceof WP_Parser_Node;
2382
2383 if ( $child instanceof WP_Parser_Node && 'tableRef' === $child->rule_name ) {
2384 // MySQL supports INSERT without the INTO keyword; SQLite requires it.
2385 if ( ! $node->has_child_token( WP_MySQL_Lexer::INTO_SYMBOL ) ) {
2386 $parts[] = 'INTO';
2387 }
2388
2389 $database = $this->get_database_name( $child );
2390 if ( 'information_schema' === strtolower( $database ) ) {
2391 throw $this->new_access_denied_to_information_schema_exception();
2392 }
2393 }
2394
2395 // Skip the SET keyword in "INSERT INTO ... SET ..." syntax.
2396 if ( $is_token && WP_MySQL_Lexer::SET_SYMBOL === $child->id ) {
2397 continue;
2398 }
2399
2400 if ( $is_token && WP_MySQL_Lexer::IGNORE_SYMBOL === $child->id ) {
2401 // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE".
2402 $parts[] = 'OR IGNORE';
2403 } elseif (
2404 $is_node
2405 && (
2406 'insertFromConstructor' === $child->rule_name
2407 || 'insertQueryExpression' === $child->rule_name
2408 || 'updateList' === $child->rule_name
2409 )
2410 ) {
2411 $table_ref = $node->get_first_child_node( 'tableRef' );
2412 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
2413 $parts[] = $this->translate_insert_or_replace_body( $table_name, $child );
2414 } elseif ( $is_node && 'insertUpdateList' === $child->rule_name ) {
2415 /*
2416 * Translate "ON DUPLICATE KEY UPDATE" to "ON CONFLICT DO UPDATE SET".
2417 *
2418 * For SQLite versions older than 3.35.0, we need to handle the
2419 * ON CONFLICT clause differently, and at this stage, we only
2420 * save the translated update list to a variable.
2421 *
2422 * See below at "Handle ON CONFLICT clause for SQLite < 3.35.0".
2423 */
2424 $sqlite_version = $this->get_sqlite_version();
2425 if ( version_compare( $sqlite_version, '3.35.0', '<' ) ) {
2426 $on_conflict_update_list = $this->translate_update_list( $table_name, $child );
2427 } else {
2428 $parts[] = 'ON CONFLICT DO UPDATE SET ';
2429 $parts[] = $this->translate_update_list( $table_name, $child );
2430 }
2431 } else {
2432 $parts[] = $this->translate( $child );
2433 }
2434 }
2435
2436 $query = implode( ' ', $parts );
2437
2438 /*
2439 * Handle ON CONFLICT clause for SQLite < 3.35.0.
2440 *
2441 * If and "$on_conflict_update_list" was saved, we are on SQLite version
2442 * older than 3.35.0 and an ON CONFLICT clause was used in the query.
2443 *
2444 * SQLite supports a generic ON CONFLICT clause without an explicit column
2445 * list only from version 3.35.0.
2446 *
2447 * For older versions, we need to work around this limitation:
2448 * 1. Save the ON CONFLICT update list to a variable.
2449 * 2. Execute the query without the ON CONFLICT clause.
2450 * 3. If a constraint violation error occurs, parse the names of the
2451 * columns that caused the violation from the error message.
2452 * 4. Execute the query again, appending the ON CONFLICT clause with
2453 * the column names parsed from the error message.
2454 */
2455 if ( null !== $on_conflict_update_list ) {
2456 try {
2457 $this->last_result_statement = $this->execute_sqlite_query( $query );
2458 } catch ( PDOException $e ) {
2459 $unique_key_violation_prefix = 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ';
2460 if ( '23000' === $e->getCode() && str_contains( $e->getMessage(), $unique_key_violation_prefix ) ) {
2461 /*
2462 * Parse column names from the constraint violation error.
2463 *
2464 * The error message is in the following format:
2465 * <prefix>: <table>.<col1>, <table>.<col2>, ...
2466 *
2467 * The table and column names in the message are not quoted.
2468 * To be on the safe side, we first strip the error message
2469 * prefix and the "<table>." part for the first column, and
2470 * then split the rest of the list by ", <table>." sequence.
2471 */
2472 $column_list = substr( $e->getMessage(), strlen( $unique_key_violation_prefix ) + strlen( $table_name ) + 1 );
2473 $column_names = explode( ", $table_name.", $column_list );
2474 $quoted_column_names = array_map(
2475 function ( $column ) {
2476 return $this->quote_sqlite_identifier( $column );
2477 },
2478 $column_names
2479 );
2480 $this->last_result_statement = $this->execute_sqlite_query(
2481 $query . sprintf(
2482 ' ON CONFLICT(%s) DO UPDATE SET %s',
2483 implode( ', ', $quoted_column_names ),
2484 $on_conflict_update_list
2485 )
2486 );
2487 } else {
2488 throw $e;
2489 }
2490 }
2491 $this->last_insert_id = $this->connection->get_last_insert_id();
2492 return;
2493 }
2494
2495 $this->last_result_statement = $this->execute_sqlite_query( $query );
2496 $this->last_insert_id = $this->connection->get_last_insert_id();
2497 }
2498
2499 /**
2500 * Translate and execute a MySQL UPDATE statement in SQLite.
2501 *
2502 * @param WP_Parser_Node $node The "updateStatement" AST node.
2503 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2504 */
2505 private function execute_update_statement( WP_Parser_Node $node ): void {
2506 // @TODO: Add support for UPDATE with multiple tables and JOINs.
2507 // SQLite supports them in the FROM clause.
2508
2509 $has_order = $node->has_child_node( 'orderClause' );
2510 $has_limit = $node->has_child_node( 'simpleLimitClause' );
2511
2512 /*
2513 * SQLite doesn't support UPDATE with ORDER BY/LIMIT.
2514 * We need to use a subquery to emulate this behavior.
2515 *
2516 * For instance, the following query:
2517 * UPDATE t SET c = 1 WHERE c = 2 LIMIT 1;
2518 * Will be rewritten to:
2519 * UPDATE t SET c = 1 WHERE rowid IN ( SELECT rowid FROM t WHERE c = 2 LIMIT 1 );
2520 */
2521 $where_subquery = null;
2522 if ( $has_order || $has_limit ) {
2523 $where_subquery = 'SELECT rowid FROM ' . $this->translate_sequence(
2524 array(
2525 $node->get_first_child_node( 'tableReferenceList' ),
2526 $node->get_first_child_node( 'whereClause' ),
2527 $node->get_first_child_node( 'orderClause' ),
2528 $node->get_first_child_node( 'simpleLimitClause' ),
2529 )
2530 );
2531 }
2532
2533 /*
2534 * Translate the UPDATE statement parts.
2535 *
2536 * [GRAMMAR]
2537 * updateStatement:
2538 * withClause? UPDATE_SYMBOL LOW_PRIORITY_SYMBOL? IGNORE_SYMBOL? tableReferenceList
2539 * SET_SYMBOL updateList whereClause? orderClause? simpleLimitClause?
2540 */
2541
2542 // Collect all tables used in the UPDATE clause (e.g, UPDATE t1, t2 JOIN t3).
2543 $table_alias_map = $this->create_table_reference_map(
2544 $node->get_first_child_node( 'tableReferenceList' )
2545 );
2546
2547 /*
2548 * Deny UPDATE for information schema tables.
2549 *
2550 * This basic approach is rather restrictive, as it blocks the usage
2551 * of information schema tables anywhere in the UPDATE statement.
2552 *
2553 * TODO: Implement support for UPDATE statements like:
2554 * UPDATE t, information_schema.columns c SET t.column = c.column ...
2555 */
2556 foreach ( $table_alias_map as $alias => $data ) {
2557 if ( 'information_schema' === strtolower( $data['database'] ?? '' ) ) {
2558 throw $this->new_access_denied_to_information_schema_exception();
2559 }
2560 }
2561
2562 // Determine whether the UPDATE statement modifies multiple tables.
2563 $update_list_node = $node->get_first_child_node( 'updateList' );
2564 $update_target = null;
2565 $updates_multiple_tables = false;
2566 if ( count( $table_alias_map ) > 1 ) {
2567 foreach ( $update_list_node->get_child_nodes( 'updateElement' ) as $update_element ) {
2568 $column_ref = $update_element->get_first_child_node( 'columnRef' );
2569 $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' );
2570 $table_or_alias = count( $column_ref_parts ) > 1
2571 ? $this->unquote_sqlite_identifier( $this->translate( $column_ref_parts[0] ) )
2572 : null;
2573
2574 // When the SET column reference is not qualified, we need to
2575 // verify whether the column is used in multiple tables.
2576 if ( null === $table_or_alias ) {
2577 $persistent_table_names = array();
2578 $temporary_table_names = array();
2579 foreach ( array_filter( array_column( $table_alias_map, 'table_name' ) ) as $table_name ) {
2580 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2581 $quoted_table_name = $this->quote_sqlite_value( $table_name );
2582 if ( $is_temporary ) {
2583 $temporary_table_names[] = $quoted_table_name;
2584 } else {
2585 $persistent_table_names[] = $quoted_table_name;
2586 }
2587 }
2588
2589 $column_name = $this->unquote_sqlite_identifier(
2590 $this->translate( end( $column_ref_parts ) )
2591 );
2592
2593 $matched_temporary_tables = array();
2594 if ( count( $temporary_table_names ) > 0 ) {
2595 $matched_temporary_tables = $this->execute_sqlite_query(
2596 sprintf(
2597 'SELECT table_name FROM %s WHERE table_schema = ? AND table_name IN ( %s ) AND column_name = ?',
2598 $this->quote_sqlite_identifier(
2599 $this->information_schema_builder->get_table_name( true, 'columns' )
2600 ),
2601 implode( ', ', $temporary_table_names )
2602 ),
2603 array( $this->get_saved_db_name(), $column_name )
2604 )->fetchAll( PDO::FETCH_COLUMN );
2605 }
2606
2607 $matched_persistent_tables = array();
2608 if ( count( $persistent_table_names ) > 0 ) {
2609 $matched_persistent_tables = $this->execute_sqlite_query(
2610 sprintf(
2611 'SELECT table_name FROM %s WHERE table_schema = ? AND table_name IN ( %s ) AND column_name = ?',
2612 $this->quote_sqlite_identifier(
2613 $this->information_schema_builder->get_table_name( false, 'columns' )
2614 ),
2615 implode( ', ', $persistent_table_names )
2616 ),
2617 array( $this->get_saved_db_name(), $column_name )
2618 )->fetchAll( PDO::FETCH_COLUMN );
2619 }
2620
2621 $matched_tables = array_merge( $matched_temporary_tables, $matched_persistent_tables );
2622 $matched_aliases = array();
2623 foreach ( $table_alias_map as $alias => $data ) {
2624 // Derived tables do not have a table name.
2625 if ( null === $data['table_name'] ) {
2626 continue;
2627 }
2628
2629 foreach ( $matched_tables as $matched_table ) {
2630 if ( 0 === strcasecmp( $data['table_name'], $matched_table ) ) {
2631 $matched_aliases[] = $alias;
2632 break;
2633 }
2634 }
2635 }
2636 $updates_multiple_tables = count( $matched_aliases ) > 1;
2637 if ( 1 === count( $matched_aliases ) ) {
2638 $table_or_alias = $matched_aliases[0];
2639 } else {
2640 break;
2641 }
2642 }
2643
2644 if ( null === $update_target ) {
2645 $update_target = $table_or_alias;
2646 }
2647
2648 if ( $update_target !== $table_or_alias ) {
2649 $updates_multiple_tables = true;
2650 break;
2651 }
2652 }
2653 } else {
2654 $update_target = array_keys( $table_alias_map )[0];
2655 }
2656
2657 // TODO: Support UPDATE that modifies multiple tables.
2658 // This is non-trivial and likely requires temporary tables.
2659 // E.g.: UPDATE t1, t2 SET t1.id = t2.id, t2.id = t1.id;
2660 if ( $updates_multiple_tables ) {
2661 throw $this->new_not_supported_exception( 'UPDATE statement modifying multiple tables' );
2662 }
2663
2664 // Translate WITH clause.
2665 $with = $this->translate( $node->get_first_child_node( 'withClause' ) );
2666
2667 // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE".
2668 $or_ignore = $node->has_child_token( WP_MySQL_Lexer::IGNORE_SYMBOL )
2669 ? 'OR IGNORE'
2670 : null;
2671
2672 // Compose the update target clause.
2673 $update_target_table = $table_alias_map[ $update_target ]['table_name'] ?? $update_target;
2674 $update_target_clause = $this->quote_sqlite_identifier( $update_target_table );
2675 if ( $update_target !== $update_target_table ) {
2676 $update_target_clause .= ' AS ' . $this->quote_sqlite_identifier( $update_target );
2677 }
2678
2679 // Compose the FROM clause using all tables except the one being updated.
2680 // UPDATE with FROM in SQLite is equivalent to UPDATE with JOIN in MySQL.
2681 $from_items = array();
2682 foreach ( $table_alias_map as $alias => $data ) {
2683 if ( $alias === $update_target ) {
2684 continue;
2685 }
2686
2687 $table_name = $data['table_name'];
2688
2689 // Derived table.
2690 if ( null === $table_name ) {
2691 $from_item = $data['table_expr'] . ' AS ' . $this->quote_sqlite_identifier( $alias );
2692 $from_items[] = $from_item;
2693 continue;
2694 }
2695
2696 // Regular table.
2697 $from_item = $this->quote_sqlite_identifier( $table_name );
2698 if ( $alias !== $table_name ) {
2699 $from_item .= ' AS ' . $this->quote_sqlite_identifier( $alias );
2700 }
2701 $from_items[] = $from_item;
2702 }
2703
2704 $from = null;
2705 if ( count( $from_items ) > 0 ) {
2706 $from = 'FROM ' . implode( ', ', $from_items );
2707 }
2708
2709 // Translate UPDATE list, applying relevant type casting and IMPLICIT DEFAULT values.
2710 $update_list = $this->translate_update_list( $update_target_table, $node );
2711
2712 // Translate WHERE, ORDER BY, and LIMIT clauses.
2713 if ( $where_subquery ) {
2714 // When using a subquery, skip the original WHERE, ORDER BY, and LIMIT.
2715 $where_clause = ' WHERE rowid IN ( ' . $where_subquery . ' )';
2716 $order_clause = null;
2717 $limit_clause = null;
2718 } else {
2719 $where_clause = $this->translate( $node->get_first_child_node( 'whereClause' ) );
2720 $order_clause = $this->translate( $node->get_first_child_node( 'orderClause' ) );
2721 $limit_clause = $this->translate( $node->get_first_child_node( 'simpleLimitClause' ) );
2722 }
2723
2724 // With JOINs, we need to use the JOIN expressions in the WHERE clause.
2725 $join_exprs = array_filter( array_column( $table_alias_map, 'join_expr' ) );
2726 if ( count( $join_exprs ) > 0 ) {
2727 $where_clause .= $where_clause ? ' AND ' : ' WHERE ';
2728 $where_clause .= implode( ' AND ', $join_exprs );
2729 }
2730
2731 // Compose the UPDATE query.
2732 $parts = array(
2733 $with,
2734 'UPDATE',
2735 $or_ignore,
2736 $update_target_clause,
2737 'SET',
2738 $update_list,
2739 $from,
2740 $where_clause,
2741 $order_clause,
2742 $limit_clause,
2743 );
2744 $query = implode( ' ', array_filter( $parts ) );
2745
2746 $this->last_result_statement = $this->execute_sqlite_query( $query );
2747 }
2748
2749 /**
2750 * Translate and execute a MySQL DELETE statement in SQLite.
2751 *
2752 * @param WP_Parser_Node $node The "deleteStatement" AST node.
2753 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2754 */
2755 private function execute_delete_statement( WP_Parser_Node $node ): void {
2756 /*
2757 * Multi-table DELETE.
2758 *
2759 * MySQL supports multi-table DELETE statements that don't work in SQLite.
2760 * These statements can have the following two flavours:
2761 * 1. "DELETE t1, t2 FROM ... JOIN ... WHERE ..."
2762 * 2. "DELETE FROM t1, t2 USING ... JOIN ... WHERE ..."
2763 *
2764 * We will rewrite such statements into a SELECT to fetch the ROWIDs of
2765 * the rows to delete and then execute a DELETE statement for each table.
2766 */
2767 $alias_ref_list = $node->get_first_child_node( 'tableAliasRefList' );
2768 if ( null !== $alias_ref_list ) {
2769 // 1. Get table aliases targeted by the DELETE statement.
2770 $table_aliases = array();
2771 foreach ( $alias_ref_list->get_child_nodes() as $alias_ref ) {
2772 $table_aliases[] = $this->unquote_sqlite_identifier(
2773 $this->translate( $alias_ref )
2774 );
2775 }
2776
2777 // 2. Create an alias to table name map.
2778 $alias_map = array();
2779 $table_ref_list = $node->get_first_child_node( 'tableReferenceList' );
2780 foreach ( $table_ref_list->get_descendant_nodes( 'singleTable' ) as $single_table ) {
2781 $table_ref = $single_table->get_first_child_node( 'tableRef' );
2782 $alias_node = $single_table->get_first_child_node( 'tableAlias' );
2783 if ( $alias_node ) {
2784 $alias = $this->unquote_sqlite_identifier( $this->translate( $alias_node ) );
2785 } else {
2786 $alias = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
2787 }
2788
2789 // For an information schema table, check if is a DELETE target.
2790 $database = $this->get_database_name( $table_ref );
2791 if (
2792 'information_schema' === strtolower( $database )
2793 && in_array( $alias, $table_aliases, true )
2794 ) {
2795 throw $this->new_access_denied_to_information_schema_exception();
2796 }
2797
2798 $alias_map[ $alias ] = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
2799 }
2800
2801 // 3. Compose the SELECT query to fetch ROWIDs to delete.
2802 $where_clause = $node->get_first_child_node( 'whereClause' );
2803 if ( null !== $where_clause ) {
2804 $where = $this->translate( $where_clause->get_first_child_node( 'expr' ) );
2805 }
2806
2807 $select_list = array();
2808 foreach ( $table_aliases as $table ) {
2809 $select_list[] = sprintf(
2810 '%s.rowid AS %s',
2811 $this->quote_sqlite_identifier( $table ),
2812 $this->quote_sqlite_identifier( $table . '_rowid' )
2813 );
2814 }
2815
2816 $ids = $this->execute_sqlite_query(
2817 sprintf(
2818 'SELECT %s FROM %s %s',
2819 implode( ', ', $select_list ),
2820 $this->translate( $table_ref_list ),
2821 isset( $where ) ? "WHERE $where" : ''
2822 )
2823 )->fetchAll( PDO::FETCH_ASSOC );
2824
2825 // 4. Execute DELETE statements for each table.
2826 $affected_rows = 0;
2827 if ( count( $ids ) > 0 ) {
2828 foreach ( $table_aliases as $table ) {
2829 $stmt = $this->execute_sqlite_query(
2830 sprintf(
2831 'DELETE FROM %s AS %s WHERE rowid IN ( %s )',
2832 $this->quote_sqlite_identifier( $alias_map[ $table ] ),
2833 $this->quote_sqlite_identifier( $table ),
2834 implode( ', ', array_column( $ids, "{$table}_rowid" ) )
2835 )
2836 );
2837 $affected_rows += $stmt->rowCount();
2838 }
2839 }
2840
2841 $this->last_result_statement = $this->create_result_statement_from_data( array(), array() );
2842 $this->last_affected_rows = $affected_rows;
2843 return;
2844 }
2845
2846 // @TODO: Translate DELETE with JOIN to use a subquery.
2847
2848 $table_ref = $node->get_first_child_node( 'tableRef' );
2849 $database = $this->get_database_name( $table_ref );
2850 if ( 'information_schema' === strtolower( $database ) ) {
2851 throw $this->new_access_denied_to_information_schema_exception();
2852 }
2853
2854 /*
2855 * SQLite doesn't support DELETE with ORDER BY/LIMIT.
2856 * We need to use a subquery to emulate this behavior.
2857 *
2858 * For instance, the following query:
2859 * DELETE FROM t WHERE c = 2 LIMIT 1;
2860 * Will be rewritten to:
2861 * DELETE FROM t WHERE rowid IN ( SELECT rowid FROM t WHERE c = 2 LIMIT 1 );
2862 */
2863 $has_order = $node->has_child_node( 'orderClause' );
2864 $has_limit = $node->has_child_node( 'simpleLimitClause' );
2865 if ( $has_order || $has_limit ) {
2866 $where_subquery = 'SELECT rowid FROM ' . $this->translate_sequence(
2867 array(
2868 $table_ref,
2869 $node->get_first_child_node( 'tableAlias' ),
2870 $node->get_first_child_node( 'whereClause' ),
2871 $node->get_first_child_node( 'orderClause' ),
2872 $node->get_first_child_node( 'simpleLimitClause' ),
2873 )
2874 );
2875
2876 $query = sprintf(
2877 'DELETE FROM %s WHERE rowid IN ( %s )',
2878 $this->translate( $table_ref ),
2879 $where_subquery
2880 );
2881
2882 $this->last_result_statement = $this->execute_sqlite_query( $query );
2883 return;
2884 }
2885
2886 $query = $this->translate( $node );
2887 $this->last_result_statement = $this->execute_sqlite_query( $query );
2888 }
2889
2890 /**
2891 * Translate and execute a MySQL CREATE TABLE statement in SQLite.
2892 *
2893 * @param WP_Parser_Node $node The "createStatement" AST node with "createTable" child.
2894 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2895 */
2896 private function execute_create_table_statement( WP_Parser_Node $node ): void {
2897 $subnode = $node->get_first_child_node();
2898
2899 // Handle TEMPORARY keyword.
2900 $table_is_temporary = $subnode->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL );
2901
2902 // Handle CREATE TABLE ... [AS] SELECT.
2903 $element_list = $subnode->get_first_child_node( 'tableElementList' );
2904 if ( null === $element_list ) {
2905 /*
2906 * While SQLite supports CREATE TABLE ... AS SELECT statements,
2907 * we need to somehow implement information schema support for
2908 * the tables created in this way.
2909 *
2910 * TODO: Implement information schema support for CREATE TABLE ... AS SELECT.
2911 */
2912 throw $this->new_not_supported_exception(
2913 'CREATE TABLE ... [AS] SELECT is currently not supported'
2914 );
2915 }
2916
2917 // Get table name.
2918 $table_name_node = $subnode->get_first_child_node( 'tableName' );
2919 $database = $this->get_database_name( $table_name_node );
2920 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_name_node ) );
2921
2922 if ( 'information_schema' === strtolower( $database ) ) {
2923 throw $this->new_access_denied_to_information_schema_exception();
2924 }
2925
2926 // Handle IF NOT EXISTS.
2927 if ( $subnode->has_child_node( 'ifNotExists' ) ) {
2928 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
2929 $table_exists = $this->execute_sqlite_query(
2930 sprintf(
2931 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ?',
2932 $this->quote_sqlite_identifier( $tables_table )
2933 ),
2934 array( $this->get_saved_db_name(), $table_name )
2935 )->fetchColumn();
2936
2937 if ( $table_exists ) {
2938 $this->last_result_statement = $this->create_result_statement_from_data( array(), array() );
2939 return;
2940 }
2941 }
2942
2943 // Save information to information schema tables.
2944 $this->information_schema_builder->record_create_table( $node );
2945
2946 // Generate CREATE TABLE statement from the information schema tables.
2947 $queries = $this->get_sqlite_create_table_statement( $table_is_temporary, $table_name );
2948 $create_table_query = $queries[0];
2949 $constraint_queries = array_slice( $queries, 1 );
2950
2951 $this->execute_sqlite_query( $create_table_query );
2952
2953 foreach ( $constraint_queries as $query ) {
2954 $this->execute_sqlite_query( $query );
2955 }
2956
2957 // Apply AUTO_INCREMENT = N table option, if any.
2958 $this->apply_auto_increment_table_option( $table_is_temporary, $table_name, $node );
2959 }
2960
2961 /**
2962 * Translate and execute a MySQL ALTER TABLE statement in SQLite.
2963 *
2964 * @param WP_Parser_Node $node The "alterStatement" AST node with "alterTable" child.
2965 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
2966 */
2967 private function execute_alter_table_statement( WP_Parser_Node $node ): void {
2968 $table_ref = $node->get_first_descendant_node( 'tableRef' );
2969 $database = $this->get_database_name( $table_ref );
2970 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
2971 if ( 'information_schema' === strtolower( $database ) ) {
2972 throw $this->new_access_denied_to_information_schema_exception();
2973 }
2974
2975 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2976
2977 // Save all column names from the original table.
2978 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
2979 $column_names = $this->execute_sqlite_query(
2980 sprintf(
2981 'SELECT
2982 COLUMN_NAME,
2983 LOWER(COLUMN_NAME) AS COLUMN_NAME_LOWERCASE
2984 FROM %s WHERE table_schema = ? AND table_name = ?',
2985 $this->quote_sqlite_identifier( $columns_table )
2986 ),
2987 array( $this->get_saved_db_name( $database ), $table_name )
2988 )->fetchAll( PDO::FETCH_ASSOC );
2989
2990 // Track column renames and removals.
2991 $column_map = array_combine(
2992 array_column( $column_names, 'COLUMN_NAME_LOWERCASE' ),
2993 array_column( $column_names, 'COLUMN_NAME' )
2994 );
2995 foreach ( $node->get_descendant_nodes( 'alterListItem' ) as $action ) {
2996 $first_token = $action->get_first_child_token();
2997
2998 switch ( $first_token->id ) {
2999 case WP_MySQL_Lexer::DROP_SYMBOL:
3000 $name = $this->translate( $action->get_first_child_node( 'fieldIdentifier' ) );
3001 if ( null !== $name ) {
3002 $name = $this->unquote_sqlite_identifier( $name );
3003 unset( $column_map[ strtolower( $name ) ] );
3004 }
3005 break;
3006 case WP_MySQL_Lexer::CHANGE_SYMBOL:
3007 $old_name = $this->unquote_sqlite_identifier(
3008 $this->translate( $action->get_first_child_node( 'fieldIdentifier' ) )
3009 );
3010 $new_name = $this->unquote_sqlite_identifier(
3011 $this->translate( $action->get_first_child_node( 'identifier' ) )
3012 );
3013
3014 $column_map[ strtolower( $old_name ) ] = $new_name;
3015 break;
3016 case WP_MySQL_Lexer::RENAME_SYMBOL:
3017 $column_ref = $action->get_first_child_node( 'fieldIdentifier' );
3018 if ( null !== $column_ref ) {
3019 $old_name = $this->unquote_sqlite_identifier(
3020 $this->translate( $column_ref )
3021 );
3022 $new_name = $this->unquote_sqlite_identifier(
3023 $this->translate( $action->get_first_child_node( 'identifier' ) )
3024 );
3025
3026 $column_map[ strtolower( $old_name ) ] = $new_name;
3027 }
3028 break;
3029 }
3030 }
3031
3032 /*
3033 * Skip the expensive table rebuild when the statement only carries
3034 * table options (e.g. ALTER TABLE t AUTO_INCREMENT = N). These don't
3035 * change the schema, so the recreate would be a pointless full copy.
3036 */
3037 if ( count( $node->get_descendant_nodes( 'alterListItem' ) ) > 0 ) {
3038 $this->information_schema_builder->record_alter_table( $node );
3039 $this->recreate_table_from_information_schema( $table_is_temporary, $table_name, $column_map );
3040 }
3041
3042 // Apply AUTO_INCREMENT = N table option, if any.
3043 $this->apply_auto_increment_table_option( $table_is_temporary, $table_name, $node );
3044
3045 // @TODO: Consider using a "fast path" for ALTER TABLE statements that
3046 // consist only of operations that SQLite's ALTER TABLE supports.
3047 }
3048
3049 /**
3050 * Translate and execute a MySQL DROP TABLE statement in SQLite.
3051 *
3052 * @param WP_Parser_Node $node The "dropStatement" AST node with "dropTable" child.
3053 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3054 */
3055 private function execute_drop_table_statement( WP_Parser_Node $node ): void {
3056 // Record the changes in the information schema.
3057 $this->information_schema_builder->record_drop_table( $node );
3058
3059 // MySQL supports removing multiple tables in a single query DROP query.
3060 // In SQLite, we need to execute each DROP TABLE statement separately.
3061 $child_node = $node->get_first_child_node();
3062 $table_refs = $child_node->get_first_child_node( 'tableRefList' )->get_child_nodes();
3063 $table_is_temporary = $child_node->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL );
3064 $queries = array();
3065 foreach ( $table_refs as $table_ref ) {
3066 $database = $this->get_database_name( $table_ref );
3067 if ( 'information_schema' === strtolower( $database ) ) {
3068 throw $this->new_access_denied_to_information_schema_exception();
3069 }
3070
3071 $parts = array();
3072 foreach ( $child_node->get_children() as $child ) {
3073 $is_token = $child instanceof WP_MySQL_Token;
3074
3075 // Skip the TEMPORARY keyword.
3076 if ( $is_token && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $child->id ) {
3077 continue;
3078 }
3079
3080 // Replace table list with the current table reference.
3081 if ( ! $is_token && 'tableRefList' === $child->rule_name ) {
3082 // Add a "temp." schema prefix for temporary tables.
3083 $prefix = $table_is_temporary ? '`temp`.' : '';
3084 $part = $prefix . $this->translate( $table_ref );
3085 } else {
3086 $part = $this->translate( $child );
3087 }
3088
3089 if ( null !== $part ) {
3090 $parts[] = $part;
3091 }
3092 }
3093 $queries[] = 'DROP ' . implode( ' ', $parts );
3094 }
3095
3096 foreach ( $queries as $query ) {
3097 $this->execute_sqlite_query( $query );
3098 }
3099 }
3100
3101 /**
3102 * Translate and execute a MySQL TRUNCATE TABLE statement in SQLite.
3103 *
3104 * @param WP_Parser_Node $node The "truncateTableStatement" AST node.
3105 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3106 */
3107 private function execute_truncate_table_statement( WP_Parser_Node $node ): void {
3108 $table_ref = $node->get_first_child_node( 'tableRef' );
3109 $database = $this->get_database_name( $table_ref );
3110 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3111 if ( 'information_schema' === strtolower( $database ) ) {
3112 throw $this->new_access_denied_to_information_schema_exception();
3113 }
3114
3115 $this->execute_sqlite_query(
3116 sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) )
3117 );
3118 try {
3119 $this->last_result_statement = $this->execute_sqlite_query(
3120 'DELETE FROM sqlite_sequence WHERE name = ?',
3121 array( $table_name )
3122 );
3123 } catch ( PDOException $e ) {
3124 if ( str_contains( $e->getMessage(), 'no such table' ) ) {
3125 // The table might not exist if no sequences are used in the DB.
3126 } else {
3127 throw $e;
3128 }
3129 }
3130 }
3131
3132 /**
3133 * Translate and execute a MySQL CREATE INDEX statement in SQLite.
3134 *
3135 * @param WP_Parser_Node $node The "createStatement" AST node with "createIndex" child.
3136 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3137 */
3138 private function execute_create_index_statement( WP_Parser_Node $node ): void {
3139 $create_index = $node->get_first_child_node( 'createIndex' );
3140 $target = $create_index->get_first_child_node( 'createIndexTarget' );
3141 $table_ref = $target->get_first_child_node( 'tableRef' );
3142 $database = $this->get_database_name( $table_ref );
3143 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3144
3145 if ( 'information_schema' === strtolower( $database ) ) {
3146 throw $this->new_access_denied_to_information_schema_exception();
3147 }
3148
3149 $this->information_schema_builder->record_create_index( $node );
3150
3151 $index_name = $this->unquote_sqlite_identifier(
3152 $this->translate( $create_index->get_first_child_node( 'indexName' ) )
3153 );
3154 $is_unique = $create_index->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL );
3155
3156 // Get the key parts.
3157 $key_list_variants = $target->get_first_child_node( 'keyListVariants' );
3158 $key_list_nodes = $key_list_variants->get_first_child_node()->get_child_nodes();
3159 foreach ( $key_list_nodes as $key_list_node ) {
3160 if ( 'keyPartOrExpression' === $key_list_node->rule_name ) {
3161 $key_part_node = $key_list_node->get_first_child();
3162 } else {
3163 $key_part_node = $key_list_node;
3164 }
3165
3166 if ( 'keyPart' === $key_part_node->rule_name ) {
3167 $key_part = $this->translate( $key_part_node->get_first_child_node( 'identifier' ) );
3168 $direction = $key_part_node->get_first_child_node( 'direction' );
3169 if ( null !== $direction ) {
3170 $key_part .= ' ' . $this->translate( $direction );
3171 }
3172 } else {
3173 $key_part = $this->translate( $key_part_node );
3174 }
3175 $key_parts[] = $key_part;
3176 }
3177
3178 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $index_name );
3179 $this->execute_sqlite_query(
3180 sprintf(
3181 'CREATE %sINDEX %s ON %s (%s)',
3182 $is_unique ? 'UNIQUE ' : '',
3183 $this->quote_sqlite_identifier( $sqlite_index_name ),
3184 $this->translate( $target->get_first_child_node( 'tableRef' ) ),
3185 implode( ', ', $key_parts )
3186 )
3187 );
3188 }
3189
3190 /**
3191 * Translate and execute a MySQL DROP INDEX statement in SQLite.
3192 *
3193 * @param WP_Parser_Node $node The "dropStatement" AST node with "dropIndex" child.
3194 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3195 */
3196 private function execute_drop_index_statement( WP_Parser_Node $node ): void {
3197 $drop_index = $node->get_first_child_node( 'dropIndex' );
3198 $table_ref = $drop_index->get_first_child_node( 'tableRef' );
3199 $database = $this->get_database_name( $table_ref );
3200 if ( 'information_schema' === strtolower( $database ) ) {
3201 throw $this->new_access_denied_to_information_schema_exception();
3202 }
3203
3204 $this->information_schema_builder->record_drop_index( $node );
3205
3206 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3207 $index_name = $this->unquote_sqlite_identifier(
3208 $this->translate( $drop_index->get_first_child_node( 'indexRef' ) )
3209 );
3210
3211 /*
3212 * In MySQL, "DROP INDEX `PRIMARY` ON <table>" removes the PRIMARY KEY.
3213 * This is not supported in SQLite, so in such cases, we need to recreate
3214 * the table without the PRIMARY KEY using the updated information schema.
3215 */
3216 if ( 'PRIMARY' === strtoupper( $index_name ) ) {
3217 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
3218 $this->recreate_table_from_information_schema( $table_is_temporary, $table_name );
3219 return;
3220 }
3221
3222 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $index_name );
3223 $this->execute_sqlite_query(
3224 sprintf(
3225 'DROP INDEX %s',
3226 $this->quote_sqlite_identifier( $sqlite_index_name )
3227 )
3228 );
3229 }
3230
3231 /**
3232 * Translate and execute a MySQL SHOW statement in SQLite.
3233 *
3234 * @param WP_Parser_Node $node The "showStatement" AST node.
3235 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3236 */
3237 private function execute_show_statement( WP_Parser_Node $node ): void {
3238 $tokens = $node->get_child_tokens();
3239 $keyword1 = $tokens[1];
3240 $keyword2 = $tokens[2] ?? null;
3241
3242 switch ( $keyword1->id ) {
3243 case WP_MySQL_Lexer::COLLATION_SYMBOL:
3244 $this->execute_show_collation_statement( $node );
3245 return;
3246 case WP_MySQL_Lexer::DATABASES_SYMBOL:
3247 $this->execute_show_databases_statement( $node );
3248 return;
3249 case WP_MySQL_Lexer::COLUMNS_SYMBOL:
3250 case WP_MySQL_Lexer::FIELDS_SYMBOL:
3251 $this->execute_show_columns_statement( $node );
3252 return;
3253 case WP_MySQL_Lexer::CREATE_SYMBOL:
3254 if ( WP_MySQL_Lexer::TABLE_SYMBOL === $keyword2->id ) {
3255 $table_ref = $node->get_first_child_node( 'tableRef' );
3256 $database = $this->get_database_name( $table_ref );
3257 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3258
3259 // Refuse SHOW CREATE TABLE for information schema tables,
3260 // as we don't have the table definitions at the moment.
3261 if ( 'information_schema' === strtolower( $database ) ) {
3262 throw $this->new_driver_exception(
3263 sprintf( "SHOW command denied to user 'sqlite'@'%%' for table '%s'", $table_name ),
3264 '42000'
3265 );
3266 }
3267
3268 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
3269
3270 $sql = $this->get_mysql_create_table_statement( $table_is_temporary, $table_name );
3271
3272 $this->last_column_meta = array(
3273 array(
3274 'native_type' => 'STRING',
3275 'pdo_type' => PDO::PARAM_STR,
3276 'flags' => array( 'not_null' ),
3277 'table' => '',
3278 'name' => 'Table',
3279 'len' => 256,
3280 'precision' => 31,
3281 ),
3282 array(
3283 'native_type' => 'STRING',
3284 'pdo_type' => PDO::PARAM_STR,
3285 'flags' => array( 'not_null' ),
3286 'table' => '',
3287 'name' => 'Create Table',
3288 'len' => strlen( $sql ?? '' ),
3289 'precision' => 31,
3290 ),
3291 );
3292
3293 $this->last_result_statement = $this->create_result_statement_from_data(
3294 array_column( $this->last_column_meta, 'name' ),
3295 null === $sql ? array() : array( array( $table_name, $sql ) )
3296 );
3297 $this->found_rows = null === $sql ? 0 : 1;
3298 return;
3299 }
3300 break;
3301 case WP_MySQL_Lexer::INDEX_SYMBOL:
3302 case WP_MySQL_Lexer::INDEXES_SYMBOL:
3303 case WP_MySQL_Lexer::KEYS_SYMBOL:
3304 $this->execute_show_index_statement( $node );
3305 return;
3306 case WP_MySQL_Lexer::GRANTS_SYMBOL:
3307 $this->last_result_statement = $this->create_result_statement_from_data(
3308 array( 'Grants for root@%' ),
3309 array( array( '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' ) )
3310 );
3311 $this->last_column_meta = array(
3312 array(
3313 'native_type' => 'STRING',
3314 'pdo_type' => PDO::PARAM_STR,
3315 'flags' => array( 'not_null' ),
3316 'table' => '',
3317 'name' => 'Grants for root@%',
3318 'len' => 4096,
3319 'precision' => 31,
3320 ),
3321 );
3322 $this->found_rows = 1;
3323 return;
3324 case WP_MySQL_Lexer::TABLE_SYMBOL:
3325 $this->execute_show_table_status_statement( $node );
3326 return;
3327 case WP_MySQL_Lexer::TABLES_SYMBOL:
3328 $this->execute_show_tables_statement( $node );
3329 return;
3330 case WP_MySQL_Lexer::VARIABLES_SYMBOL:
3331 $this->execute_show_variables_statement( $node );
3332 return;
3333 }
3334
3335 throw $this->new_not_supported_exception(
3336 sprintf(
3337 'statement type: "%s" > "%s"',
3338 $node->rule_name,
3339 $keyword1->get_value()
3340 )
3341 );
3342 }
3343
3344 /**
3345 * Translate and execute a MySQL SHOW VARIABLES statement in SQLite.
3346 *
3347 * @param WP_Parser_Node $node The "showStatement" AST node.
3348 */
3349 private function execute_show_variables_statement( WP_Parser_Node $node ): void {
3350 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
3351 if ( null !== $like_or_where ) {
3352 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'Variable_name' );
3353 }
3354
3355 $query = sprintf(
3356 "SELECT column1 AS `Variable_name`, column2 AS `Value`
3357 FROM (
3358 VALUES
3359 ('version', ?),
3360 ('version_comment', ?)
3361 )
3362 WHERE TRUE %s
3363 ORDER BY column1",
3364 $condition ?? ''
3365 );
3366 $params = array(
3367 $this->get_mysql_server_version_string(),
3368 $this->get_mysql_server_version_comment(),
3369 );
3370
3371 $stmt = $this->execute_sqlite_query( $query, $params );
3372 $this->store_last_column_meta_from_statement( $stmt );
3373 $this->last_result_statement = $stmt;
3374 $this->found_rows = array( $query, $params );
3375 }
3376
3377 /**
3378 * Translate and execute a MySQL SHOW COLLATION statement in SQLite.
3379 *
3380 * @param WP_Parser_Node $node The "showStatement" AST node.
3381 */
3382 private function execute_show_collation_statement( WP_Parser_Node $node ): void {
3383 $definition = $this->information_schema_builder
3384 ->get_computed_information_schema_table_definition( 'collations' );
3385
3386 // LIKE and WHERE clauses.
3387 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
3388 if ( $like_or_where ) {
3389 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'collation_name' );
3390 }
3391
3392 $query = sprintf(
3393 'SELECT
3394 COLLATION_NAME AS `Collation`,
3395 CHARACTER_SET_NAME AS `Charset`,
3396 ID AS `Id`,
3397 IS_DEFAULT AS `Default`,
3398 IS_COMPILED AS `Compiled`,
3399 SORTLEN AS `Sortlen`,
3400 PAD_ATTRIBUTE AS `Pad_attribute`
3401 FROM (%s)
3402 WHERE TRUE %s',
3403 $definition,
3404 $condition ?? ''
3405 );
3406 $stmt = $this->execute_sqlite_query( $query );
3407 $this->store_last_column_meta_from_statement( $stmt );
3408 $this->last_result_statement = $stmt;
3409 $this->found_rows = $query;
3410 }
3411
3412 /**
3413 * Translate and execute a MySQL SHOW DATABASES statement in SQLite.
3414 *
3415 * @param WP_Parser_Node $node The "showStatement" AST node.
3416 */
3417 private function execute_show_databases_statement( WP_Parser_Node $node ): void {
3418 $schemata_table = $this->information_schema_builder->get_table_name( false, 'schemata' );
3419
3420 // LIKE and WHERE clauses.
3421 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
3422 if ( $like_or_where ) {
3423 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'schema_name' );
3424 }
3425 $query = sprintf(
3426 'SELECT SCHEMA_NAME AS Database
3427 FROM (
3428 SELECT CASE WHEN SCHEMA_NAME = ? THEN ? ELSE SCHEMA_NAME END AS SCHEMA_NAME
3429 FROM %s
3430 ORDER BY SCHEMA_NAME
3431 )%s',
3432 $this->quote_sqlite_identifier( $schemata_table ),
3433 isset( $condition ) ? ( ' WHERE TRUE ' . $condition ) : ''
3434 );
3435 $params = array(
3436 $this->get_saved_db_name(),
3437 $this->main_db_name,
3438 );
3439
3440 $stmt = $this->execute_sqlite_query( $query, $params );
3441 $this->store_last_column_meta_from_statement( $stmt );
3442 $this->last_result_statement = $stmt;
3443 $this->found_rows = array( $query, $params );
3444 }
3445
3446 /**
3447 * Translate and execute a MySQL SHOW INDEX statement in SQLite.
3448 *
3449 * @param WP_Parser_Node $node The "showStatement" AST node.
3450 */
3451 private function execute_show_index_statement( WP_Parser_Node $node ): void {
3452 // Get database and table name.
3453 $table_ref = $node->get_first_child_node( 'tableRef' );
3454 $in_db = $node->get_first_child_node( 'inDb' );
3455 if ( $in_db ) {
3456 // FROM/IN database.
3457 $database = $this->get_database_name( $in_db );
3458 } else {
3459 $database = $this->get_database_name( $table_ref );
3460 }
3461 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3462
3463 // WHERE clause.
3464 $where = $node->get_first_child_node( 'whereClause' );
3465 if ( null !== $where ) {
3466 $value = $this->translate( $where->get_first_child_node( 'expr' ) );
3467 $condition = sprintf( 'AND %s', $value );
3468 } else {
3469 $condition = '';
3470 }
3471
3472 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
3473
3474 /*
3475 * TODO: Index naming.
3476 *
3477 * From the old driver:
3478 *
3479 * SQLite automatically assigns names to some indexes.
3480 * However, dbDelta in WordPress expects the name to be
3481 * the same as in the original CREATE TABLE. Let's
3482 * translate the name back.
3483 *
3484 * The old driver does the two following conversions:
3485 * 1)
3486 * $mysql_key_name = substr( $mysql_key_name, strlen( 'sqlite_autoindex_' ) );
3487 * $mysql_key_name = preg_replace( '/_[0-9]+$/', '', $mysql_key_name );
3488 * 2)
3489 * $mysql_key_name = substr( $mysql_key_name, strlen( "{$table_name}__" ) );
3490 */
3491
3492 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
3493 $query = sprintf(
3494 "
3495 SELECT
3496 TABLE_NAME AS `Table`,
3497 NON_UNIQUE AS `Non_unique`,
3498 INDEX_NAME AS `Key_name`,
3499 SEQ_IN_INDEX AS `Seq_in_index`,
3500 COLUMN_NAME AS `Column_name`,
3501 COLLATION AS `Collation`,
3502 CARDINALITY AS `Cardinality`,
3503 SUB_PART AS `Sub_part`,
3504 PACKED AS `Packed`,
3505 NULLABLE AS `Null`,
3506 INDEX_TYPE AS `Index_type`,
3507 COMMENT AS `Comment`,
3508 INDEX_COMMENT AS `Index_comment`,
3509 IS_VISIBLE AS `Visible`,
3510 EXPRESSION AS `Expression`
3511 FROM %s
3512 WHERE table_schema = ?
3513 AND table_name = ?
3514 %s
3515 ORDER BY
3516 INDEX_NAME = 'PRIMARY' DESC,
3517 NON_UNIQUE = '0' DESC,
3518 INDEX_TYPE = 'SPATIAL' DESC,
3519 INDEX_TYPE = 'BTREE' DESC,
3520 INDEX_TYPE = 'FULLTEXT' DESC,
3521 ROWID,
3522 SEQ_IN_INDEX
3523 ",
3524 $this->quote_sqlite_identifier( $statistics_table ),
3525 $condition
3526 );
3527 $params = array(
3528 $this->get_saved_db_name( $database ),
3529 $table_name,
3530 );
3531
3532 $stmt = $this->execute_sqlite_query( $query, $params );
3533 $this->store_last_column_meta_from_statement( $stmt );
3534 $this->last_result_statement = $stmt;
3535 $this->found_rows = array( $query, $params );
3536 }
3537
3538 /**
3539 * Translate and execute a MySQL SHOW TABLE STATUS statement in SQLite.
3540 *
3541 * @param WP_Parser_Node $node The "showStatement" AST node.
3542 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3543 */
3544 private function execute_show_table_status_statement( WP_Parser_Node $node ): void {
3545 // FROM/IN database.
3546 $in_db = $node->get_first_child_node( 'inDb' );
3547 if ( null === $in_db ) {
3548 $database = $this->db_name;
3549 } else {
3550 $database = $this->unquote_sqlite_identifier(
3551 $this->translate( $in_db->get_first_child_node( 'identifier' ) )
3552 );
3553 }
3554
3555 // LIKE and WHERE clauses.
3556 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
3557 if ( null !== $like_or_where ) {
3558 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'Name' );
3559 }
3560
3561 // SHOW TABLE STATUS lists only non-temporary tables.
3562 $tables_table = $this->information_schema_builder->get_table_name( false, 'tables' );
3563 $columns_table = $this->information_schema_builder->get_table_name( false, 'columns' );
3564
3565 // Compose a subquery to compute auto-increment values.
3566 $has_sequence_table = (bool) $this->execute_sqlite_query(
3567 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'"
3568 )->fetchColumn();
3569
3570 $auto_increment_subquery = sprintf(
3571 "(
3572 SELECT COALESCE(s.seq + 1, 1)
3573 FROM %s AS c
3574 %s
3575 WHERE c.extra = 'auto_increment'
3576 AND c.table_schema = t.table_schema
3577 AND c.table_name = t.table_name
3578 )",
3579 $this->quote_sqlite_identifier( $columns_table ),
3580 $has_sequence_table
3581 ? 'LEFT JOIN main.sqlite_sequence AS s ON s.name = c.table_name'
3582 : 'LEFT JOIN (SELECT 0 AS seq) AS s'
3583 );
3584
3585 $query = sprintf(
3586 'SELECT * FROM (
3587 SELECT
3588 table_name AS `Name`,
3589 engine AS `Engine`,
3590 version AS `Version`,
3591 row_format AS `Row_format`,
3592 table_rows AS `Rows`,
3593 avg_row_length AS `Avg_row_length`,
3594 data_length AS `Data_length`,
3595 max_data_length AS `Max_data_length`,
3596 index_length AS `Index_length`,
3597 data_free AS `Data_free`,
3598 %s AS `Auto_increment`,
3599 create_time AS `Create_time`,
3600 update_time AS `Update_time`,
3601 check_time AS `Check_time`,
3602 table_collation AS `Collation`,
3603 checksum AS `Checksum`,
3604 create_options AS `Create_options`,
3605 table_comment AS `Comment`
3606 FROM %s AS t
3607 WHERE table_schema = ?
3608 )
3609 WHERE 1 %s
3610 ORDER BY `Name`',
3611 $auto_increment_subquery,
3612 $this->quote_sqlite_identifier( $tables_table ),
3613 $condition ?? ''
3614 );
3615 $params = array(
3616 $this->get_saved_db_name( $database ),
3617 );
3618
3619 $stmt = $this->execute_sqlite_query( $query, $params );
3620 $this->store_last_column_meta_from_statement( $stmt );
3621 $this->last_result_statement = $stmt;
3622 $this->found_rows = array( $query, $params );
3623 }
3624
3625 /**
3626 * Translate and execute a MySQL SHOW TABLES statement in SQLite.
3627 *
3628 * @param WP_Parser_Node $node The "showStatement" AST node.
3629 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3630 */
3631 private function execute_show_tables_statement( WP_Parser_Node $node ): void {
3632 // FROM/IN database.
3633 $in_db = $node->get_first_child_node( 'inDb' );
3634 if ( null === $in_db ) {
3635 $database = $this->db_name;
3636 } else {
3637 $database = $this->unquote_sqlite_identifier(
3638 $this->translate( $in_db->get_first_child_node( 'identifier' ) )
3639 );
3640 }
3641
3642 // LIKE and WHERE clauses.
3643 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
3644 if ( null !== $like_or_where ) {
3645 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'table_name' );
3646 }
3647
3648 // Handle the FULL keyword.
3649 $command_type = $node->get_first_child_node( 'showCommandType' );
3650 $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL );
3651
3652 // Fetch table information.
3653 $table_tables = $this->information_schema_builder->get_table_name(
3654 false, // SHOW TABLES lists only non-temporary tables.
3655 'tables'
3656 );
3657 $query = sprintf(
3658 'SELECT %s FROM %s WHERE table_schema = ? %s ORDER BY table_name',
3659 $is_full
3660 ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database )
3661 : sprintf( 'table_name AS `Tables_in_%s`', $database ),
3662 $this->quote_sqlite_identifier( $table_tables ),
3663 $condition ?? ''
3664 );
3665 $params = array(
3666 $this->get_saved_db_name( $database ),
3667 );
3668
3669 $stmt = $this->execute_sqlite_query( $query, $params );
3670 $this->store_last_column_meta_from_statement( $stmt );
3671 $this->last_result_statement = $stmt;
3672 $this->found_rows = array( $query, $params );
3673 }
3674
3675 /**
3676 * Translate and execute a MySQL SHOW COLUMNS statement in SQLite.
3677 *
3678 * @param WP_Parser_Node $node The "showStatement" AST node.
3679 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3680 * @throws PDOException When given table doesn't exist.
3681 */
3682 private function execute_show_columns_statement( WP_Parser_Node $node ): void {
3683 // TODO: EXTENDED, FULL
3684
3685 // Get database and table name.
3686 $table_ref = $node->get_first_child_node( 'tableRef' );
3687 $in_db = $node->get_first_child_node( 'inDb' );
3688 if ( $in_db ) {
3689 // FROM/IN database.
3690 $database = $this->get_database_name( $in_db );
3691 } else {
3692 $database = $this->get_database_name( $table_ref );
3693 }
3694 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3695 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
3696
3697 // Check if the table exists.
3698 $tables_tables = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
3699 $table_exists = $this->execute_sqlite_query(
3700 sprintf(
3701 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ?',
3702 $this->quote_sqlite_identifier( $tables_tables )
3703 ),
3704 array( $this->get_saved_db_name( $database ), $table_name )
3705 )->fetchColumn();
3706
3707 if ( ! $table_exists ) {
3708 throw $this->new_table_not_found_exception( sprintf( '%s.%s', $database, $table_name ) );
3709 }
3710
3711 // LIKE and WHERE clauses.
3712 $like_or_where = $node->get_first_child_node( 'likeOrWhere' );
3713 if ( null !== $like_or_where ) {
3714 $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'column_name' );
3715 }
3716
3717 // Handle the FULL keyword.
3718 $command_type = $node->get_first_child_node( 'showCommandType' );
3719 $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL );
3720
3721 // Fetch column information.
3722 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
3723
3724 if ( $is_full ) {
3725 $fields = '
3726 column_name AS `Field`,
3727 column_type AS `Type`,
3728 collation_name AS `Collation`,
3729 is_nullable AS `Null`,
3730 column_key AS `Key`,
3731 column_default AS `Default`,
3732 extra AS `Extra`,
3733 privileges AS `Privileges`,
3734 column_comment AS `Comment`
3735 ';
3736 } else {
3737 $fields = '
3738 column_name AS `Field`,
3739 column_type AS `Type`,
3740 is_nullable AS `Null`,
3741 column_key AS `Key`,
3742 column_default AS `Default`,
3743 extra AS `Extra`
3744 ';
3745 }
3746
3747 $query = sprintf(
3748 'SELECT %s
3749 FROM %s
3750 WHERE table_schema = ? AND table_name = ? %s
3751 ORDER BY ordinal_position',
3752 $fields,
3753 $this->quote_sqlite_identifier( $columns_table ),
3754 $condition ?? ''
3755 );
3756 $params = array(
3757 $this->get_saved_db_name( $database ),
3758 $table_name,
3759 );
3760
3761 $stmt = $this->execute_sqlite_query( $query, $params );
3762 $this->store_last_column_meta_from_statement( $stmt );
3763 $this->last_result_statement = $stmt;
3764 $this->found_rows = array( $query, $params );
3765 }
3766
3767 /**
3768 * Translate and execute a MySQL DESCRIBE statement in SQLite.
3769 *
3770 * @param WP_Parser_Node $node The "describeStatement" AST node.
3771 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3772 */
3773 private function execute_describe_statement( WP_Parser_Node $node ): void {
3774 $table_ref = $node->get_first_child_node( 'tableRef' );
3775 $database = $this->get_database_name( $table_ref );
3776 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
3777
3778 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
3779
3780 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
3781 $query = sprintf(
3782 'SELECT
3783 column_name AS `Field`,
3784 column_type AS `Type`,
3785 is_nullable AS `Null`,
3786 column_key AS `Key`,
3787 column_default AS `Default`,
3788 extra AS `Extra`
3789 FROM %s
3790 WHERE table_schema = ?
3791 AND table_name = ?
3792 ORDER BY ordinal_position',
3793 $this->quote_sqlite_identifier( $columns_table )
3794 );
3795 $params = array(
3796 $this->get_saved_db_name( $database ),
3797 $table_name,
3798 );
3799
3800 $stmt = $this->execute_sqlite_query( $query, $params );
3801 $this->store_last_column_meta_from_statement( $stmt );
3802 $this->last_result_statement = $stmt;
3803 $this->found_rows = array( $query, $params );
3804 }
3805
3806 /**
3807 * Translate and execute a MySQL USE statement in SQLite.
3808 *
3809 * @param WP_Parser_Node $node The "useStatement" AST node.
3810 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3811 */
3812 private function execute_use_statement( WP_Parser_Node $node ): void {
3813 $database_name = $this->unquote_sqlite_identifier(
3814 $this->translate( $node->get_first_child_node( 'identifier' ) )
3815 );
3816 $database_name = strtolower( $database_name );
3817
3818 if ( $this->main_db_name === $database_name || 'information_schema' === $database_name ) {
3819 $this->db_name = $database_name;
3820 } else {
3821 throw $this->new_not_supported_exception(
3822 sprintf(
3823 "can't use schema '%s', only '%s' and 'information_schema' are supported",
3824 $database_name,
3825 $this->db_name
3826 )
3827 );
3828 }
3829 }
3830
3831 /**
3832 * Translate and execute a MySQL SET statement in SQLite.
3833 *
3834 * @param WP_Parser_Node $node The "setStatement" AST node.
3835 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3836 */
3837 private function execute_set_statement( WP_Parser_Node $node ): void {
3838 /*
3839 * 1. Flatten the SET statement into a single array of definitions.
3840 *
3841 * The grammar is non-trivial, and supports multi-statements like:
3842 * SET @var = '...', SESSION sql_mode = '...', @@GLOBAL.time_zone = '...', @@debug = '...', ...
3843 *
3844 * This will be flattened into a single array of grammar node lists:
3845 * [
3846 * [ <userVariable>, <equal>, <expr> ],
3847 * [ <optionType>, <internalVariableName>, <equal>, <setExprOrDefault> ],
3848 * [ <setSystemVariable>, <equal>, <setExprOrDefault> ],
3849 * [ <setSystemVariable>, <equal>, <setExprOrDefault> ],
3850 * ]
3851 */
3852 $subnode = $node->get_first_child_node();
3853 if ( $subnode->has_child_node( 'optionValueNoOptionType' ) ) {
3854 $start_node = $subnode->get_first_child_node( 'optionValueNoOptionType' );
3855 $definitions = array( $start_node->get_children() );
3856 } elseif ( $subnode->has_child_node( 'startOptionValueListFollowingOptionType' ) ) {
3857 $start_node = $subnode
3858 ->get_first_child_node( 'startOptionValueListFollowingOptionType' )
3859 ->get_first_child_node( 'optionValueFollowingOptionType' ) ?? $node;
3860 $definitions = array(
3861 array_merge(
3862 array( $subnode->get_first_child_node( 'optionType' ) ),
3863 $start_node->get_children()
3864 ),
3865 );
3866 } else {
3867 $definitions = array( $subnode->get_children() );
3868 }
3869
3870 $continue_node = $subnode->get_first_child_node( 'optionValueListContinued' );
3871 if ( $continue_node ) {
3872 foreach ( $continue_node->get_child_nodes( 'optionValue' ) as $child ) {
3873 $node = $child->get_first_child_node( 'optionValueNoOptionType' ) ?? $child;
3874 $definitions[] = $node->get_child_nodes();
3875 }
3876 }
3877
3878 /*
3879 * 2. Iterate and process the SET definitions.
3880 *
3881 * When an "optionType" node is encountered (such as "SESSION var = ..."),
3882 * it's value is used for all following system variable assignments that
3883 * have no type keyword specified, until the next "optionType" is found.
3884 *
3885 * This doesn't apply to "@@" type prefixes (such as "@@SESSION.var_name"),
3886 * which always impact only the immediately following system variable.
3887 */
3888 $default_type = WP_MySQL_Lexer::SESSION_SYMBOL;
3889 foreach ( $definitions as $definition ) {
3890 // Check if the definition starts with an "optionType" node with
3891 // one of the SESSION, GLOBAL, PERSIST, or PERSIST_ONLY tokens.
3892 $part = array_shift( $definition );
3893 if ( $part instanceof WP_Parser_Node && 'optionType' === $part->rule_name ) {
3894 $default_type = $part->get_first_child_token()->id;
3895 $part = array_shift( $definition );
3896 }
3897
3898 if (
3899 $part instanceof WP_MySQL_Token
3900 && WP_MySQL_Lexer::NAMES_SYMBOL === $part->id
3901 ) {
3902 // "SET NAMES ..." is a no-op for now.
3903 // TODO: Validate charset compatibility with UTF-8.
3904 // See: https://github.com/WordPress/sqlite-database-integration/issues/192
3905 } elseif (
3906 $part instanceof WP_Parser_Node
3907 && 'charsetClause' === $part->rule_name
3908 ) {
3909 // "SET CHARACTER SET ..." is a no-op for now.
3910 // TODO: Validate charset compatibility with UTF-8.
3911 // See: https://github.com/WordPress/sqlite-database-integration/issues/192
3912 } elseif (
3913 $part instanceof WP_Parser_Node
3914 && (
3915 'internalVariableName' === $part->rule_name
3916 || 'setSystemVariable' === $part->rule_name
3917 )
3918 ) {
3919 // Set a system variable.
3920 array_shift( $definition ); // Remove the '='.
3921 $value = array_shift( $definition );
3922 $this->execute_set_system_variable_statement( $part, $value, $default_type );
3923 } elseif (
3924 $part instanceof WP_Parser_Node
3925 && 'userVariable' === $part->rule_name
3926 ) {
3927 // Set a user variable.
3928 array_shift( $definition ); // Remove the '='.
3929 $value = array_shift( $definition );
3930 $this->execute_set_user_variable_statement( $part, $value );
3931 } else {
3932 throw $this->new_not_supported_exception(
3933 sprintf( 'SET statement: %s', $node->rule_name )
3934 );
3935 }
3936 }
3937
3938 $this->last_result_statement = $this->create_result_statement_from_data( array(), array() );
3939 }
3940
3941 /**
3942 * Translate and execute a MySQL SET statement for system variables.
3943 *
3944 * @param WP_Parser_Node $set_var_node The "internalVariableName" or "setSystemVariable" AST node.
3945 * @param WP_Parser_Node $value_node The "setExprOrDefault" AST node.
3946 * @param int $default_type The currently active default variable type.
3947 * One of the SESSION, GLOBAL, PERSIST, PERSIST_ONLY tokens.
3948 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
3949 */
3950 private function execute_set_system_variable_statement(
3951 WP_Parser_Node $set_var_node,
3952 WP_Parser_Node $value_node,
3953 int $default_type
3954 ): void {
3955 // Get the variable name.
3956 $internal_variable_name = 'setSystemVariable' === $set_var_node->rule_name
3957 ? $set_var_node->get_first_child_node( 'internalVariableName' )
3958 : $set_var_node;
3959
3960 $name = strtolower(
3961 $this->unquote_sqlite_identifier(
3962 $this->translate( $internal_variable_name )
3963 )
3964 );
3965
3966 // Get the type attribute (one of SESSION, GLOBAL, PERSIST, PERSIST_ONLY).
3967 $type = $default_type;
3968 if ( $set_var_node->has_child_node( 'setVarIdentType' ) ) {
3969 $var_ident_type = $set_var_node->get_first_child_node( 'setVarIdentType' );
3970 $type = $var_ident_type->get_first_child_token()->id;
3971 }
3972
3973 /*
3974 * Some MySQL system variables values can be set using an unquoted pure
3975 * identifier rather than a string literal. This includes non-reserved
3976 * keywords. This is equivalent to using a corresponding string literal.
3977 *
3978 * For example, the following statement pairs are equivalent:
3979 *
3980 * SET default_storage_engine = InnoDB
3981 * SET default_storage_engine = 'InnoDB'
3982 *
3983 * SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci
3984 * SET default_collation_for_utf8mb4 = 'utf8mb4_0900_ai_ci'
3985 *
3986 * In this cases, we need to use the value directly without attempting
3987 * to evaluate the expression, as that would result in a query error.
3988 * In the grammar, unquoted identifiers are captured by "columnRef".
3989 */
3990 $identifier = $this->translate( $value_node->get_first_descendant_node( 'columnRef' ) );
3991 if ( $identifier && $identifier === $this->translate( $value_node ) ) {
3992 $value = $this->unquote_sqlite_identifier( $identifier );
3993 } elseif ( ! $value_node->has_child_node( 'expr' ) ) {
3994 $value = $this->unquote_sqlite_identifier( $this->translate( $value_node ) );
3995 } else {
3996 $value = $this->evaluate_expression( $value_node );
3997 }
3998
3999 /*
4000 * Handle ON/OFF values. They are accepted as both strings and keywords.
4001 *
4002 * @TODO: This is actually variable-specific and depends on its type.
4003 * For example:
4004 * SET autocommit = OFF; SELECT @@autocommit; -> 0
4005 * SET autocommit = false; SELECT @@autocommit; -> 0
4006 * SET session_track_gtids = OFF; SELECT @@session_track_gtids; -> OFF
4007 * SET session_track_gtids = false; SELECT @@session_track_gtids; -> OFF
4008 * SET updatable_views_with_limit = OFF; ERROR 1231 (42000)
4009 * SET updatable_views_with_limit = false; SELECT @@updatable_views_with_limit; -> NO
4010 */
4011 $lowercase_value = null === $value ? null : strtolower( $value );
4012 if ( 'sql_mode' !== $name && ( 'on' === $lowercase_value || 'off' === $lowercase_value ) ) {
4013 $value = 'on' === $lowercase_value ? 1 : 0;
4014 }
4015
4016 if ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) {
4017 if ( 'sql_mode' === $name ) {
4018 if ( null !== $value_node->get_first_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) {
4019 $sql_modes = $this->get_default_sql_modes();
4020 } elseif ( is_string( $value ) ) {
4021 /*
4022 * MySQL removes spaces only from the end of the complete value.
4023 * Spaces within individual mode names remain invalid.
4024 *
4025 * See: https://github.com/mysql/mysql-server/blob/8.4/sql/strfunc.cc#L39-L64
4026 */
4027 $sql_modes = explode( ',', rtrim( $value, ' ' ) );
4028 } else {
4029 $sql_modes = $value;
4030 }
4031 $this->set_sql_modes( $sql_modes );
4032 } else {
4033 $this->session_system_variables[ $name ] = $value;
4034 }
4035 } elseif ( WP_MySQL_Lexer::GLOBAL_SYMBOL === $type ) {
4036 throw $this->new_not_supported_exception( "SET statement type: 'GLOBAL'" );
4037 } elseif ( WP_MySQL_Lexer::PERSIST_SYMBOL === $type ) {
4038 throw $this->new_not_supported_exception( "SET statement type: 'PERSIST'" );
4039 } elseif ( WP_MySQL_Lexer::PERSIST_ONLY_SYMBOL === $type ) {
4040 throw $this->new_not_supported_exception( "SET statement type: 'PERSIST_ONLY'" );
4041 }
4042
4043 // TODO: Handle GLOBAL, PERSIST, and PERSIST_ONLY types.
4044 }
4045
4046 /**
4047 * Translate and execute a MySQL SET statement for user variables.
4048 *
4049 * @param WP_Parser_Node $user_variable The "userVariable" AST node.
4050 * @param WP_Parser_Node $expr The "expr" AST node.
4051 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
4052 */
4053 private function execute_set_user_variable_statement(
4054 WP_Parser_Node $user_variable,
4055 WP_Parser_Node $expr
4056 ): void {
4057 $name = $this->unquote_sqlite_identifier(
4058 $this->translate( $user_variable->get_first_child() )
4059 );
4060 $name = strtolower( substr( $name, 1 ) ); // Remove '@', normalize case.
4061 $value = $this->evaluate_expression( $expr );
4062
4063 $this->user_variables[ $name ] = $value;
4064 }
4065
4066 /**
4067 * Translate and execute a MySQL administration statement in SQLite.
4068 *
4069 * This emulates the following MySQL statements:
4070 * - ANALYZE TABLE
4071 * - CHECK TABLE
4072 * - OPTIMIZE TABLE
4073 * - REPAIR TABLE
4074 *
4075 * @param WP_Parser_Node $node A "tableAdministrationStatement" AST node.
4076 * @throws WP_MySQL_On_SQLite_Exception When the query execution fails.
4077 */
4078 private function execute_administration_statement( WP_Parser_Node $node ): void {
4079 $first_token = $node->get_first_child_token();
4080 $table_ref_list = $node->get_first_child_node( 'tableRefList' );
4081 $results = array();
4082 foreach ( $table_ref_list->get_child_nodes( 'tableRef' ) as $table_ref ) {
4083 $database = $this->get_database_name( $table_ref );
4084 if ( 'information_schema' === strtolower( $database ) ) {
4085 throw $this->new_access_denied_to_information_schema_exception();
4086 }
4087
4088 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
4089 $quoted_table_name = $this->quote_sqlite_identifier( $table_name );
4090 try {
4091 switch ( $first_token->id ) {
4092 case WP_MySQL_Lexer::ANALYZE_SYMBOL:
4093 $stmt = $this->execute_sqlite_query( sprintf( 'ANALYZE %s', $quoted_table_name ) );
4094 $errors = $stmt->fetchAll( PDO::FETCH_COLUMN );
4095 break;
4096 case WP_MySQL_Lexer::CHECK_SYMBOL:
4097 $stmt = $this->execute_sqlite_query(
4098 sprintf( 'PRAGMA integrity_check(%s)', $quoted_table_name )
4099 );
4100 $errors = $stmt->fetchAll( PDO::FETCH_COLUMN );
4101 if ( 'ok' === $errors[0] ) {
4102 array_shift( $errors );
4103 }
4104 break;
4105 case WP_MySQL_Lexer::OPTIMIZE_SYMBOL:
4106 case WP_MySQL_Lexer::REPAIR_SYMBOL:
4107 /*
4108 * SQLite doesn't support OPTIMIZE and REPAIR TABLE commands.
4109 * We will recreate the table and copy the data instead.
4110 * This corresponds to older MySQL OPTIMIZE TABLE behavior
4111 * and still applies to some storage engines in some cases.
4112 */
4113 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
4114 $this->recreate_table_from_information_schema( $table_is_temporary, $table_name );
4115 $errors = array();
4116 break;
4117 default:
4118 throw $this->new_not_supported_exception(
4119 sprintf(
4120 'statement type: "%s" > "%s"',
4121 $node->rule_name,
4122 $first_token->get_value()
4123 )
4124 );
4125 }
4126 } catch ( PDOException $e ) {
4127 if ( '42S02' === $e->getCode() && isset( $e->errorInfo[2] ) ) {
4128 $errors = array( $e->errorInfo[2] );
4129 } elseif ( 'HY000' === $e->getCode() ) {
4130 $errors = array( "Table '$table_name' doesn't exist" );
4131 } else {
4132 $errors = array( $e->getMessage() );
4133 }
4134 }
4135
4136 $operation = strtolower( $first_token->get_value() );
4137 foreach ( $errors as $error ) {
4138 $results[] = array(
4139 'Table' => $this->db_name . '.' . $table_name,
4140 'Op' => $operation,
4141 'Msg_type' => 'Error',
4142 'Msg_text' => $error,
4143 );
4144 }
4145 $results[] = array(
4146 'Table' => $this->db_name . '.' . $table_name,
4147 'Op' => $operation,
4148 'Msg_type' => 'status',
4149 'Msg_text' => count( $errors ) > 0 ? 'Operation failed' : 'OK',
4150 );
4151 }
4152
4153 $this->last_column_meta = array(
4154 array(
4155 'native_type' => 'STRING',
4156 'pdo_type' => PDO::PARAM_STR,
4157 'flags' => array(),
4158 'table' => '',
4159 'name' => 'Table',
4160 'len' => 512,
4161 'precision' => 31,
4162 ),
4163 array(
4164 'native_type' => 'STRING',
4165 'pdo_type' => PDO::PARAM_STR,
4166 'flags' => array(),
4167 'table' => '',
4168 'name' => 'Op',
4169 'len' => 40,
4170 'precision' => 31,
4171 ),
4172 array(
4173 'native_type' => 'STRING',
4174 'pdo_type' => PDO::PARAM_STR,
4175 'flags' => array(),
4176 'table' => '',
4177 'name' => 'Msg_type',
4178 'len' => 40,
4179 'precision' => 31,
4180 ),
4181 array(
4182 'native_type' => 'TEXT',
4183 'pdo_type' => PDO::PARAM_STR,
4184 'flags' => array(),
4185 'table' => '',
4186 'name' => 'Msg_text',
4187 'len' => 1572864,
4188 'precision' => 31,
4189 ),
4190 );
4191 $this->last_result_statement = $this->create_result_statement_from_data(
4192 array_column( $this->last_column_meta, 'name' ),
4193 $results
4194 );
4195 }
4196
4197 /**
4198 * Evaluate an expression and return the value, preserving its type.
4199 *
4200 * This is used to support expressions in SET statements for MySQL variables.
4201 *
4202 * @param WP_Parser_Node $node The "expr" AST node.
4203 * @return mixed The value of the expression.
4204 */
4205 private function evaluate_expression( WP_Parser_Node $node ) {
4206 // To support expressions, we'll use a SQLite query.
4207 $stmt = $this->execute_sqlite_query(
4208 sprintf( 'SELECT %s', $this->translate( $node ) )
4209 );
4210
4211 // MySQL variables are typed, so we need to preserve the value type.
4212 $value = $stmt->fetchColumn();
4213 $type = $stmt->getColumnMeta( 0 )['native_type'];
4214 if ( 'null' === $type ) {
4215 return null;
4216 } elseif ( 'integer' === $type ) {
4217 return (int) $value;
4218 } elseif ( 'double' === $type ) {
4219 return (float) $value;
4220 }
4221 return $value;
4222 }
4223
4224 /**
4225 * Translate a MySQL AST node or token to an SQLite query fragment.
4226 *
4227 * @param WP_Parser_Node|WP_MySQL_Token $node The AST node to translate.
4228 * @return string|null The translated query fragment.
4229 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4230 */
4231 private function translate( $node ): ?string {
4232 if ( null === $node ) {
4233 return null;
4234 }
4235
4236 if ( $node instanceof WP_MySQL_Token ) {
4237 return $this->translate_token( $node );
4238 }
4239
4240 if ( ! $node instanceof WP_Parser_Node ) {
4241 throw $this->new_driver_exception(
4242 sprintf(
4243 'Expected a WP_Parser_Node or WP_MySQL_Token instance, got: %s',
4244 gettype( $node )
4245 )
4246 );
4247 }
4248
4249 $rule_name = $node->rule_name;
4250 switch ( $rule_name ) {
4251 case 'queryExpression':
4252 return $this->translate_query_expression( $node );
4253 case 'querySpecification':
4254 return $this->translate_query_specification( $node );
4255 case 'tableRef':
4256 return $this->translate_table_ref( $node );
4257 case 'qualifiedIdentifier':
4258 case 'tableRefWithWildcard':
4259 $parts = $node->get_descendant_nodes( 'identifier' );
4260 if ( count( $parts ) === 2 ) {
4261 return $this->translate_qualified_identifier( $parts[0], $parts[1] );
4262 }
4263 return $this->translate_qualified_identifier( null, $parts[0] );
4264 case 'fieldIdentifier':
4265 case 'simpleIdentifier':
4266 $parts = $node->get_descendant_nodes( 'identifier' );
4267 if ( count( $parts ) === 3 ) {
4268 return $this->translate_qualified_identifier( $parts[0], $parts[1], $parts[2] );
4269 } elseif ( count( $parts ) === 2 ) {
4270 return $this->translate_qualified_identifier( null, $parts[0], $parts[1] );
4271 }
4272 return $this->translate_qualified_identifier( null, null, $parts[0] );
4273 case 'tableWild':
4274 $parts = $node->get_descendant_nodes( 'identifier' );
4275 if ( count( $parts ) === 2 ) {
4276 return $this->translate_qualified_identifier( $parts[0], $parts[1] ) . '.*';
4277 }
4278 return $this->translate_qualified_identifier( null, $parts[0] ) . '.*';
4279 case 'dotIdentifier':
4280 return $this->translate_sequence( $node->get_children(), '' );
4281 case 'identifierKeyword':
4282 return '`' . $this->translate( $node->get_first_child() ) . '`';
4283 case 'pureIdentifier':
4284 $value = $this->translate_pure_identifier( $node );
4285
4286 /*
4287 * At the moment, we only support ASCII bytes in all identifiers.
4288 * This is because SQLite doesn't support case-insensitive Unicode
4289 * character matching: https://sqlite.org/faq.html#q18
4290 */
4291 for ( $i = 0; $i < strlen( $value ); $i++ ) {
4292 if ( ord( $value[ $i ] ) > 127 ) {
4293 throw $this->new_driver_exception(
4294 'The SQLite driver only supports ASCII characters in identifiers.'
4295 );
4296 }
4297 }
4298 return $value;
4299 case 'textStringLiteral':
4300 return $this->translate_string_literal( $node );
4301 case 'dataType':
4302 case 'nchar':
4303 $child = $node->get_first_child();
4304 if ( $child instanceof WP_Parser_Node ) {
4305 return $this->translate( $child );
4306 }
4307
4308 // Handle optional prefixes (data type is the second token):
4309 // 1. LONG VARCHAR, LONG CHAR(ACTER) VARYING, LONG VARBINARY.
4310 // 2. NATIONAL CHAR, NATIONAL VARCHAR, NATIONAL CHAR(ACTER) VARYING.
4311 if ( WP_MySQL_Lexer::LONG_SYMBOL === $child->id ) {
4312 $child = $node->get_child_tokens()[1] ?? null;
4313 } elseif ( WP_MySQL_Lexer::NATIONAL_SYMBOL === $child->id ) {
4314 $child = $node->get_child_tokens()[1] ?? null;
4315 }
4316
4317 if ( null === $child ) {
4318 throw $this->new_invalid_input_exception();
4319 }
4320
4321 $type_token = self::DATA_TYPE_MAP[ $child->id ] ?? null;
4322 if ( null !== $type_token ) {
4323 return $type_token;
4324 }
4325
4326 // SERIAL is an alias for BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE.
4327 if ( WP_MySQL_Lexer::SERIAL_SYMBOL === $child->id ) {
4328 return 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE';
4329 }
4330
4331 // @TODO: Handle SET and JSON.
4332 throw $this->new_not_supported_exception(
4333 sprintf( 'data type: %s', $child->get_value() )
4334 );
4335 case 'selectItem':
4336 return $this->translate_select_item( $node );
4337 case 'fromClause':
4338 // FROM DUAL is MySQL-specific syntax that means "FROM no tables"
4339 // and it is equivalent to omitting the FROM clause entirely.
4340 if ( $node->has_child_token( WP_MySQL_Lexer::DUAL_SYMBOL ) ) {
4341 return null;
4342 }
4343 return $this->translate_sequence( $node->get_children() );
4344 case 'simpleExprBody':
4345 return $this->translate_simple_expr_body( $node );
4346 case 'predicateOperations':
4347 $token = $node->get_first_child_token();
4348 if ( WP_MySQL_Lexer::LIKE_SYMBOL === $token->id ) {
4349 return $this->translate_like( $node );
4350 } elseif ( WP_MySQL_Lexer::REGEXP_SYMBOL === $token->id ) {
4351 return $this->translate_regexp_functions( $node );
4352 }
4353 return $this->translate_sequence( $node->get_children() );
4354 case 'runtimeFunctionCall':
4355 return $this->translate_runtime_function_call( $node );
4356 case 'functionCall':
4357 return $this->translate_function_call( $node );
4358 case 'substringFunction':
4359 $nodes = $node->get_child_nodes();
4360 if ( count( $nodes ) === 2 ) {
4361 return sprintf(
4362 'SUBSTR(%s, %s)',
4363 $this->translate( $nodes[0] ),
4364 $this->translate( $nodes[1] )
4365 );
4366 } else {
4367 return sprintf(
4368 'SUBSTR(%s, %s, %s)',
4369 $this->translate( $nodes[0] ),
4370 $this->translate( $nodes[1] ),
4371 $this->translate( $nodes[2] )
4372 );
4373 }
4374 case 'systemVariable':
4375 $var_ident_type = $node->get_first_child_node( 'varIdentType' );
4376 $type_token = $var_ident_type ? $var_ident_type->get_first_child_token() : null;
4377 $original_name = $this->unquote_sqlite_identifier(
4378 $this->translate( $node->get_first_child_node( 'textOrIdentifier' ) )
4379 );
4380
4381 $name = strtolower( $original_name );
4382 $type = $type_token ? $type_token->id : WP_MySQL_Lexer::SESSION_SYMBOL;
4383 if ( 'sql_mode' === $name ) {
4384 $value = implode( ',', $this->get_active_sql_mode_names() );
4385 } elseif ( 'version' === $name ) {
4386 $value = $this->get_mysql_server_version_string();
4387 } elseif ( 'version_comment' === $name ) {
4388 $value = $this->get_mysql_server_version_comment();
4389 } elseif ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) {
4390 $value = $this->session_system_variables[ $name ] ?? null;
4391 } else {
4392 // When we have no value, it's reasonable to use NULL.
4393 $value = null;
4394 }
4395
4396 // @TODO: Emulate more system variables, or use reasonable defaults.
4397 // See: https://dev.mysql.com/doc/refman/8.4/en/server-system-variable-reference.html
4398 // See: https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html
4399 if ( null === $value ) {
4400 return 'NULL';
4401 }
4402 if ( is_string( $value ) ) {
4403 return $this->quote_sqlite_value( $value );
4404 }
4405 return (string) $value;
4406 case 'userVariable':
4407 $name = $this->unquote_sqlite_identifier( $this->translate( $node->get_first_child() ) );
4408 $name = strtolower( substr( $name, 1 ) ); // Remove '@', normalize case.
4409 $value = $this->user_variables[ $name ] ?? null;
4410 if ( null === $value ) {
4411 return 'NULL';
4412 }
4413 if ( is_string( $value ) ) {
4414 return $this->quote_sqlite_value( $value );
4415 }
4416 return (string) $value;
4417 case 'castType':
4418 $first_child = $node->get_first_child();
4419 if ( $first_child instanceof WP_Parser_Node ) {
4420 $first_token = $first_child->get_first_child_token();
4421 } else {
4422 $first_token = $first_child;
4423 }
4424 switch ( $first_token->id ) {
4425 case WP_MySQL_Lexer::BINARY_SYMBOL:
4426 return 'BLOB';
4427 case WP_MySQL_Lexer::CHAR_SYMBOL:
4428 case WP_MySQL_Lexer::NCHAR_SYMBOL:
4429 case WP_MySQL_Lexer::NATIONAL_SYMBOL:
4430 case WP_MySQL_Lexer::DATE_SYMBOL:
4431 case WP_MySQL_Lexer::TIME_SYMBOL:
4432 case WP_MySQL_Lexer::DATETIME_SYMBOL:
4433 case WP_MySQL_Lexer::JSON_SYMBOL:
4434 return 'TEXT';
4435 case WP_MySQL_Lexer::SIGNED_SYMBOL:
4436 case WP_MySQL_Lexer::UNSIGNED_SYMBOL:
4437 // @TODO: Emulate UNSIGNED semantics. MySQL wraps negative
4438 // values, but SQLite has no unsigned integer type.
4439 return 'INTEGER';
4440 case WP_MySQL_Lexer::DECIMAL_SYMBOL:
4441 case WP_MySQL_Lexer::FLOAT_SYMBOL:
4442 case WP_MySQL_Lexer::REAL_SYMBOL:
4443 case WP_MySQL_Lexer::DOUBLE_SYMBOL:
4444 return 'REAL';
4445 default:
4446 throw $this->new_not_supported_exception(
4447 sprintf( 'cast type: %s', $first_child->get_value() )
4448 );
4449 }
4450 case 'defaultCollation':
4451 // @TODO: Check and save in information schema.
4452 return null;
4453 case 'duplicateAsQueryExpression':
4454 // @TODO: How to handle IGNORE/REPLACE?
4455
4456 // The "AS" keyword is optional in MySQL, but required in SQLite.
4457 return 'AS ' . $this->translate( $node->get_first_child_node() );
4458 case 'indexHint':
4459 case 'indexHintList':
4460 return null;
4461 case 'lockingClause':
4462 // SQLite doesn't support locking clauses (SELECT ... FOR UPDATE).
4463 // They are not needed in SQLite due to the database file locking.
4464 return null;
4465 default:
4466 return $this->translate_sequence( $node->get_children() );
4467 }
4468 }
4469
4470 /**
4471 * Translate a MySQL token to SQLite.
4472 *
4473 * @param WP_MySQL_Token $token The MySQL token to translate.
4474 * @return string|null The translated value.
4475 */
4476 private function translate_token( WP_MySQL_Token $token ): ?string {
4477 switch ( $token->id ) {
4478 case WP_MySQL_Lexer::EOF:
4479 return null;
4480 case WP_MySQL_Lexer::BIN_NUMBER:
4481 /*
4482 * There are no binary literals in SQLite. We need to convert all
4483 * MySQL binary string values to HEX strings in SQLite (x'...').
4484 */
4485 $value = $token->get_value();
4486 if ( '0' === $value[0] ) {
4487 // 0b...
4488 $value = substr( $value, 2 );
4489 } else {
4490 // b'...' or B'...'
4491 $value = substr( $value, 2, -1 );
4492 }
4493
4494 // Convert the binary string to HEX.
4495 $hex = base_convert( $value, 2, 16 );
4496
4497 /*
4498 * The "base_convert()" function doesn't add or preserve padding.
4499 * Let's compute how many bytes we expect and pad the HEX value
4500 * to full bytes (SQLite requires HEX strings of even length).
4501 */
4502 $byte_count = (int) ceil( strlen( $value ) / 8 );
4503 $hex = str_pad( $hex, $byte_count * 2, '0', STR_PAD_LEFT );
4504 return sprintf( "x'%s'", $hex );
4505 case WP_MySQL_Lexer::HEX_NUMBER:
4506 /*
4507 * In MySQL, "0x" prefixed values represent binary literal values,
4508 * while in SQLite, that would be a hexadecimal number. Therefore,
4509 * we need to convert the 0x... syntax to x'...'.
4510 */
4511 $value = $token->get_value();
4512 if ( '0' === $value[0] && 'x' === $value[1] ) {
4513 return sprintf( "x'%s'", substr( $value, 2 ) );
4514 }
4515 return $value;
4516 case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL:
4517 return 'AUTOINCREMENT';
4518 case WP_MySQL_Lexer::BINARY_SYMBOL:
4519 /*
4520 * "BINARY expr" is translated in "translate_simple_expr_body()".
4521 * Returning null here is a safety net for any unhandled context
4522 * where a bare BINARY token would otherwise leak into the output.
4523 */
4524 return null;
4525 case WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL:
4526 /*
4527 * The "SQL_CALC_FOUND_ROWS" keyword is implemented in the select
4528 * statement translation and then removed from the output here.
4529 */
4530 return null;
4531 default:
4532 return $token->get_value();
4533 }
4534 }
4535
4536 /**
4537 * Translate a sequence of MySQL AST nodes to SQLite.
4538 *
4539 * @param array<WP_Parser_Node|WP_MySQL_Token> $nodes The MySQL token to translate.
4540 * @param string $separator The separator to use between fragments.
4541 * @return string|null The translated value.
4542 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4543 */
4544 private function translate_sequence( array $nodes, string $separator = ' ' ): ?string {
4545 $parts = array();
4546 foreach ( $nodes as $node ) {
4547 if ( null === $node ) {
4548 continue;
4549 }
4550
4551 $translated = $this->translate( $node );
4552 if ( null === $translated ) {
4553 continue;
4554 }
4555 $parts[] = $translated;
4556 }
4557 if ( 0 === count( $parts ) ) {
4558 return null;
4559 }
4560 return implode( $separator, $parts );
4561 }
4562
4563 /**
4564 * Translate a MySQL string literal to SQLite.
4565 *
4566 * @param WP_Parser_Node $node The "textStringLiteral" AST node.
4567 * @return string The translated value.
4568 */
4569 private function translate_string_literal( WP_Parser_Node $node ): string {
4570 $token = $node->get_first_child_token();
4571 $value = $token->get_value();
4572
4573 /*
4574 * Translate datetime literals.
4575 *
4576 * Process only strings that could possibly represent a date or datetime
4577 * literal ("YYYY-M-D", "YYYY-MM-DDTHH:MM:SSZ", etc.).
4578 */
4579 if ( strlen( $value ) >= 8 && is_numeric( $value[0] ) ) {
4580 $value = $this->translate_datetime_literal( $value );
4581 }
4582
4583 /*
4584 * Handle null characters.
4585 *
4586 * SQLite doesn't fully support null characters (\u0000) in strings.
4587 * However, it can store them and read them, with some limitations.
4588 *
4589 * In PHP, null bytes are often produced by the serialize() function.
4590 * Removing them would damage the serialized data.
4591 *
4592 * There is no way to store null bytes using a string literal, so we
4593 * need to pass the value as a HEX string and cast it back to TEXT.
4594 * This will convert literals will null bytes to expressions.
4595 *
4596 * Alternatively, we could replace string literals with parameters and
4597 * pass them using prepared statements. However, that's not universally
4598 * applicable for all string literals (e.g., in default column values).
4599 *
4600 * We can't use the "part1 || CHAR(0) || part2 || ..." syntax, because
4601 * with a large number of null bytes, SQLite throws the following error:
4602 *
4603 * SQLSTATE[HY000]:
4604 * General error: 1 Expression tree is too large (maximum depth 1000)
4605 *
4606 * See:
4607 * https://www.sqlite.org/nulinstr.html
4608 */
4609 if ( strpos( $value, "\0" ) !== false ) {
4610 return sprintf( "CAST(x'%s' AS TEXT)", bin2hex( $value ) );
4611 }
4612 return $this->quote_sqlite_value( $value );
4613 }
4614
4615 /**
4616 * Translate a MySQL pure identifier to SQLite.
4617 *
4618 * @param WP_Parser_Node $node The "pureIdentifier" AST node.
4619 * @return string The translated value.
4620 */
4621 private function translate_pure_identifier( WP_Parser_Node $node ): string {
4622 $token = $node->get_first_child_token();
4623 $value = $token->get_value();
4624
4625 if ( str_starts_with( $value, self::RESERVED_PREFIX ) ) {
4626 throw $this->new_driver_exception(
4627 sprintf(
4628 "Invalid identifier '%s', prefix '%s' is reserved",
4629 $value,
4630 self::RESERVED_PREFIX
4631 )
4632 );
4633 }
4634
4635 return '`' . str_replace( '`', '``', $value ) . '`';
4636 }
4637
4638 /**
4639 * Translate a qualified MySQL identifier to SQLite.
4640 *
4641 * The identifier can be composed of 1 to 3 parts (schema, object, child).
4642 *
4643 * @param WP_Parser_Node|null $schema_node An identifier node representing a schema name (database).
4644 * @param WP_Parser_Node|null $object_node An identifier node representing a database-level object name
4645 * (table, view, procedure, trigger, etc.).
4646 * @param WP_Parser_Node|null $child_node An identifier node representing an object child name (column, index, etc.).
4647 * @return string The translated value.
4648 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4649 */
4650 private function translate_qualified_identifier(
4651 ?WP_Parser_Node $schema_node,
4652 ?WP_Parser_Node $object_node = null,
4653 ?WP_Parser_Node $child_node = null
4654 ): string {
4655 $parts = array();
4656
4657 // Database name.
4658 $is_information_schema = 'information_schema' === $this->db_name;
4659 if ( null !== $schema_node ) {
4660 $schema_name = $this->unquote_sqlite_identifier(
4661 $this->translate_sequence( $schema_node->get_children() )
4662 );
4663 if ( 'information_schema' === strtolower( $schema_name ) ) {
4664 $is_information_schema = true;
4665 } elseif ( $this->main_db_name === $schema_name ) {
4666 $is_information_schema = false;
4667 } else {
4668 throw $this->new_not_supported_exception(
4669 sprintf(
4670 "can't use schema '%s', only '%s' and 'information_schema' are supported",
4671 $schema_name,
4672 $this->db_name
4673 )
4674 );
4675 }
4676 }
4677
4678 // Database-level object name (table, view, procedure, trigger, etc.).
4679 if ( null !== $object_node ) {
4680 $parts[] = $this->translate( $object_node );
4681 }
4682
4683 // Object child name (column, index, etc.).
4684 if ( null !== $child_node ) {
4685 $parts[] = $this->translate( $child_node );
4686 }
4687
4688 return implode( '.', $parts );
4689 }
4690
4691 /**
4692 * Translate a MySQL query expression to SQLite.
4693 *
4694 * @param WP_Parser_Node $node The "queryExpression" AST node.
4695 * @return string The translated value.
4696 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4697 */
4698 private function translate_query_expression( WP_Parser_Node $node ): string {
4699 // Get the query expression subnode under which we need to look for the
4700 // SELECT item list node. This prevents searching under "withClause".
4701 $query_expr_main = (
4702 $node->get_first_child_node( 'queryExpressionBody' )
4703 ?? $node->get_first_child_node( 'queryExpressionParens' )
4704 );
4705 $query_term = $query_expr_main->get_first_descendant_node( 'queryTerm' );
4706 $has_union = $query_expr_main->has_child_token( WP_MySQL_Lexer::UNION_SYMBOL );
4707 $has_except = $query_expr_main->has_child_token( WP_MySQL_Lexer::EXCEPT_SYMBOL );
4708 $has_intersect = $query_term->has_child_token( WP_MySQL_Lexer::INTERSECT_SYMBOL );
4709
4710 /*
4711 * When the ORDER BY clause is present, we need to disambiguate the item
4712 * list and make sure they don't cause an "ambiguous column name" error.
4713 *
4714 * @see WP_MySQL_On_SQLite::disambiguate_item()
4715 */
4716 $disambiguated_order_list = array();
4717 $order_clause = $node->get_first_child_node( 'orderClause' );
4718 if ( $order_clause && ! $has_union && ! $has_except && ! $has_intersect ) {
4719 /*
4720 * [GRAMMAR]
4721 * queryExpression: (withClause)? (
4722 * queryExpressionBody orderClause? limitClause?
4723 * | queryExpressionParens orderClause? limitClause?
4724 * ) (procedureAnalyseClause)?
4725 */
4726
4727 // Create the SELECT item disambiguation map.
4728 $select_item_list = $query_expr_main->get_first_descendant_node( 'selectItemList' );
4729 $disambiguation_map = $this->create_select_item_disambiguation_map( $select_item_list );
4730
4731 // For each "orderList" item, search for a matching SELECT item.
4732 $disambiguated_order_list = array();
4733 $order_list = $order_clause->get_first_child_node( 'orderList' );
4734 foreach ( $order_list->get_child_nodes() as $order_item ) {
4735 /*
4736 * [GRAMMAR]
4737 * orderExpression: expr direction?
4738 */
4739 $order_expr = $order_item->get_first_child_node( 'expr' );
4740 $order_direction = $order_item->get_first_child_node( 'direction' );
4741 $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $order_expr );
4742
4743 $disambiguated_order_list[] = sprintf(
4744 '%s%s',
4745 $disambiguated_item ?? $this->translate( $order_expr ),
4746 null !== $order_direction ? ( ' ' . $this->translate( $order_direction ) ) : ''
4747 );
4748 }
4749
4750 // Translate the query expression, replacing the ORDER BY list with
4751 // the one that was constructed using the disambiguation algorithm.
4752 $parts = array();
4753 foreach ( $node->get_children() as $child ) {
4754 if ( $child instanceof WP_Parser_Node && 'orderClause' === $child->rule_name ) {
4755 $parts[] = 'ORDER BY ' . implode( ', ', $disambiguated_order_list );
4756 } else {
4757 $parts[] = $this->translate( $child );
4758 }
4759 }
4760 return implode( ' ', $parts );
4761 }
4762
4763 return $this->translate_sequence( $node->get_children() );
4764 }
4765
4766 /**
4767 * Translate a MySQL query specification node to SQLite.
4768 *
4769 * @param WP_Parser_Node $node The "querySpecification" AST node.
4770 * @return string The translated value.
4771 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4772 * @return string|null
4773 */
4774 private function translate_query_specification( WP_Parser_Node $node ): string {
4775 $group_by = $node->get_first_child_node( 'groupByClause' );
4776 $having = $node->get_first_child_node( 'havingClause' );
4777
4778 /*
4779 * When the GROUP BY or HAVING clause is present, we need to disambiguate
4780 * the items to ensure they don't cause an "ambiguous column name" error.
4781 *
4782 * @see WP_MySQL_On_SQLite::disambiguate_item()
4783 */
4784 $group_by_clause = null;
4785 $having_clause = null;
4786 if ( $group_by || $having ) {
4787 // Build a SELECT list disambiguation map for both GROUP BY and HAVING.
4788 $select_item_list = $node->get_first_child_node( 'selectItemList' );
4789 $disambiguation_map = $this->create_select_item_disambiguation_map( $select_item_list );
4790
4791 // Disambiguate the GROUP BY clause column references.
4792 $disambiguated_group_by_list = array();
4793 if ( $group_by ) {
4794 /*
4795 * [GRAMMAR]
4796 * groupByClause: GROUP_SYMBOL BY_SYMBOL orderList olapOption?
4797 */
4798 $group_by_list = $group_by->get_first_child_node( 'orderList' );
4799 foreach ( $group_by_list->get_child_nodes() as $group_by_item ) {
4800 $group_by_expr = $group_by_item->get_first_child_node( 'expr' );
4801 $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $group_by_expr );
4802 $disambiguated_group_by_list[] = $disambiguated_item ?? $this->translate( $group_by_expr );
4803 }
4804 $group_by_clause = 'GROUP BY ' . implode( ', ', $disambiguated_group_by_list );
4805 }
4806
4807 // Disambiguate the HAVING clause column references.
4808 $disambiguated_having_list = array();
4809 if ( $having ) {
4810 /*
4811 * [GRAMMAR]
4812 * havingClause: HAVING_SYMBOL expr
4813 */
4814 $having_expr = $having->get_first_child_node();
4815 $having_expr_children = $having_expr->get_children();
4816 foreach ( $having_expr_children as $having_item ) {
4817 if ( $having_item instanceof WP_Parser_Node ) {
4818 $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $having_item );
4819 $disambiguated_having_list[] = $disambiguated_item ?? $this->translate( $having_item );
4820 } else {
4821 $disambiguated_having_list[] = $this->translate( $having_item );
4822 }
4823 }
4824 $having_clause = 'HAVING ' . implode( ' ', $disambiguated_having_list );
4825 }
4826
4827 // Translate the query specification, replacing the ORDER BY/HAVING
4828 // items with the ones that were disambiguated using the SELECT list.
4829 $parts = array();
4830 foreach ( $node->get_children() as $child ) {
4831 if ( $child instanceof WP_Parser_Node && 'groupByClause' === $child->rule_name ) {
4832 $parts[] = $group_by_clause;
4833 } elseif ( $child instanceof WP_Parser_Node && 'havingClause' === $child->rule_name ) {
4834 // SQLite doesn't allow using the "HAVING" clause without "GROUP BY".
4835 // In such cases, let's prefix the "HAVING" clause with "GROUP BY 1".
4836 if ( ! $group_by ) {
4837 $parts[] = 'GROUP BY 1';
4838 }
4839 $parts[] = $having_clause;
4840 } else {
4841 $part = $this->translate( $child );
4842 if ( null !== $part ) {
4843 $parts[] = $part;
4844 }
4845 }
4846 }
4847 return implode( ' ', $parts );
4848 }
4849 return $this->translate_sequence( $node->get_children() );
4850 }
4851
4852 /**
4853 * Translate a MySQL simple expression body to SQLite.
4854 *
4855 * @param WP_Parser_Node $node The "simpleExprBody" AST node.
4856 * @return string The translated value.
4857 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4858 */
4859 private function translate_simple_expr_body( WP_Parser_Node $node ): string {
4860 $token = $node->get_first_child_token();
4861
4862 // Translate "VALUES(col)" to "excluded.col" in ON DUPLICATE KEY UPDATE.
4863 if ( null !== $token && WP_MySQL_Lexer::VALUES_SYMBOL === $token->id ) {
4864 return sprintf(
4865 '`excluded`.%s',
4866 $this->translate( $node->get_first_child_node( 'simpleIdentifier' ) )
4867 );
4868 }
4869
4870 /*
4871 * Translate "BINARY expr" to "expr COLLATE BINARY".
4872 *
4873 * The MySQL BINARY operator enforces byte-by-byte string comparison.
4874 * In SQLite, COLLATE BINARY is equivalent in comparison contexts.
4875 */
4876 if ( null !== $token && WP_MySQL_Lexer::BINARY_SYMBOL === $token->id ) {
4877 $expr = $node->get_first_child_node( 'simpleExpr' );
4878 return sprintf( '%s COLLATE BINARY', $this->translate( $expr ) );
4879 }
4880
4881 // Translate "CAST(expr AS type)" to its SQLite equivalent.
4882 if ( null !== $token && WP_MySQL_Lexer::CAST_SYMBOL === $token->id ) {
4883 $expr = $node->get_first_child_node( 'expr' );
4884 $cast_type = $node->get_first_child_node( 'castType' );
4885 return $this->translate_cast_expr( $expr, $cast_type );
4886 }
4887
4888 /**
4889 * Translate MySQL CONVERT() expression.
4890 *
4891 * MySQL supports two forms of CONVERT():
4892 * 1. CONVERT(expr, type): Equivalent to CAST(expr AS type).
4893 * 2. CONVERT(expr USING charset): Converts the character set.
4894 */
4895 if ( null !== $token && WP_MySQL_Lexer::CONVERT_SYMBOL === $token->id ) {
4896 $expr = $node->get_first_child_node( 'expr' );
4897 $cast_type = $node->get_first_child_node( 'castType' );
4898
4899 if ( null !== $cast_type ) {
4900 // CONVERT(expr, type): Translate to cast expression.
4901 // TODO: Emulate UNSIGNED cast. SQLite has no unsigned integer type.
4902 return $this->translate_cast_expr( $expr, $cast_type );
4903 } else {
4904 // CONVERT(expr USING charset): Keep "expr" as is (no SQLite support).
4905 // TODO: Consider rejecting UTF-8-incompatible charsets.
4906 return $this->translate( $expr );
4907 }
4908 }
4909
4910 return $this->translate_sequence( $node->get_children() );
4911 }
4912
4913 /**
4914 * Translate a MySQL CAST expression to SQLite.
4915 *
4916 * Shared by the CAST(expr AS type) and CONVERT(expr, type) forms.
4917 *
4918 * @param WP_Parser_Node $expr The "expr" AST node.
4919 * @param WP_Parser_Node $cast_type The "castType" AST node.
4920 * @return string The translated SQLite expression.
4921 */
4922 private function translate_cast_expr( WP_Parser_Node $expr, WP_Parser_Node $cast_type ): string {
4923 /*
4924 * Translate "CAST(expr AS BINARY)" to "CAST(expr AS TEXT) COLLATE BINARY".
4925 * Emitting "CAST(expr AS BLOB)" would break equality against TEXT values
4926 * due to SQLite's storage-class ordering (BLOB > TEXT).
4927 */
4928 if ( $cast_type->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) {
4929 return sprintf( 'CAST(%s AS TEXT) COLLATE BINARY', $this->translate( $expr ) );
4930 }
4931 return sprintf( 'CAST(%s AS %s)', $this->translate( $expr ), $this->translate( $cast_type ) );
4932 }
4933
4934 /**
4935 * Translate a MySQL LIKE expression to SQLite.
4936 *
4937 * @param WP_Parser_Node $node The "predicateOperations" AST node.
4938 * @return string The translated value.
4939 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4940 */
4941 private function translate_like( WP_Parser_Node $node ): string {
4942 $tokens = $node->get_descendant_tokens();
4943 $is_binary = isset( $tokens[1] ) && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[1]->id;
4944
4945 if ( true === $is_binary ) {
4946 // TODO: GLOB can match different invalid UTF-8 bytes as equal.
4947 // Use byte-wise matching for full LIKE BINARY compatibility.
4948 $children = $node->get_children();
4949 return sprintf(
4950 'GLOB _helper_like_to_glob_pattern(%s)',
4951 $this->translate( $children[1] )
4952 );
4953 }
4954
4955 /*
4956 * @TODO: Implement the ESCAPE '...' clause.
4957 */
4958
4959 /*
4960 * @TODO: Implement more correct LIKE behavior.
4961 *
4962 * While SQLite supports the LIKE operator, it seems to differ from the
4963 * MySQL behavior in some ways:
4964 *
4965 * 1. In SQLite, LIKE is case-insensitive only for ASCII characters
4966 * ('a' LIKE 'A' is TRUE but 'æ' LIKE 'Æ' is FALSE)
4967 * 2. In MySQL, LIKE interprets some escape sequences. See the contents
4968 * of the "_helper_like_to_glob_pattern" function.
4969 *
4970 * We'll probably need to overload the like() function:
4971 * https://www.sqlite.org/lang_corefunc.html#like
4972 */
4973 $statement = $this->translate_sequence( $node->get_children() );
4974 if ( $this->is_sql_mode_active( 'NO_BACKSLASH_ESCAPES' ) ) {
4975 return $statement;
4976 }
4977 return $statement . " ESCAPE '\\'";
4978 }
4979
4980 /**
4981 * Translate MySQL REGEXP expression to SQLite.
4982 *
4983 * @param WP_Parser_Node $node The "predicateOperations" AST node.
4984 * @return string The translated value.
4985 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
4986 */
4987 private function translate_regexp_functions( WP_Parser_Node $node ): string {
4988 $tokens = $node->get_descendant_tokens();
4989 $is_binary = isset( $tokens[1] ) && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[1]->id;
4990
4991 /*
4992 * If the query says REGEXP BINARY, the comparison is byte-by-byte
4993 * and letter casing matters – lowercase and uppercase letters are
4994 * represented using different byte codes.
4995 *
4996 * The REGEXP function can't be easily made to accept two
4997 * parameters, so we'll have to use a hack to get around this.
4998 *
4999 * If the first character of the pattern is a null byte, we'll
5000 * remove it and make the comparison case-sensitive. This should
5001 * be reasonably safe since PHP does not allow null bytes in
5002 * regular expressions anyway.
5003 */
5004 if ( true === $is_binary ) {
5005 return 'REGEXP CHAR(0) || ' . $this->translate( $node->get_first_child_node() );
5006 }
5007 return 'REGEXP ' . $this->translate( $node->get_first_child_node() );
5008 }
5009
5010 /**
5011 * Translate a MySQL runtime function call to SQLite.
5012 *
5013 * @param WP_Parser_Node $node The "runtimeFunctionCall" AST node.
5014 * @return string The translated value.
5015 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5016 */
5017 private function translate_runtime_function_call( WP_Parser_Node $node ): string {
5018 $child = $node->get_first_child();
5019 if ( $child instanceof WP_Parser_Node ) {
5020 return $this->translate( $child );
5021 }
5022
5023 switch ( $child->id ) {
5024 case WP_MySQL_Lexer::IF_SYMBOL:
5025 // CASE also supports SQLite versions before IIF() was added in 3.32.0.
5026 $nodes = $node->get_child_nodes();
5027 return sprintf(
5028 'CASE WHEN %s THEN %s ELSE %s END',
5029 $this->translate( $nodes[0] ),
5030 $this->translate( $nodes[1] ),
5031 $this->translate( $nodes[2] )
5032 );
5033 case WP_MySQL_Lexer::DATABASE_SYMBOL:
5034 return $this->quote_sqlite_value( $this->db_name );
5035 case WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL:
5036 case WP_MySQL_Lexer::NOW_SYMBOL:
5037 /*
5038 * 1) SQLite doesn't support CURRENT_TIMESTAMP() with parentheses.
5039 * 2) In MySQL, CURRENT_TIMESTAMP and CURRENT_TIMESTAMP() are an
5040 * alias of NOW(). In SQLite, there is no NOW() function.
5041 */
5042 return 'CURRENT_TIMESTAMP';
5043 case WP_MySQL_Lexer::DATE_ADD_SYMBOL:
5044 case WP_MySQL_Lexer::DATE_SUB_SYMBOL:
5045 $nodes = $node->get_child_nodes();
5046 $value = $this->translate( $nodes[1] );
5047 $unit = $this->translate( $nodes[2] );
5048 if ( 'WEEK' === $unit ) {
5049 $unit = 'DAY';
5050 $value = 7 * $value;
5051 }
5052 return sprintf(
5053 "DATETIME(%s, '%s' || %s || ' %s')",
5054 $this->translate( $nodes[0] ),
5055 WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id ? '-' : '+',
5056 $value,
5057 $unit
5058 );
5059 case WP_MySQL_Lexer::LEFT_SYMBOL:
5060 $nodes = $node->get_child_nodes();
5061 return sprintf(
5062 'SUBSTR(%s, 1, %s)',
5063 $this->translate( $nodes[0] ),
5064 $this->translate( $nodes[1] )
5065 );
5066 default:
5067 return $this->translate_sequence( $node->get_children() );
5068 }
5069 }
5070
5071 /**
5072 * Translate a MySQL function call to SQLite.
5073 *
5074 * @param WP_Parser_Node $node The "functionCall" AST node.
5075 * @return string The translated value.
5076 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5077 */
5078 private function translate_function_call( WP_Parser_Node $node ): string {
5079 $nodes = $node->get_child_nodes();
5080 $name = strtoupper(
5081 $this->unquote_sqlite_identifier( $this->translate( $nodes[0] ) )
5082 );
5083
5084 $args = array();
5085 if ( isset( $nodes[1] ) ) {
5086 foreach ( $nodes[1]->get_child_nodes() as $child ) {
5087 $args[] = $this->translate( $child );
5088 }
5089 }
5090
5091 switch ( $name ) {
5092 case 'RAND':
5093 /*
5094 * Unseeded RAND() compiles to a fast native SQLite expression.
5095 * In MySQL, unseeded RAND() uses the thread-level random state,
5096 * independent of any RAND(N) seeding, so we don't need PHP UDF.
5097 *
5098 * We map SQLite's RANDOM() to a float in [0, 1) by masking to
5099 * 53 bits (IEEE 754 double mantissa width) and dividing by 2^53.
5100 * This avoids the edge case where masking to 63 bits and dividing
5101 * by 2^63 could round to 1.0 due to loss of precision in double.
5102 *
5103 * Seeded RAND(N) falls through to the UDF, which implements
5104 * MySQL's deterministic LCG (Linear Congruential Generator).
5105 * The UDF also handles RAND(NULL) as RAND(0), matching MySQL.
5106 */
5107 if ( 0 === count( $args ) ) {
5108 return '((RANDOM() & ((1 << 53) - 1)) / ((1 << 53) * 1.0))';
5109 }
5110 return $this->translate_sequence( $node->get_children() );
5111 case 'DATE_FORMAT':
5112 list ( $date, $mysql_format ) = $args;
5113
5114 $format = strtr( $mysql_format, self::MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAP );
5115 if ( ! $format ) {
5116 throw $this->new_driver_exception(
5117 sprintf(
5118 'Could not translate a DATE_FORMAT() format to STRFTIME format (%s)',
5119 $mysql_format
5120 )
5121 );
5122 }
5123
5124 /*
5125 * MySQL supports comparing strings and floats, e.g.
5126 *
5127 * > SELECT '00.42' = 0.4200
5128 * 1
5129 *
5130 * SQLite does not support that. At the same time,
5131 * WordPress likes to filter dates by comparing numeric
5132 * outputs of DATE_FORMAT() to floats, e.g.:
5133 *
5134 * -- Filter by hour and minutes
5135 * DATE_FORMAT(
5136 * STR_TO_DATE('2014-10-21 00:42:29', '%Y-%m-%d %H:%i:%s'),
5137 * '%H.%i'
5138 * ) = 0.4200;
5139 *
5140 * Let's cast the STRFTIME() output to a float if
5141 * the date format is typically used for string
5142 * to float comparisons.
5143 *
5144 * In the future, let's update WordPress to avoid comparing
5145 * strings and floats.
5146 */
5147 $cast_to_float = "'%H.%i'" === $mysql_format;
5148 if ( true === $cast_to_float ) {
5149 return sprintf( 'CAST(STRFTIME(%s, %s) AS FLOAT)', $format, $date );
5150 }
5151 return sprintf( 'STRFTIME(%s, %s)', $format, $date );
5152 case 'CHAR_LENGTH':
5153 // @TODO LENGTH and CHAR_LENGTH aren't always the same in MySQL for utf8 characters.
5154 return 'LENGTH(' . $args[0] . ')';
5155 case 'CONCAT':
5156 return '(' . implode( ' || ', $args ) . ')';
5157 case 'FOUND_ROWS':
5158 $found_rows = $this->found_rows;
5159 if ( is_int( $found_rows ) ) {
5160 return $found_rows;
5161 } elseif ( is_string( $found_rows ) ) {
5162 return (int) $this->execute_sqlite_query(
5163 sprintf( 'SELECT COUNT(*) FROM (%s)', $found_rows )
5164 )->fetchColumn()[0];
5165 } elseif ( is_array( $found_rows ) && isset( $found_rows[0] ) ) {
5166 return (int) $this->execute_sqlite_query(
5167 sprintf( 'SELECT COUNT(*) FROM (%s)', $found_rows[0] ),
5168 $found_rows[1] ?? array()
5169 )->fetchColumn()[0];
5170 } else {
5171 return 0;
5172 }
5173 case 'VERSION':
5174 return $this->quote_sqlite_value( $this->get_mysql_server_version_string() );
5175 default:
5176 return $this->translate_sequence( $node->get_children() );
5177 }
5178 }
5179
5180 /**
5181 * Get the configured MySQL version in dotted notation.
5182 *
5183 * @return string The MySQL version.
5184 */
5185 private function get_mysql_version_string(): string {
5186 return sprintf(
5187 '%d.%d.%d',
5188 intdiv( $this->mysql_version, 10000 ),
5189 intdiv( $this->mysql_version % 10000, 100 ),
5190 $this->mysql_version % 100
5191 );
5192 }
5193
5194 /**
5195 * Get the raw version string of the emulated MySQL server.
5196 *
5197 * @return string The MySQL server version.
5198 */
5199 private function get_mysql_server_version_string(): string {
5200 return sprintf(
5201 '%s-mysql-on-sqlite-%s',
5202 $this->get_mysql_version_string(),
5203 SQLITE_DRIVER_VERSION
5204 );
5205 }
5206
5207 /**
5208 * Get the comment identifying the emulated MySQL server implementation.
5209 *
5210 * @return string The MySQL server version comment.
5211 */
5212 private function get_mysql_server_version_comment(): string {
5213 return 'MySQL on SQLite';
5214 }
5215
5216 /**
5217 * Translate a MySQL datetime literal to SQLite.
5218 *
5219 * @param string $value The MySQL datetime literal.
5220 * @return string The translated value.
5221 */
5222 private function translate_datetime_literal( string $value ): string {
5223 /*
5224 * Normalize MySQL date and datetime literals for SQLite.
5225 *
5226 * MySQL accepts date and time components without zero padding. SQLite
5227 * stores temporal values as text, so they must be normalized for lexical
5228 * comparisons and SQLite date functions to work correctly.
5229 *
5230 * MySQL accepts ISO 8601 date strings: 'YYYY-MM-DDTHH:MM:SSZ'
5231 * SQLite prefers a slightly different format: 'YYYY-MM-DD HH:MM:SS'
5232 *
5233 * SQLite date and time functions can understand the ISO 8601 notation, but
5234 * lookups don't. To keep the lookups working, we need to store all dates
5235 * in UTC without the "T" and "Z" characters.
5236 *
5237 * Caveat: It will adjust every string that matches the pattern, not just dates.
5238 *
5239 * In theory, we could only adjust semantic dates, e.g. the data inserted
5240 * to a date column or compared against a date column.
5241 *
5242 * In practice, this is hard because dates are just text – SQLite has no separate
5243 * datetime field. We'd need to cache the MySQL data type from the original
5244 * CREATE TABLE query and then keep refreshing the cache after each ALTER TABLE query.
5245 *
5246 * That's a lot of complexity that's perhaps not worth it. Let's just convert
5247 * everything for now. The regexp assumes "Z" is always at the end of the string,
5248 * which is true in the unit test suite, but there could also be a timezone offset
5249 * like "+00:00" or "+01:00". We could add support for that later if needed.
5250 */
5251 if (
5252 1 === preg_match(
5253 '/^(\d{4})-(\d{1,2})-(\d{1,2})(?:([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})(Z)?)?$/',
5254 $value,
5255 $matches
5256 )
5257 ) {
5258 $value = sprintf( '%04d-%02d-%02d', $matches[1], $matches[2], $matches[3] );
5259 if ( isset( $matches[4] ) && '' !== $matches[4] ) {
5260 $is_iso_8601 = 'T' === $matches[4] && 'Z' === ( $matches[8] ?? '' );
5261 $value .= sprintf(
5262 '%s%02d:%02d:%02d%s',
5263 $is_iso_8601 ? ' ' : $matches[4],
5264 $matches[5],
5265 $matches[6],
5266 $matches[7],
5267 $is_iso_8601 ? '' : ( $matches[8] ?? '' )
5268 );
5269 }
5270 }
5271
5272 /*
5273 * Mimic MySQL's behavior and truncate invalid dates.
5274 *
5275 * "2020-12-41 14:15:27" becomes "0000-00-00 00:00:00"
5276 *
5277 * WARNING: We have no idea whether the truncated value should
5278 * be treated as a date in the first place.
5279 * In SQLite dates are just strings. This could be a perfectly
5280 * valid string that just happens to contain a date-like value.
5281 *
5282 * At the same time, WordPress seems to rely on MySQL's behavior
5283 * and even tests for it in Tests_Post_wpInsertPost::test_insert_empty_post_date.
5284 * Let's truncate the dates for now.
5285 *
5286 * In the future, let's update WordPress to do its own date validation
5287 * and stop relying on this MySQL feature,
5288 */
5289 if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) {
5290 /*
5291 * Calling strtotime("0000-00-00 00:00:00") in 32-bit environments triggers
5292 * an "out of integer range" warning – let's avoid that call for the popular
5293 * case of "zero" dates.
5294 */
5295 if ( '0000-00-00 00:00:00' !== $value && false === strtotime( $value ) ) {
5296 /*
5297 * Check for dates with zero month/day parts (e.g. '2020-00-15 00:00:00').
5298 *
5299 * When the NO_ZERO_IN_DATE SQL mode is not active, MySQL accepts dates
5300 * where the year is nonzero but the month or day is zero. We must
5301 * preserve these values so that cast_value_for_saving() can handle
5302 * them correctly at the column level.
5303 *
5304 * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date
5305 */
5306 $has_zero_in_date = (
5307 ( '00' === $matches[2] || '00' === $matches[3] ) &&
5308 '0000' !== $matches[1]
5309 );
5310 if ( ! $has_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) {
5311 $value = '0000-00-00 00:00:00';
5312 }
5313 }
5314 }
5315 return $value;
5316 }
5317
5318 /**
5319 * Translate a select item to SQLite.
5320 *
5321 * In some cases, an explicit alias will be added to the select item, so that
5322 * the returned column name is always the same as it would be in MySQL.
5323 *
5324 * @param WP_Parser_Node $node The "selectItem" AST node.
5325 * @return string The translated expression.
5326 */
5327 private function translate_select_item( WP_Parser_Node $node ): string {
5328 /*
5329 * First, let's translate the select item subtree.
5330 *
5331 * [GRAMMAR]
5332 * selectItem: tableWild | (expr selectAlias?)
5333 */
5334 $item = $this->translate_sequence( $node->get_children() );
5335
5336 // A table wildcard (e.g., "SELECT *, t.*, ...") never has an alias.
5337 if ( $node->has_child_node( 'tableWild' ) ) {
5338 return $item;
5339 }
5340
5341 // When an explicit alias is provided, we can use it as is.
5342 $alias = $node->get_first_child_node( 'selectAlias' );
5343 if ( $alias ) {
5344 return $item;
5345 }
5346
5347 /*
5348 * When the select item contains only a column definition, we need to use
5349 * it without change, so that the returned column name reflects the real
5350 * column name in all cases, including when using a fully qualified name.
5351 *
5352 * For example, for "SELECT t.id", the column name in the result set will
5353 * only be "id", not "t.id", as it may appear based on the original query.
5354 *
5355 * In this case, SQLite uses the same logic as MySQL, so using the value
5356 * as is without adding an explicit alias will produce the correct result.
5357 */
5358 $column_ref = $node->get_first_descendant_node( 'columnRef' );
5359 $is_column_ref = $column_ref && $item === $this->translate( $column_ref );
5360 if ( $is_column_ref ) {
5361 return $item;
5362 }
5363
5364 /*
5365 * When the select item is a text string literal, we need to use an alias
5366 * to ensure that the column name is the same as it would be in MySQL.
5367 * In MySQL, the column name is the original text string literal value
5368 * without quotes and escaping, but in SQLite, it is the quoted value.
5369 *
5370 * For example, for "SELECT 'abc'", the resulting column name is "abc"
5371 * in MySQL, but would be "'abc'" in SQLite if an alias was not used.
5372 *
5373 * Descend the AST until we reach a textStringLiteral. If at any level
5374 * we don't have a single child node, bail out; it's not a bare literal.
5375 */
5376 $current = $node;
5377 while ( 'textStringLiteral' !== $current->rule_name ) {
5378 $children = $current->get_children();
5379 if ( 1 !== count( $children ) || ! $children[0] instanceof WP_Parser_Node ) {
5380 break;
5381 }
5382 $current = $children[0];
5383 }
5384 if ( 'textStringLiteral' === $current->rule_name ) {
5385 $alias = $current->get_first_child_token()->get_value();
5386
5387 // When the literal value contains a NULL byte, MySQL truncates the
5388 // resulting identifier at the position of the first one of them.
5389 $fist_null_byte_pos = strpos( $alias, "\0" );
5390 if ( false !== $fist_null_byte_pos ) {
5391 $alias = substr( $alias, 0, $fist_null_byte_pos );
5392 }
5393 return sprintf( '%s AS %s', $item, $this->quote_sqlite_identifier( $alias ) );
5394 }
5395
5396 /*
5397 * When the select item has no explicit alias, we need to ensure that the
5398 * returned column name is equivalent to what MySQL infers from the input.
5399 *
5400 * For example, if we translate "CONCAT('a', 'b')" to "('a' || 'b')", we
5401 * need to use the original "CONCAT('a', 'b')" string as the column name.
5402 * To achieve this, the select item will be translated as follows:
5403 *
5404 * SELECT CONCAT('a', 'b') -> SELECT ('a' || 'b') AS `CONCAT('a', 'b')`
5405 */
5406 $raw_alias = substr( $this->last_mysql_query, $node->get_start(), $node->get_length() );
5407 $alias = $this->quote_sqlite_identifier( $raw_alias );
5408 if ( $alias === $item || $raw_alias === $item ) {
5409 // For the simple case of selecting only columns ("SELECT id FROM t"),
5410 // let's avoid unnecessary aliases ("SELECT `id` AS `id` FROM t").
5411 return $item;
5412 }
5413 return sprintf( '%s AS %s', $item, $alias );
5414 }
5415
5416 /**
5417 * Translate a MySQL table reference to SQLite.
5418 *
5419 * When the table reference targets an information schema table, we replace
5420 * it with a subquery, injecting the configured database name dynamically.
5421 *
5422 * For example, the following query:
5423 *
5424 * SELECT *, t.*, t.table_schema FROM information_schema.tables t
5425 *
5426 * Will be translated to:
5427 *
5428 * SELECT *, `t`.*, `t`.`table_schema` FROM (
5429 * SELECT
5430 * `TABLE_CATALOG`,
5431 * CASE WHEN `TABLE_SCHEMA` = 'information_schema' THEN `TABLE_SCHEMA` ELSE 'database_name' END AS `TABLE_SCHEMA`,
5432 * `TABLE_NAME`,
5433 * ...
5434 * FROM `_wp_sqlite_mysql_information_schema_tables` AS `tables`
5435 * ) `t`
5436 *
5437 * The same logic will be applied to table references in JOIN clauses as well.
5438 *
5439 * @param WP_Parser_Node $node The "tableRef" AST node.
5440 * @return string The translated value.
5441 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5442 */
5443 private function translate_table_ref( WP_Parser_Node $node ): string {
5444 // The table reference is in "<schema>.<table>" or "<table>" format.
5445 $parts = $node->get_descendant_nodes( 'identifier' );
5446 $table = array_pop( $parts );
5447 $schema = array_pop( $parts );
5448
5449 $schema_name = $schema ? $this->unquote_sqlite_identifier( $this->translate( $schema ) ) : null;
5450 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table ) );
5451
5452 // When the table reference targets an information schema table,
5453 // we need to inject some additional values dynamically.
5454 if (
5455 ( null === $schema_name && 'information_schema' === $this->db_name )
5456 || ( null !== $schema_name && 'information_schema' === strtolower( $schema_name ) )
5457 ) {
5458 $table_name = strtolower( $table_name );
5459
5460 // Some information schema tables can be computed on the fly.
5461 if ( 'character_sets' === $table_name || 'collations' === $table_name ) {
5462 $table_definition = $this->information_schema_builder
5463 ->get_computed_information_schema_table_definition( $table_name );
5464 if ( null !== $table_definition ) {
5465 return sprintf( '(%s)', $table_definition );
5466 }
5467 }
5468
5469 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
5470 $sqlite_table_name = $this->information_schema_builder->get_table_name( $table_is_temporary, $table_name );
5471
5472 // We need to fetch the SQLite column information, because the information
5473 // schema tables don't contain records for the information schema itself.
5474 $columns = $this->execute_sqlite_query(
5475 'SELECT name FROM pragma_table_info(?)',
5476 array( $sqlite_table_name )
5477 )->fetchAll( PDO::FETCH_COLUMN );
5478
5479 if ( count( $columns ) === 0 ) {
5480 return $this->translate_sequence( $node->get_children() );
5481 }
5482
5483 // List all columns in the table, replacing columns targeting database
5484 // name columns with the configured database name.
5485 static $information_schema_db_column_map = array(
5486 'SCHEMA_NAME' => true,
5487 'TABLE_SCHEMA' => true,
5488 'VIEW_SCHEMA' => true,
5489 'INDEX_SCHEMA' => true,
5490 'CONSTRAINT_SCHEMA' => true,
5491 'UNIQUE_CONSTRAINT_SCHEMA' => true,
5492 'REFERENCED_TABLE_SCHEMA' => true,
5493 'TRIGGER_SCHEMA' => true,
5494 );
5495
5496 $expanded_list = array();
5497 foreach ( $columns as $column ) {
5498 $quoted_column = $this->quote_sqlite_identifier( $column );
5499 if ( isset( $information_schema_db_column_map[ $column ] ) ) {
5500 // Replace the database name with the configured database name.
5501 $expanded_list[] = sprintf(
5502 "CASE WHEN %s = 'information_schema' THEN %s ELSE %s END AS %s",
5503 $quoted_column,
5504 $quoted_column,
5505 $this->quote_sqlite_value( $this->main_db_name ),
5506 $quoted_column
5507 );
5508 } elseif ( 'tables' === $table_name && 'AUTO_INCREMENT' === $column ) {
5509 // Inject the auto-increment values.
5510 $columns_table = $this->information_schema_builder->get_table_name( false, 'columns' );
5511 $has_sequence_table = (bool) $this->execute_sqlite_query(
5512 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'"
5513 )->fetchColumn();
5514
5515 $auto_increment_subquery = sprintf(
5516 "(
5517 SELECT COALESCE(s.seq + 1, 1)
5518 FROM %s AS c
5519 %s
5520 WHERE c.extra = 'auto_increment'
5521 AND c.table_schema = %s.table_schema
5522 AND c.table_name = %s.table_name
5523 )",
5524 $this->quote_sqlite_identifier( $columns_table ),
5525 $has_sequence_table
5526 ? 'LEFT JOIN main.sqlite_sequence AS s ON s.name = c.table_name'
5527 : 'LEFT JOIN (SELECT 0 AS seq) AS s',
5528 $this->quote_sqlite_identifier( $table_name ),
5529 $this->quote_sqlite_identifier( $table_name )
5530 );
5531
5532 $expanded_list[] = sprintf( '%s AS %s', $auto_increment_subquery, $quoted_column );
5533 } else {
5534 $expanded_list[] = $quoted_column;
5535 }
5536 }
5537 $column_list = implode( ', ', $expanded_list );
5538
5539 // Compose information schema subquery.
5540 return sprintf(
5541 '(SELECT %s FROM %s AS %s)',
5542 $column_list,
5543 $this->quote_sqlite_identifier( $sqlite_table_name ),
5544 $this->quote_sqlite_identifier( $table_name )
5545 );
5546 }
5547 return $this->translate_sequence( $node->get_children() );
5548 }
5549
5550 /**
5551 * Recreate an existing table using data in the information schema.
5552 *
5553 * This is used for a generic support of ALTER TABLE queries, as well as
5554 * for some other statements like OPTIMIZE TABLE and REPAIR TABLE.
5555 *
5556 * See:
5557 * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
5558 *
5559 * @param bool $table_is_temporary Whether the table is temporary.
5560 * @param string $table_name The name of the table to recreate.
5561 * @param array $column_map Optional. A map of column names (old name -> new name)
5562 * to use when copying data from the original table.
5563 * When not provided, all columns are copied without renaming.
5564 * @throws WP_MySQL_On_SQLite_Exception
5565 */
5566 private function recreate_table_from_information_schema(
5567 bool $table_is_temporary,
5568 string $table_name,
5569 ?array $column_map = null
5570 ): void {
5571 if ( null === $column_map ) {
5572 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
5573 $column_names = $this->execute_sqlite_query(
5574 sprintf(
5575 'SELECT COLUMN_NAME FROM %s WHERE table_schema = ? AND table_name = ?',
5576 $this->quote_sqlite_identifier( $columns_table )
5577 ),
5578 array( $this->get_saved_db_name(), $table_name )
5579 )->fetchAll( PDO::FETCH_COLUMN );
5580 $column_map = array_combine( $column_names, $column_names );
5581 }
5582
5583 // Preserve ROWIDs.
5584 // This also addresses a special case when all original columns are dropped
5585 // and there is nothing to copy. We'll always have at least the ROWID column.
5586 $column_map = array( 'rowid' => 'rowid' ) + $column_map;
5587
5588 /*
5589 * See:
5590 * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
5591 */
5592
5593 // 1. If foreign key constraints are enabled, disable them.
5594 $pragma_foreign_keys = $this->execute_sqlite_query( 'PRAGMA foreign_keys' )->fetchColumn();
5595 $this->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' );
5596
5597 // 2. Create a new table with the new schema.
5598 $tmp_table_name = self::RESERVED_PREFIX . "tmp_{$table_name}_" . uniqid();
5599 $quoted_table_name = $this->quote_sqlite_identifier( $table_name );
5600 $quoted_tmp_table_name = $this->quote_sqlite_identifier( $tmp_table_name );
5601 $queries = $this->get_sqlite_create_table_statement( $table_is_temporary, $table_name, $tmp_table_name );
5602 $create_table_query = $queries[0];
5603 $constraint_queries = array_slice( $queries, 1 );
5604 $this->execute_sqlite_query( $create_table_query );
5605
5606 // 3. Copy data from the original table to the new table.
5607 $this->execute_sqlite_query(
5608 sprintf(
5609 'INSERT INTO %s (%s) SELECT %s FROM %s',
5610 $quoted_tmp_table_name,
5611 implode(
5612 ', ',
5613 array_map( array( $this, 'quote_sqlite_identifier' ), $column_map )
5614 ),
5615 implode(
5616 ', ',
5617 array_map( array( $this, 'quote_sqlite_identifier' ), array_keys( $column_map ) )
5618 ),
5619 $quoted_table_name
5620 )
5621 );
5622
5623 // 4. Drop the original table.
5624 $this->execute_sqlite_query( sprintf( 'DROP TABLE %s', $quoted_table_name ) );
5625
5626 // 5. Rename the new table to the original table name.
5627 $this->execute_sqlite_query(
5628 sprintf(
5629 'ALTER TABLE %s RENAME TO %s',
5630 $quoted_tmp_table_name,
5631 $quoted_table_name
5632 )
5633 );
5634
5635 // 6. Reconstruct indexes, triggers, and views.
5636 foreach ( $constraint_queries as $query ) {
5637 $this->execute_sqlite_query( $query );
5638 }
5639
5640 // 7. If foreign key constraints were enabled, verify and enable them.
5641 if ( '1' === $pragma_foreign_keys ) {
5642 $this->execute_sqlite_query( 'PRAGMA foreign_key_check' );
5643 $this->execute_sqlite_query( 'PRAGMA foreign_keys = ON' );
5644 }
5645
5646 // @TODO: Triggers and views.
5647 }
5648
5649 /**
5650 * Apply the AUTO_INCREMENT table option from a CREATE TABLE or ALTER TABLE
5651 * statement by adjusting the row in SQLite's "sqlite_sequence" table.
5652 *
5653 * @param bool $table_is_temporary Whether the table is temporary.
5654 * @param string $table_name The table name.
5655 * @param WP_Parser_Node $node The "createStatement" or "alterStatement" AST node.
5656 */
5657 private function apply_auto_increment_table_option(
5658 bool $table_is_temporary,
5659 string $table_name,
5660 WP_Parser_Node $node
5661 ): void {
5662 // Find the last AUTO_INCREMENT = N option (MySQL uses the last one).
5663 $value = null;
5664 foreach ( $node->get_descendant_nodes( 'createTableOption' ) as $option ) {
5665 if ( ! $option->has_child_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) {
5666 continue;
5667 }
5668 $number_node = $option->get_first_child_node( 'ulonglong_number' );
5669 if ( null === $number_node ) {
5670 continue;
5671 }
5672 $value = (int) $number_node->get_first_descendant_token()->get_value();
5673 }
5674 if ( null === $value ) {
5675 return;
5676 }
5677
5678 // Find the AUTO_INCREMENT column.
5679 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
5680 $auto_column = $this->execute_sqlite_query(
5681 sprintf(
5682 "SELECT column_name FROM %s
5683 WHERE table_schema = ?
5684 AND table_name = ?
5685 AND extra = 'auto_increment'",
5686 $this->quote_sqlite_identifier( $columns_table )
5687 ),
5688 array( $this->get_saved_db_name(), $table_name )
5689 )->fetchColumn();
5690 if ( false === $auto_column ) {
5691 return;
5692 }
5693
5694 /*
5695 * Prepare an expression for the sequence value.
5696 * 1. Use N - 1. MySQL stores the next value, SQLite the last one.
5697 * 2. Clamp to MAX(col) like MySQL (we can't go below existing values).
5698 *
5699 * The value is inlined as an integer literal because PDO binds PHP ints
5700 * as TEXT, and SQLite's type affinity ranks TEXT above INTEGER in MAX().
5701 */
5702 $schema = $table_is_temporary ? 'temp' : 'main';
5703 $seq_expr = sprintf(
5704 'MAX(%d, COALESCE((SELECT MAX(%s) FROM %s), 0))',
5705 $value - 1,
5706 $this->quote_sqlite_identifier( $auto_column ),
5707 $this->quote_sqlite_identifier( $table_name )
5708 );
5709
5710 // Update the value in the "sqlite_sequence" table.
5711 $updated = $this->execute_sqlite_query(
5712 sprintf( 'UPDATE %s.sqlite_sequence SET seq = %s WHERE name = ?', $schema, $seq_expr ),
5713 array( $table_name )
5714 )->rowCount();
5715
5716 // If the sequence value does not exist yet, insert a new row.
5717 // SQLite reports matched (not affected) rows, so the 0 check is safe.
5718 if ( 0 === $updated ) {
5719 $this->execute_sqlite_query(
5720 sprintf( 'INSERT INTO %s.sqlite_sequence (name, seq) VALUES (?, %s)', $schema, $seq_expr ),
5721 array( $table_name )
5722 );
5723 }
5724 }
5725
5726 /**
5727 * Translate a MySQL SHOW LIKE ... or SHOW WHERE ... condition to SQLite.
5728 *
5729 * @param WP_Parser_Node $like_or_where The "likeOrWhere" AST node.
5730 * @param string $like_column The column name to use in the LIKE clause ("table_name", "column_name", etc.).
5731 * @return string The translated value.
5732 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5733 */
5734 private function translate_show_like_or_where_condition( WP_Parser_Node $like_or_where, string $like_column ): string {
5735 $like_clause = $like_or_where->get_first_child_node( 'likeClause' );
5736 if ( null !== $like_clause ) {
5737 $value = $this->translate(
5738 $like_clause->get_first_child_node( 'textStringLiteral' )
5739 );
5740 return sprintf(
5741 "AND %s LIKE %s ESCAPE '\\'",
5742 $this->quote_sqlite_identifier( $like_column ),
5743 $value
5744 );
5745 }
5746
5747 $where_clause = $like_or_where->get_first_child_node( 'whereClause' );
5748 if ( null !== $where_clause ) {
5749 $value = $this->translate(
5750 $where_clause->get_first_child_node( 'expr' )
5751 );
5752 return sprintf( 'AND %s', $value );
5753 }
5754
5755 return '';
5756 }
5757
5758 /**
5759 * Translate INSERT or REPLACE statement body to SQLite, while emulating
5760 * MySQL column type casting and implicit default values when saving data.
5761 *
5762 * This method rewrites an INSERT or REPLACE statement body from:
5763 * INSERT INTO table (optionally some columns) <select-or-values>
5764 * To a statement body with the following structure:
5765 * INSERT INTO table (table columns)
5766 * SELECT <adjusted-values> FROM (<select-or-values>) WHERE true
5767 *
5768 * In MySQL, the behavior of INSERT and UPDATE statements depends on whether
5769 * the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
5770 *
5771 * This method applies relevant type casting and emulates IMPLICIT DEFAULT
5772 * value behavior as follows:
5773 * 1. In STRICT mode:
5774 * - Apply relevant type casting based on the column data type.
5775 * 2. In non-STRICT mode:
5776 * - Apply relevant type casting based on the column data type.
5777 * - Replace invalid values with IMPLICIT DEFAULTs.
5778 * - Replace missing values without defaults with IMPLICIT DEFAULTs.
5779 *
5780 * The strict SQL modes can be set per session, and can be changed at runtime.
5781 * In SQLite, we can emulate this using the knowledge of the table structure.
5782 *
5783 * -----
5784 *
5785 * Here's a summary of the strict vs. non-strict IMPLICIT DEFAULT behavior:
5786 *
5787 * When STRICT_TRANS_TABLES or STRICT_ALL_TABLES is enabled:
5788 * 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
5789 * 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
5790 * 3. NOT NULL + NO DEFAULT: No value is rejected, NULL is rejected, DEFAULT is rejected.
5791 * 4. NOT NULL + DEFAULT: No value saves DEFAULT, NULL is rejected, DEFAULT saves DEFAULT.
5792 *
5793 * When STRICT_TRANS_TABLES and STRICT_ALL_TABLES are disabled:
5794 * 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
5795 * 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
5796 * 3. NOT NULL + NO DEFAULT: No value saves IMPLICIT DEFAULT.
5797 * NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
5798 * DEFAULT saves IMPLICIT DEFAULT.
5799 * 4. NOT NULL + DEFAULT: No value saves DEFAULT.
5800 * NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
5801 * DEFAULT saves DEFAULT.
5802 *
5803 * For more information about STRICT mode in MySQL, see:
5804 * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-strict
5805 *
5806 * For more information about IMPLICIT DEFAULT values in MySQL, see:
5807 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
5808 *
5809 * @param string $table_name The name of the target table.
5810 * @param WP_Parser_Node $node The "insertQueryExpression" or "insertValues" AST node.
5811 * @return string The translated INSERT query body.
5812 */
5813 private function translate_insert_or_replace_body(
5814 string $table_name,
5815 WP_Parser_Node $node
5816 ): string {
5817 // This method is always used with the main database.
5818 $database = $this->get_saved_db_name( $this->main_db_name );
5819
5820 // Check if strict mode is enabled.
5821 $is_strict_mode = (
5822 $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
5823 || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
5824 );
5825
5826 // Get column metadata for the target table from the information schema.
5827 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
5828 $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
5829 $columns = $this->execute_sqlite_query(
5830 '
5831 SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, extra
5832 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
5833 WHERE table_schema = ?
5834 AND table_name = ?
5835 ORDER BY ordinal_position
5836 ',
5837 array( $database, $table_name )
5838 )->fetchAll( PDO::FETCH_ASSOC );
5839
5840 // Check if the table exists.
5841 if ( 0 === count( $columns ) ) {
5842 throw $this->new_table_not_found_exception( $table_name );
5843 }
5844
5845 // Get a list of columns that are targeted by the INSERT or REPLACE query.
5846 // This is either an explicit column list, or all columns of the table.
5847 $insert_list = array();
5848 $fields_node = $node->get_first_child_node( 'fields' );
5849 if ( $fields_node ) {
5850 // "INSERT INTO ... (column1, column2, ...)"
5851 foreach ( $fields_node->get_child_nodes() as $field ) {
5852 $column_name = $this->unquote_sqlite_identifier( $this->translate( $field ) );
5853 $insert_list[] = strtolower( $column_name );
5854 }
5855 } elseif ( 'updateList' === $node->rule_name ) {
5856 // "INSERT INTO ... SET column1 = value1, column2 = value2, ..."
5857 foreach ( $node->get_child_nodes( 'updateElement' ) as $update_element ) {
5858 $column_ref = $update_element->get_first_child_node( 'columnRef' );
5859 $column_name = $this->unquote_sqlite_identifier( $this->translate( $column_ref ) );
5860 $insert_list[] = strtolower( $column_name );
5861 }
5862 } else {
5863 // "INSERT INTO ... VALUES(...)" or "INSERT INTO ... SELECT ..."
5864 // No explicit column list is provided; we need to list all columns.
5865 foreach ( array_column( $columns, 'COLUMN_NAME' ) as $column_name ) {
5866 $insert_list[] = strtolower( $column_name );
5867 }
5868 }
5869
5870 // Check if all listed columns exist.
5871 $unknown_columns = array_diff( $insert_list, array_column( $columns, 'COLUMN_NAME' ) );
5872 if ( count( $unknown_columns ) > 0 ) {
5873 throw $this->new_driver_exception(
5874 sprintf(
5875 "SQLSTATE[42S22]: Column not found: 1054 Unknown column '%s' in 'field list'",
5876 $unknown_columns[0]
5877 ),
5878 '42S22'
5879 );
5880 }
5881
5882 // Prepare a helper map of columns that are included in the INSERT list.
5883 $insert_map = array_combine( $insert_list, $insert_list );
5884
5885 /*
5886 * Filter out columns that were omitted in the INSERT list:
5887 * 1. In strict mode, filter out all omitted columns.
5888 * 2. In non-strict mode, filter out omitted columns that will get a
5889 * value from the SQLite engine. That is, nullable columns, columns
5890 * with defaults, and generated columns.
5891 */
5892 $columns = array_values(
5893 array_filter(
5894 $columns,
5895 function ( $column ) use ( $is_strict_mode, $insert_map ) {
5896 $is_omitted = ! isset( $insert_map[ $column['COLUMN_NAME'] ] );
5897 if ( ! $is_omitted ) {
5898 return true;
5899 }
5900 if ( $is_strict_mode ) {
5901 return false;
5902 }
5903 $is_nullable = 'YES' === $column['IS_NULLABLE'];
5904 $has_default = $column['COLUMN_DEFAULT'];
5905 $is_generated = str_contains( $column['EXTRA'], 'auto_increment' );
5906 return ! ( $is_nullable || $has_default || $is_generated );
5907 }
5908 )
5909 );
5910
5911 /*
5912 * Get a list of column names for the INSERT or REPLACE values clause.
5913 * These are the columns that will be used in a SELECT statement when
5914 * the values clause is wrapped in a subquery:
5915 *
5916 * INSERT INTO ... SELECT <select-list> FROM (<values-from-original-query>)
5917 */
5918 $select_list = array();
5919 if ( 'insertQueryExpression' === $node->rule_name ) {
5920 // When inserting from a SELECT query, we don't know the column names.
5921 // Let's wrap the query with a "SELECT (...) LIMIT 0" to obtain them.
5922 $expr = $node->get_first_child_node( 'queryExpressionOrParens' );
5923 $stmt = $this->execute_sqlite_query(
5924 'SELECT * FROM (' . $this->translate( $expr ) . ') LIMIT 1'
5925 );
5926 $stmt->execute();
5927
5928 for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
5929 /*
5930 * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3.
5931 * See also: https://github.com/php/php-src/pull/5654
5932 */
5933 if ( PHP_VERSION_ID < 70300 ) {
5934 try {
5935 $column_meta = $stmt->getColumnMeta( $i );
5936 } catch ( Throwable $e ) {
5937 $column_meta = false;
5938 }
5939 if ( false === $column_meta ) {
5940 // Due to a PDO bug in PHP < 7.3, we get no column metadata
5941 // when no rows are returned. In that case, no data will be
5942 // inserted, so we can bail out using a simple translation.
5943 return $this->translate( $node );
5944 }
5945 }
5946 $select_list[] = $stmt->getColumnMeta( $i )['name'];
5947 }
5948 } else {
5949 // When inserting from a VALUES list, SQLite uses a "columnN" naming.
5950 // This also applies to the SET syntax, which is converted to VALUES.
5951 foreach ( array_keys( $insert_list ) as $position ) {
5952 $select_list[] = 'column' . ( $position + 1 );
5953 }
5954 }
5955
5956 // Compose a new INSERT column list with all columns from the table.
5957 $fragment = '(';
5958 foreach ( $columns as $i => $column ) {
5959 $fragment .= $i > 0 ? ', ' : '';
5960 $fragment .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
5961 }
5962 $fragment .= ')';
5963
5964 // Compose a wrapper SELECT statement emulating MySQL-like type casting,
5965 // and, in non-strict mode, IMPLICIT DEFAULT values for omitted columns.
5966 $fragment .= ' SELECT ';
5967 foreach ( $columns as $i => $column ) {
5968 $is_omitted = ! isset( $insert_map[ $column['COLUMN_NAME'] ] );
5969 $fragment .= $i > 0 ? ', ' : '';
5970 if ( $is_omitted ) {
5971 /*
5972 * This path only applies to non-strict mode. In strict mode,
5973 * omitted columns get no IMPLICIT DEFAULT values, and they were
5974 * previously filtered out from the columns list.
5975 *
5976 * When a column is omitted from the INSERT list, we need to use
5977 * an IMPLICIT DEFAULT value. Note that at this point, all omitted
5978 * columns that will not get an implicit default are filtered out.
5979 * (That is, nullable, generated, and columns with true defaults.)
5980 */
5981 $default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null;
5982 $fragment .= null === $default ? 'NULL' : $this->quote_sqlite_value( $default );
5983 } else {
5984 // When a column value is included, we need to apply type casting.
5985 $position = array_search( $column['COLUMN_NAME'], $insert_list, true );
5986 $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] );
5987 $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier );
5988 $is_auto_increment = str_contains( $column['EXTRA'], 'auto_increment' );
5989
5990 /*
5991 * In MySQL, inserting 0 into an AUTO_INCREMENT column increments
5992 * the sequence, unless the NO_AUTO_VALUE_ON_ZERO SQL mode is set.
5993 *
5994 * In SQLite, we need to rewrite 0 to NULL to advance the sequence.
5995 * The value is cast to INTEGER before the comparison, because
5996 * SQLite treats values of different types as unequal (0 != '0').
5997 *
5998 * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_auto_value_on_zero
5999 */
6000 if ( $is_auto_increment && ! $this->is_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) ) {
6001 $value = sprintf( 'NULLIF(CAST(%s AS INTEGER), 0)', $value );
6002 }
6003
6004 /*
6005 * In MySQL non-STRICT mode, when inserting from a SELECT query:
6006 *
6007 * When a column is declared as NOT NULL, inserting a NULL value
6008 * saves an IMPLICIT DEFAULT value instead. This behavior only
6009 * applies to the INSERT ... SELECT syntax (not VALUES or SET).
6010 *
6011 * AUTO_INCREMENT columns are excluded. A NULL value advances
6012 * the sequence regardless of the column's nullability.
6013 */
6014 $is_insert_from_select = 'insertQueryExpression' === $node->rule_name;
6015 if (
6016 ! $is_strict_mode
6017 && ! $is_auto_increment
6018 && $is_insert_from_select
6019 && 'NO' === $column['IS_NULLABLE']
6020 ) {
6021 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null;
6022 if ( null !== $implicit_default ) {
6023 $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) );
6024 }
6025 }
6026 $fragment .= $value;
6027 }
6028 }
6029
6030 // Wrap the original insert VALUES, SELECT, or SET list in a FROM clause.
6031 if ( 'insertFromConstructor' === $node->rule_name ) {
6032 // VALUES (...)
6033 $insert_values = $node->get_first_child_node( 'insertValues' );
6034 $from = $this->translate( $insert_values );
6035
6036 /**
6037 * The automatic "columnN" naming for VALUES lists is supported only
6038 * from SQLite 3.33.0. For older versions, we need to emulate it by
6039 * prepending a dummy VALUES list header via the UNION ALL operator:
6040 *
6041 * SELECT
6042 * NULL AS `column1`, NULL AS `column2`, ... WHERE FALSE
6043 * UNION ALL
6044 * VALUES (value1, value2, ...)
6045 */
6046 $is_values_naming_supported = version_compare( $this->get_sqlite_version(), '3.33.0', '>=' );
6047 if ( ! $is_values_naming_supported ) {
6048 $values_list = $insert_values->get_first_child_node( 'valueList' );
6049 $values = $values_list->get_first_child_node( 'values' );
6050 $value_count = (
6051 count( $values->get_child_nodes( 'expr' ) )
6052 + count( $values->get_child_nodes( WP_MySQL_Lexer::DEFAULT_SYMBOL ) )
6053 );
6054
6055 $columns_list = '';
6056 for ( $i = 1; $i <= $value_count; $i++ ) {
6057 $columns_list .= $i > 1 ? ', ' : '';
6058 $columns_list .= 'NULL AS ' . $this->quote_sqlite_identifier( 'column' . $i );
6059 }
6060 $from = 'SELECT ' . $columns_list . ' WHERE FALSE UNION ALL ' . $from;
6061 }
6062 } elseif ( 'insertQueryExpression' === $node->rule_name ) {
6063 // SELECT ...
6064 $from = $this->translate(
6065 $node->get_first_child_node( 'queryExpressionOrParens' )
6066 );
6067 } else {
6068 // SET c1 = v1, c2 = v2, ...
6069 $values = array();
6070 foreach ( $node->get_child_nodes( 'updateElement' ) as $update_element ) {
6071 $values[] = $this->translate( $update_element->get_first_child_node( 'expr' ) );
6072 }
6073 $from = 'VALUES (' . implode( ', ', $values ) . ')';
6074 }
6075
6076 /*
6077 * The "WHERE true" suffix is used to avoid parsing ambiguity in SQLite.
6078 * When an "ON CONFLICT" clause is used and there is no "WHERE", SQLite
6079 * doesn't know if "ON" belongs to a "JOIN" or an "ON CONFLICT" clause.
6080 *
6081 * See: https://www.sqlite.org/lang_insert.html
6082 */
6083 $fragment .= ' FROM (' . $from . ') WHERE true';
6084
6085 return $fragment;
6086 }
6087
6088 /**
6089 * Translate UPDATE statement SET value list to SQLite, while emulating
6090 * MySQL column type casting and implicit default values when saving data.
6091 *
6092 * Rewrites an UPDATE statement list in the following form:
6093 * UPDATE table SET <column> = <value>
6094 * To a list with the following structure:
6095 * UPDATE table SET <column> = <adjusted-value>
6096 *
6097 * In MySQL, the behavior of INSERT and UPDATE statements depends on whether
6098 * the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
6099 *
6100 * This method applies relevant type casting and emulates IMPLICIT DEFAULT
6101 * value behavior as follows:
6102 * 1. In STRICT mode:
6103 * - Apply relevant type casting based on the column data type.
6104 * 2. In NON-STRICT mode:
6105 * - Apply relevant type casting based on the column data type.
6106 * - Replace invalid values with IMPLICIT DEFAULTs.
6107 * - Replace NULL values without defaults with IMPLICIT DEFAULTs.
6108 * (Updating a NOT NULL column to NULL saves as an IMPLICIT DEFAULT.)
6109 *
6110 * The strict SQL modes can be set per session, and can be changed at runtime.
6111 * In SQLite, we can emulate this using the knowledge of the table structure.
6112 *
6113 * For more information about STRICT mode in MySQL, see:
6114 * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-strict
6115 *
6116 * For more information about IMPLICIT DEFAULT values in MySQL, see:
6117 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
6118 *
6119 * @param string $table_name The name of the target table.
6120 * @param WP_Parser_Node $parent_node The "updateList" AST node parent node.
6121 * @return string The translated UPDATE list.
6122 */
6123 private function translate_update_list( string $table_name, WP_Parser_Node $parent_node ): string {
6124 $node = $parent_node->get_first_child_node( 'updateList' );
6125
6126 // This method is always used with the main database.
6127 $database = $this->get_saved_db_name( $this->main_db_name );
6128
6129 // Check if strict mode is enabled.
6130 $is_strict_mode = (
6131 $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
6132 || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
6133 );
6134
6135 // Get column metadata from the information schema.
6136 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
6137 $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
6138 $columns = $this->execute_sqlite_query(
6139 '
6140 SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, column_default
6141 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
6142 WHERE table_schema = ?
6143 AND table_name = ?
6144 ',
6145 array( $database, $table_name )
6146 )->fetchAll( PDO::FETCH_ASSOC );
6147
6148 // Check if the table exists.
6149 if ( 0 === count( $columns ) ) {
6150 throw $this->new_table_not_found_exception( $table_name );
6151 }
6152
6153 $column_map = array_combine( array_column( $columns, 'COLUMN_NAME' ), $columns );
6154
6155 // Translate the UPDATE list, emulating IMPLICIT DEFAULTs for NULL values.
6156 $fragment = '';
6157 foreach ( $node->get_child_nodes() as $i => $update_element ) {
6158 $column_ref = $update_element->get_first_child_node( 'columnRef' );
6159 $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' );
6160 $expr = $update_element->get_first_child_node( 'expr' );
6161
6162 // Get column info.
6163 $column_name = $this->unquote_sqlite_identifier( $this->translate( end( $column_ref_parts ) ) );
6164 $column_info = $column_map[ strtolower( $column_name ) ] ?? null;
6165 if ( ! $column_info ) {
6166 throw $this->new_driver_exception(
6167 sprintf(
6168 "SQLSTATE[42S22]: Column not found: 1054 Unknown column '%s' in 'field list'",
6169 $column_name
6170 ),
6171 '42S22'
6172 );
6173 }
6174
6175 $data_type = $column_info['DATA_TYPE'];
6176 $is_nullable = 'YES' === $column_info['IS_NULLABLE'];
6177 $default = $column_info['COLUMN_DEFAULT'];
6178
6179 // Get the UPDATE value. It's either an expression or a DEFAULT keyword.
6180 if ( null === $expr ) {
6181 // Emulate "column = DEFAULT".
6182 $value = null === $default ? 'NULL' : $this->format_sqlite_column_default( $column_info );
6183 } else {
6184 $value = $this->translate( $expr );
6185 }
6186
6187 // Apply type casting.
6188 $value = $this->cast_value_for_saving( $data_type, $value );
6189
6190 /*
6191 * In MySQL non-STRICT mode, when a column is declared as NOT NULL,
6192 * updating to a NULL value saves an IMPLICIT DEFAULT value instead.
6193 * This behavior does not apply to ON DUPLICATE KEY UPDATE clauses.
6194 */
6195 $is_on_duplicate_key_update = 'insertUpdateList' === $parent_node->rule_name;
6196 if ( ! $is_strict_mode && ! $is_nullable && ! $is_on_duplicate_key_update ) {
6197 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $data_type ] ?? null;
6198 if ( null !== $implicit_default ) {
6199 $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) );
6200 }
6201 }
6202
6203 // Compose the UPDATE list item.
6204 $fragment .= $i > 0 ? ', ' : '';
6205 $fragment .= $this->translate( end( $column_ref_parts ) );
6206 $fragment .= ' = ';
6207 $fragment .= $value;
6208 }
6209 return $fragment;
6210 }
6211
6212 /**
6213 * Get the default SQL modes for the emulated MySQL version.
6214 *
6215 * @return string[] Default SQL mode names.
6216 */
6217 private function get_default_sql_modes(): array {
6218 $sql_modes = self::DEFAULT_SQL_MODES;
6219 if ( $this->mysql_version < 80000 ) {
6220 $sql_modes[] = 'NO_AUTO_CREATE_USER';
6221 }
6222
6223 return $sql_modes;
6224 }
6225
6226 /**
6227 * Set the active SQL modes from a name list or numeric bitmask.
6228 *
6229 * @param string[]|int $modes SQL mode names or a numeric bitmask.
6230 */
6231 private function set_sql_modes( $modes ): void {
6232 if ( null === $modes ) {
6233 throw $this->new_invalid_sql_mode_exception( $modes );
6234 }
6235
6236 $known_sql_modes_mask = array_sum( self::SQL_MODES );
6237
6238 // TIME_TRUNCATE_FRACTIONAL was introduced in MySQL 8.0.1.
6239 if ( $this->mysql_version < 80001 ) {
6240 $known_sql_modes_mask &= ~self::SQL_MODES['TIME_TRUNCATE_FRACTIONAL'];
6241 }
6242
6243 // Numeric SQL mode bitmap assignment (e.g., "SET sql_mode = 4" for ANSI_QUOTES).
6244 if ( is_int( $modes ) ) {
6245 // Ensure the bitmap contains only SQL mode bits known to the emulated MySQL version.
6246 if ( $modes < 0 || ( $modes & ~$known_sql_modes_mask ) !== 0 ) {
6247 throw $this->new_invalid_sql_mode_exception( $modes );
6248 }
6249
6250 // Reject recognized but no longer supported SQL modes.
6251 $unsupported_sql_modes = 0;
6252 foreach ( self::SQL_MODES as $mode => $value ) {
6253 if ( ( $modes & $value ) !== 0 && $this->is_sql_mode_removed( $mode ) ) {
6254 $unsupported_sql_modes |= $value;
6255 }
6256 }
6257
6258 if ( 0 !== $unsupported_sql_modes ) {
6259 throw $this->new_driver_exception(
6260 sprintf(
6261 'SQLSTATE[HY000]: General error: 3899 sql_mode=0x%08x is not supported.',
6262 $unsupported_sql_modes
6263 ),
6264 'HY000'
6265 );
6266 }
6267
6268 $sql_modes = $modes;
6269 } elseif ( is_array( $modes ) ) {
6270 // String SQL mode assignment (e.g., "SET sql_mode = 'ANSI_QUOTES,STRICT_TRANS_TABLES'").
6271 $sql_modes = 0;
6272 foreach ( $modes as $mode ) {
6273 if ( '' === $mode ) {
6274 continue;
6275 }
6276
6277 $normalized_mode = strtoupper( $mode );
6278 $mode_value = self::SQL_MODES[ $normalized_mode ] ?? 0;
6279 if (
6280 0 === ( $mode_value & $known_sql_modes_mask )
6281 || ( 'NOT_USED' === $normalized_mode && $this->mysql_version < 80000 )
6282 || $this->is_sql_mode_removed( $normalized_mode )
6283 ) {
6284 throw $this->new_invalid_sql_mode_exception( $mode );
6285 }
6286
6287 $sql_modes |= $mode_value;
6288 }
6289 } else {
6290 throw $this->new_driver_exception(
6291 "SQLSTATE[42000]: Syntax error or access violation: 1232 Incorrect argument type to variable 'sql_mode'",
6292 '42000'
6293 );
6294 }
6295
6296 if ( ( $sql_modes & self::SQL_MODES['NO_BACKSLASH_ESCAPES'] ) !== 0 ) {
6297 throw $this->new_not_supported_exception( "SQL mode 'NO_BACKSLASH_ESCAPES'" );
6298 }
6299
6300 /*
6301 * MySQL retains composite SQL modes while enabling their component modes.
6302 * Store both so "@@sql_mode" and individual mode checks match MySQL, even
6303 * though not all resulting modes are respected by the emulation yet.
6304 *
6305 * See:
6306 * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-combo
6307 * https://github.com/mysql/mysql-server/blob/8.4/sql/sys_vars.cc
6308 */
6309 if ( ( $sql_modes & self::SQL_MODES['ANSI'] ) !== 0 ) {
6310 $sql_modes |= self::SQL_MODES['REAL_AS_FLOAT']
6311 | self::SQL_MODES['PIPES_AS_CONCAT']
6312 | self::SQL_MODES['ANSI_QUOTES']
6313 | self::SQL_MODES['IGNORE_SPACE']
6314 | self::SQL_MODES['ONLY_FULL_GROUP_BY'];
6315 }
6316
6317 if ( ( $sql_modes & self::SQL_MODES['TRADITIONAL'] ) !== 0 ) {
6318 $sql_modes |= self::SQL_MODES['STRICT_TRANS_TABLES']
6319 | self::SQL_MODES['STRICT_ALL_TABLES']
6320 | self::SQL_MODES['NO_ZERO_IN_DATE']
6321 | self::SQL_MODES['NO_ZERO_DATE']
6322 | self::SQL_MODES['ERROR_FOR_DIVISION_BY_ZERO']
6323 | self::SQL_MODES['NO_ENGINE_SUBSTITUTION'];
6324
6325 // TRADITIONAL included NO_AUTO_CREATE_USER until its removal in MySQL 8.0.11.
6326 if ( ! $this->is_sql_mode_removed( 'NO_AUTO_CREATE_USER' ) ) {
6327 $sql_modes |= self::SQL_MODES['NO_AUTO_CREATE_USER'];
6328 }
6329 }
6330
6331 $this->active_sql_modes = $sql_modes;
6332 }
6333
6334 /**
6335 * Check whether an SQL mode was removed from the emulated MySQL version.
6336 *
6337 * @param string $mode Normalized SQL mode name.
6338 * @return bool Whether the SQL mode was removed.
6339 */
6340 private function is_sql_mode_removed( string $mode ): bool {
6341 /*
6342 * MySQL still recognizes the legacy bits for modes removed in 8.0.11
6343 * so it can report them as unsupported, rather than unknown.
6344 */
6345 return $this->mysql_version >= 80011
6346 && in_array(
6347 $mode,
6348 array(
6349 'POSTGRESQL',
6350 'ORACLE',
6351 'MSSQL',
6352 'DB2',
6353 'MAXDB',
6354 'NO_KEY_OPTIONS',
6355 'NO_TABLE_OPTIONS',
6356 'NO_FIELD_OPTIONS',
6357 'MYSQL323',
6358 'MYSQL40',
6359 'NO_AUTO_CREATE_USER',
6360 ),
6361 true
6362 );
6363 }
6364
6365 /**
6366 * Get the active SQL mode names in canonical bitmask order.
6367 *
6368 * @return string[] Active SQL mode names.
6369 */
6370 private function get_active_sql_mode_names(): array {
6371 $active_modes = array();
6372 foreach ( self::SQL_MODES as $mode => $value ) {
6373 if ( ( $this->active_sql_modes & $value ) !== 0 ) {
6374 if ( 'NOT_USED' === $mode && $this->mysql_version < 80000 ) {
6375 /*
6376 * MySQL 5.7 represents reserved bit 4 with a comma in its mode
6377 * name table. Two empty components preserve that serialization
6378 * when the final list is joined with commas.
6379 */
6380 $active_modes[] = '';
6381 $active_modes[] = '';
6382 } else {
6383 $active_modes[] = $mode;
6384 }
6385 }
6386 }
6387
6388 return $active_modes;
6389 }
6390
6391 /**
6392 * Store column metadata for the last SQLite statement.
6393 *
6394 * This function stores the original SQLite column metadata as-is, without
6395 * converting it into MySQL column metadata. That is done only when needed.
6396 *
6397 * @param PDOStatement $stmt The PDOStatement object containing the SQLite column metadata.
6398 */
6399 private function store_last_column_meta_from_statement( PDOStatement $stmt ): void {
6400 $this->last_column_meta = array();
6401 for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
6402 /*
6403 * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3.
6404 * See also: https://github.com/php/php-src/pull/5654
6405 */
6406 if ( PHP_VERSION_ID < 70300 ) {
6407 try {
6408 $this->last_column_meta[] = $stmt->getColumnMeta( $i );
6409 } catch ( Throwable $e ) {
6410 $this->last_column_meta[] = array(
6411 'native_type' => 'null',
6412 'pdo_type' => PDO::PARAM_NULL,
6413 'flags' => array(),
6414 'table' => '',
6415 'name' => '',
6416 'len' => -1,
6417 'precision' => 0,
6418 );
6419 }
6420 continue;
6421 }
6422
6423 $this->last_column_meta[] = $stmt->getColumnMeta( $i );
6424 }
6425 }
6426
6427 /**
6428 * Unnest parenthesized MySQL expression node.
6429 *
6430 * In MySQL, extra parentheses around simple expressions are not considered.
6431 *
6432 * For example, the "SELECT (((id)))" clause is equivalent to "SELECT id".
6433 * This means that the "(((id)))" part will behave as a column name rather
6434 * than as an expression, and the resulting column name will be just "id".
6435 *
6436 * @param WP_Parser_Node $node The expression AST node.
6437 * @return WP_Parser_Node The unnested expression.
6438 */
6439 private function unnest_parenthesized_expression( WP_Parser_Node $node ): WP_Parser_Node {
6440 $children = $node->get_children();
6441
6442 // Descend the "expr -> boolPri -> predicate -> bitExpr -> simpleExpr" -> "simpleExprBody"
6443 // tree, when on each level we have only a single child node (expression nesting).
6444 if (
6445 1 === count( $children )
6446 && $children[0] instanceof WP_Parser_Node
6447 && in_array( $children[0]->rule_name, array( 'expr', 'boolPri', 'predicate', 'bitExpr', 'simpleExpr', 'simpleExprBody' ), true )
6448 ) {
6449 $unnested = $this->unnest_parenthesized_expression( $children[0] );
6450 return $unnested === $children[0] ? $node : $unnested;
6451 }
6452
6453 // Unnest "OPEN_PAR_SYMBOL exprList CLOSE_PAR_SYMBOL" to "exprList".
6454 if (
6455 count( $children ) === 3
6456 && $children[0] instanceof WP_MySQL_Token && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $children[0]->id
6457 && $children[1] instanceof WP_Parser_Node && 'exprList' === $children[1]->rule_name
6458 && $children[2] instanceof WP_MySQL_Token && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $children[2]->id
6459 && 1 === count( $children[1]->get_children() )
6460 ) {
6461 return $this->unnest_parenthesized_expression( $children[1] );
6462 }
6463
6464 return $node;
6465 }
6466
6467 /**
6468 * Disambiguate and translate an expression with a simple or parenthesized
6469 * column reference for use within an ORDER BY, GROUP BY, or HAVING clause.
6470 *
6471 * In SQLite, columns that exist in multiple tables used within a query must
6472 * be fully qualified when used in the ORDER BY, GROUP BY, or HAVING clause.
6473 * In MySQL, these can be disambiguated using the SELECT item list.
6474 *
6475 * For example, when tables "t1" and "t2" both have a column called "name",
6476 * the following query will cause an "ambiguous column name" error in SQLite,
6477 * but it will succeed in MySQL, using the "t1.name" from the SELECT clause:
6478 *
6479 * SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY name
6480 *
6481 * This is because MySQL primarily considers the "name" column that was used
6482 * in the SELECT list - when it is unambiguous, it will be used in ORDER BY.
6483 *
6484 * To emulate this behavior in SQLite, we will search for unqualified column
6485 * references in the ORDER BY, GROUP BY, or HAVING item expression, and try
6486 * to qualify them using the SELECT item list.
6487 *
6488 * In other words, the above query will be rewritten as follows:
6489 *
6490 * SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY t1.name
6491 *
6492 * Note that the ORDER BY column was rewritten from "name" to "t1.name".
6493 *
6494 * @TODO: When multi-database support is implemented, we'll also need to
6495 * consider column references in forms like "db.table.column".
6496 *
6497 * @param array $disambiguation_map The SELECT item disambiguation map (column name => array of select items).
6498 * @see WP_MySQL_On_SQLite::create_select_item_disambiguation_map()
6499 * @param WP_Parser_Node $expr The expression AST node or subnode.
6500 * @return string|null The disambiguated and translated expression;
6501 * null when the expression cannot be disambiguated.
6502 */
6503 private function disambiguate_item( array $disambiguation_map, WP_Parser_Node $expr ) {
6504 // Skip when there is no column in the expression (no "columnRef" node),
6505 // or when the column is already qualified (has a "dotIdentifier" node).
6506 $column_ref = $expr->get_first_descendant_node( 'columnRef' );
6507 if ( ! $column_ref || $column_ref->get_first_descendant_node( 'dotIdentifier' ) ) {
6508 return null;
6509 }
6510
6511 // Support also parenthesized column references (e.g. "(id)").
6512 $expr = $this->unnest_parenthesized_expression( $expr );
6513
6514 // Consider only simple and parenthesized column references (as per MySQL).
6515 $expr_value = $this->translate( $expr );
6516 $column_value = $this->translate( $column_ref );
6517 if ( $expr_value !== $column_value ) {
6518 return null;
6519 }
6520
6521 // Look for SELECT items that match the column reference.
6522 $column_name = $this->translate( $column_ref );
6523 $select_item_matches = $disambiguation_map[ $column_name ] ?? array();
6524
6525 // When we find exactly one matching SELECT list item, we can disambiguate
6526 // the column reference. Otherwise, fall back to the original expression.
6527 if ( 1 === count( $select_item_matches ) ) {
6528 return $select_item_matches[0];
6529 }
6530 return null;
6531 }
6532
6533 /**
6534 * Create a SELECT item disambiguation map from a SELECT item list for use
6535 * with the ORDER BY, GROUP BY, and HAVING clause disambiguation algorithm.
6536 *
6537 * @see WP_MySQL_On_SQLite::disambiguate_item()
6538 *
6539 * @param WP_Parser_Node $select_item_list The "selectItemList" AST node.
6540 * @return array The SELECT item disambiguation map (column name => array of select items).
6541 */
6542 private function create_select_item_disambiguation_map( WP_Parser_Node $select_item_list ): array {
6543 // Create a map of SELECT item column names to their qualified values.
6544 $disambiguation_map = array();
6545 foreach ( $select_item_list->get_child_nodes() as $select_item ) {
6546 /*
6547 * [GRAMMAR]
6548 * selectItem: tableWild | (expr selectAlias?)
6549 */
6550
6551 // Skip when a "tableWild" node is used (no "expr" node).
6552 $select_item_expr = $select_item->get_first_child_node( 'expr' );
6553 if ( ! $select_item_expr ) {
6554 continue;
6555 }
6556
6557 // A SELECT item alias always needs to be preserved as-is.
6558 $alias = $select_item->get_first_child_node( 'selectAlias' );
6559 if ( $alias ) {
6560 $alias_value = $this->translate( $alias->get_first_child_node() );
6561 $disambiguation_map[ $alias_value ] = array( $alias_value );
6562 continue;
6563 }
6564
6565 // Skip when there is no column listed (no "columnRef" node).
6566 $select_column_ref = $select_item_expr->get_first_descendant_node( 'columnRef' );
6567 if ( ! $select_column_ref ) {
6568 continue;
6569 }
6570
6571 // Skip when the column reference is not qualified (no "dotIdentifier" node).
6572 $dot_identifiers = $select_column_ref->get_descendant_nodes( 'dotIdentifier' );
6573 if ( 0 === count( $dot_identifiers ) ) {
6574 continue;
6575 }
6576
6577 // Support also parenthesized column references (e.g. "(t.id)").
6578 $select_item_expr = $this->unnest_parenthesized_expression( $select_item_expr );
6579
6580 // Consider only simple and parenthesized column references (as per MySQL).
6581 $expr_value = $this->translate( $select_item_expr );
6582 $column_value = $this->translate( $select_column_ref );
6583 if ( $expr_value !== $column_value ) {
6584 continue;
6585 }
6586
6587 // The column name is the last "dotIdentifier" node.
6588 $key = $this->translate( end( $dot_identifiers )->get_first_child_node() );
6589
6590 $disambiguation_map[ $key ] = $disambiguation_map[ $key ] ?? array();
6591 $disambiguation_map[ $key ][] = $column_value;
6592 }
6593 return $disambiguation_map;
6594 }
6595
6596 /**
6597 * Analyze a "tableReferenceList" AST node and extract table data.
6598 *
6599 * This method extracts table data for all tables that are used at the root
6600 * level of a given query, including tables that are referenced using JOINs.
6601 *
6602 * The returned array maps table aliases to table names and additional data:
6603 * - key: table alias, or name if no alias is used
6604 * - value: an array of table data
6605 * - database: the database name of the table (null for derived tables)
6606 * - table_name: the real name of the table (null for derived tables)
6607 * - table_expr: the table expression for a derived table (null for regular tables)
6608 * - join_expr: the join expression used for the table (null when no join is used)
6609 *
6610 * MySQL has a non-stand ardsyntax extension where a comma-separated list of
6611 * table references is allowed as a table reference in itself, for instance:
6612 * SELECT * FROM (t1, t2) JOIN t3 ON 1
6613 *
6614 * Which is equivalent to:
6615 * SELECT * FROM (t1 CROSS JOIN t2) JOIN t3 ON 1
6616 *
6617 * @param WP_Parser_Node $node The "tableReferenceList" AST node.
6618 * @return array The table reference map (table alias => array of table data).
6619 */
6620 private function create_table_reference_map( WP_Parser_Node $node ): array {
6621 $table_map = array();
6622
6623 // Collect all table references, including the ones used in JOINs.
6624 $table_refs = array();
6625 foreach ( $node->get_child_nodes( 'tableReference' ) as $table_ref ) {
6626 $table_refs[] = $table_ref;
6627 foreach ( $table_ref->get_child_nodes( 'joinedTable' ) as $joined_table ) {
6628 $table_refs[] = $joined_table;
6629 }
6630 }
6631
6632 // Process each table reference, extracting table data.
6633 foreach ( $table_refs as $table_ref ) {
6634 $table_factor = $table_ref->get_first_descendant_node( 'tableFactor' );
6635 $join_expr = $table_ref->get_first_child_node( 'expr' );
6636 $child = $table_factor->get_first_child_node();
6637
6638 // Descend all "singleTableParens" nodes to get the "singleTable" node.
6639 if ( 'singleTableParens' === $child->rule_name ) {
6640 $child = $child->get_first_descendant_node( 'singleTable' );
6641 }
6642
6643 if ( 'singleTable' === $child->rule_name ) {
6644 // Extract data from the "singleTable" node.
6645 $table_ref = $child->get_first_child_node( 'tableRef' );
6646 $name = $this->translate( $table_ref );
6647 $alias_node = $child->get_first_child_node( 'tableAlias' );
6648 $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null;
6649
6650 $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array(
6651 'database' => $this->get_database_name( $table_ref ),
6652 'table_name' => $this->unquote_sqlite_identifier( $name ),
6653 'table_expr' => null,
6654 'join_expr' => $this->translate( $join_expr ),
6655 );
6656 } elseif ( 'derivedTable' === $child->rule_name ) {
6657 // Extract data from the "derivedTable" node.
6658 $subquery = $child->get_first_descendant_node( 'subquery' );
6659 $alias_node = $child->get_first_child_node( 'tableAlias' );
6660 $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null;
6661
6662 $table_map[ $this->unquote_sqlite_identifier( $alias ) ] = array(
6663 'database' => null,
6664 'table_name' => null,
6665 'table_expr' => $this->translate( $subquery ),
6666 'join_expr' => $this->translate( $join_expr ),
6667 );
6668 } elseif ( 'tableReferenceListParens' === $child->rule_name ) {
6669 // Recursively process the "tableReferenceListParens" node.
6670 $table_ref_list = $child->get_first_descendant_node( 'tableReferenceList' );
6671 $table_map = array_merge( $table_map, $this->create_table_reference_map( $table_ref_list ) );
6672 }
6673 }
6674 return $table_map;
6675 }
6676
6677 /**
6678 * Emulate MySQL type casting for values to be saved to the database
6679 * using INSERT, REPLACE, or UPDATE statements.
6680 *
6681 * @param string $mysql_data_type The MySQL data type.
6682 * @param string $translated_value The original translated value.
6683 * @return string The translated value.
6684 */
6685 private function cast_value_for_saving(
6686 string $mysql_data_type,
6687 string $translated_value
6688 ): string {
6689 // TODO: This is also a good place to implement checks for maximum column
6690 // lengths with truncating or bailing out depending on the SQL mode.
6691
6692 // Check if strict mode is enabled.
6693 $is_strict_mode = (
6694 $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
6695 || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
6696 );
6697
6698 $mysql_data_type = strtolower( $mysql_data_type );
6699 $sqlite_data_type = self::DATA_TYPE_STRING_MAP[ $mysql_data_type ];
6700
6701 /*
6702 * In MySQL, when saving a value via INSERT or UPDATE in non-strict mode,
6703 * 1. MySQL attempts to cast the value to the target column data type.
6704 * 2. When casting can't be done, MySQL saves an IMPLICIT DEFAULT.
6705 */
6706 switch ( $mysql_data_type ) {
6707 case 'date':
6708 case 'time':
6709 case 'datetime':
6710 case 'timestamp':
6711 case 'year':
6712 /*
6713 * MySQL supports date and time components without a zero padding,
6714 * but that doesn't work with date and time functions in SQLite.
6715 * E.g.: "2025-3-7 9:5:2" is a valid datetime/timestamp value in
6716 * in MySQL, but SQLite requires it to be "2025-03-07 09:05:02".
6717 *
6718 * A solution to this would need to be done on the SQL level to
6719 * address computed values, and it should be done for the strict
6720 * mode as well. This may require a user-defined function.
6721 *
6722 * TODO: Handle zero padding for date and time functions, while
6723 * supporting both strict and non-strict modes.
6724 */
6725
6726 if ( 'date' === $mysql_data_type ) {
6727 $function_call = sprintf( 'DATE(%s)', $translated_value );
6728 } elseif ( 'time' === $mysql_data_type ) {
6729 $function_call = sprintf( 'TIME(%s)', $translated_value );
6730 } elseif ( 'datetime' === $mysql_data_type || 'timestamp' === $mysql_data_type ) {
6731 $function_call = sprintf( 'DATETIME(%s)', $translated_value );
6732 } elseif ( 'year' === $mysql_data_type ) {
6733 /*
6734 * The YEAR type in MySQL only uses 1 byte and therefore
6735 * covers only 256 values from 1901 to 2155 included.
6736 * Additionally:
6737 * - Numbers from 0 to 69 correspond to years 2000 to 2069.
6738 * - Numbers from 70 to 99 correspond to years 1970 to 1999.
6739 */
6740 return sprintf(
6741 "(
6742 SELECT CASE
6743 WHEN value IS NULL THEN NULL
6744 WHEN value = 0 THEN '0000'
6745 WHEN value BETWEEN 1901 AND 2155 THEN value
6746 WHEN value BETWEEN 1 AND 69 THEN 2000 + value
6747 WHEN value BETWEEN 70 AND 99 THEN 1900 + value
6748 ELSE %s
6749 END
6750 FROM (SELECT CAST(%s AS INTEGER) AS value)
6751 )",
6752 $is_strict_mode
6753 ? sprintf( "THROW('Out of range value: ''' || %s || '''')", $translated_value )
6754 : "'0000'",
6755 $translated_value
6756 );
6757 }
6758
6759 // In strict mode, invalid date/time values are rejected.
6760 // In non-strict mode, they get an IMPLICIT DEFAULT value.
6761 if ( $is_strict_mode ) {
6762 $fallback = sprintf(
6763 "THROW('Incorrect %s value: ''' || %s || '''')",
6764 $mysql_data_type,
6765 $translated_value
6766 );
6767 } else {
6768 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $mysql_data_type ] ?? null;
6769 $fallback = null === $implicit_default
6770 ? 'NULL'
6771 : $this->quote_sqlite_value( $implicit_default );
6772 }
6773
6774 /*
6775 * Build the CASE expression for date/time validation.
6776 *
6777 * SQLite's DATE()/DATETIME() functions return NULL for zero
6778 * dates, so the CASE includes explicit checks controlled by
6779 * the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes.
6780 *
6781 * In MySQL, the behavior of zero dates depends on these modes:
6782 *
6783 * NO_ZERO_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
6784 * - Disabled: '0000-00-00' is permitted and produces no warning.
6785 * - Enabled without strict mode: '0000-00-00' is permitted but produces a warning.
6786 * - Enabled with strict mode: '0000-00-00' is not permitted and produces an error.
6787 *
6788 * NO_ZERO_IN_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date):
6789 * - Disabled: dates with zero month/day parts (e.g. '2020-00-15') are permitted.
6790 * - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'.
6791 * - Enabled with strict mode: zero-part dates produce an error.
6792 */
6793 return strtr(
6794 "CASE
6795 WHEN {value} IS NULL THEN NULL
6796 WHEN {value} IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT {reject_zero_date} THEN {zero_date_value}
6797 WHEN SUBSTR({value}, 1, 4) != '0000' AND (SUBSTR({value}, 6, 2) = '00' OR SUBSTR({value}, 9, 2) = '00') AND NOT {reject_zero_in_date} THEN {value}
6798 WHEN {function_call} > '0' THEN {function_call}
6799 ELSE {fallback}
6800 END",
6801 array(
6802 '{value}' => $translated_value,
6803 '{reject_zero_date}' => (
6804 $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode
6805 ) ? 1 : 0,
6806 '{zero_date_value}' => 'date' === $mysql_data_type
6807 ? "'0000-00-00'"
6808 : "'0000-00-00 00:00:00'",
6809 '{reject_zero_in_date}' => $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0,
6810 '{function_call}' => $function_call,
6811 '{fallback}' => $fallback,
6812 )
6813 );
6814 default:
6815 /*
6816 * For all other data types, cast to the SQLite types as follows:
6817 * 1. In strict mode, cast only values for TEXT and BLOB columns.
6818 * Numeric types accept string notation in SQLite as well.
6819 * 2. In non-strict mode, cast all values.
6820 *
6821 * TODO: While close to MySQL behavior, this doesn't exactly match
6822 * all special cases. We may improve this further to accept
6823 * BLOBs for numeric types, and other special behaviors.
6824 */
6825 if ( ! $is_strict_mode || 'TEXT' === $sqlite_data_type || 'BLOB' === $sqlite_data_type ) {
6826 return sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type );
6827 }
6828 return $translated_value;
6829 }
6830 }
6831
6832 /**
6833 * Get the database name as it is saved in the information schema tables.
6834 *
6835 * @param string|null $db_name Optional. The database name to use. Defaults to the current database name.
6836 * @return string The database name as it is saved in the information schema tables.
6837 */
6838 private function get_saved_db_name( ?string $db_name = null ): string {
6839 if ( null === $db_name ) {
6840 $db_name = $this->db_name;
6841 }
6842 return $this->main_db_name === $db_name
6843 ? WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME
6844 : $db_name;
6845 }
6846
6847 /**
6848 * Get the database name from one of fully-qualified name AST nodes.
6849 *
6850 * @param WP_Parser_Node $node The AST node. One of "tableName", "tableRef", or "inDb".
6851 * @return string The database name.
6852 */
6853 private function get_database_name( WP_Parser_Node $node ): string {
6854 if ( 'tableName' === $node->rule_name || 'tableRef' === $node->rule_name ) {
6855 $parts = $node->get_descendant_nodes( 'identifier' );
6856 if ( count( $parts ) > 1 ) {
6857 return $this->unquote_sqlite_identifier( $this->translate( $parts[0] ) );
6858 } else {
6859 return $this->db_name;
6860 }
6861 } elseif ( 'inDb' === $node->rule_name ) {
6862 return $this->unquote_sqlite_identifier(
6863 $this->translate( $node->get_first_child_node( 'identifier' ) )
6864 );
6865 }
6866
6867 throw $this->new_driver_exception(
6868 sprintf( 'Could not get database name from node: %s', $node->rule_name )
6869 );
6870 }
6871
6872 /**
6873 * Generate a SQLite CREATE TABLE statement from information schema data.
6874 *
6875 * @param bool $table_is_temporary Whether the table is temporary.
6876 * @param string $table_name The name of the table to create.
6877 * @param string|null $new_table_name Override the original table name for ALTER TABLE emulation.
6878 * @return string[] Queries to create the table, indexes, and constraints.
6879 * @throws WP_MySQL_On_SQLite_Exception When the table information is missing.
6880 */
6881 private function get_sqlite_create_table_statement(
6882 bool $table_is_temporary,
6883 string $table_name,
6884 ?string $new_table_name = null
6885 ): array {
6886 // This method is always used with the main database.
6887 $database = $this->get_saved_db_name( $this->main_db_name );
6888
6889 // 1. Get table info.
6890 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
6891 $table_info = $this->execute_sqlite_query(
6892 '
6893 SELECT *
6894 FROM ' . $this->quote_sqlite_identifier( $tables_table ) . "
6895 WHERE table_type = 'BASE TABLE'
6896 AND table_schema = ?
6897 AND table_name = ?
6898 ",
6899 array( $database, $table_name )
6900 )->fetch( PDO::FETCH_ASSOC );
6901
6902 if ( false === $table_info ) {
6903 throw $this->new_table_not_found_exception( $table_name );
6904 }
6905
6906 // 2. Get column info.
6907 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
6908 $column_info = $this->execute_sqlite_query(
6909 sprintf(
6910 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position',
6911 $this->quote_sqlite_identifier( $columns_table )
6912 ),
6913 array( $database, $table_name )
6914 )->fetchAll( PDO::FETCH_ASSOC );
6915
6916 // 3. Get index info, grouped by index name.
6917 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
6918 $constraint_info = $this->execute_sqlite_query(
6919 sprintf(
6920 "
6921 SELECT *
6922 FROM %s
6923 WHERE table_schema = ?
6924 AND table_name = ?
6925 ORDER BY
6926 INDEX_NAME = 'PRIMARY' DESC,
6927 NON_UNIQUE = '0' DESC,
6928 INDEX_TYPE = 'SPATIAL' DESC,
6929 INDEX_TYPE = 'BTREE' DESC,
6930 INDEX_TYPE = 'FULLTEXT' DESC,
6931 ROWID,
6932 SEQ_IN_INDEX
6933 ",
6934 $this->quote_sqlite_identifier( $statistics_table )
6935 ),
6936 array( $database, $table_name )
6937 )->fetchAll( PDO::FETCH_ASSOC );
6938
6939 $grouped_constraints = array();
6940 foreach ( $constraint_info as $constraint ) {
6941 $name = $constraint['INDEX_NAME'];
6942 $seq = $constraint['SEQ_IN_INDEX'];
6943 $grouped_constraints[ $name ][ $seq ] = $constraint;
6944 }
6945
6946 // 4. Get foreign key info.
6947 $referential_constraints_table = $this->information_schema_builder
6948 ->get_table_name( $table_is_temporary, 'referential_constraints' );
6949 $referential_constraints_info = $this->execute_sqlite_query(
6950 sprintf(
6951 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name',
6952 $this->quote_sqlite_identifier( $referential_constraints_table )
6953 ),
6954 array( $database, $table_name )
6955 )->fetchAll( PDO::FETCH_ASSOC );
6956
6957 $key_column_usage_map = array();
6958 if ( count( $referential_constraints_info ) > 0 ) {
6959 $key_column_usage_table = $this->information_schema_builder
6960 ->get_table_name( $table_is_temporary, 'key_column_usage' );
6961 $key_column_usage_info = $this->execute_sqlite_query(
6962 sprintf(
6963 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL',
6964 $this->quote_sqlite_identifier( $key_column_usage_table )
6965 ),
6966 array( $database, $table_name )
6967 )->fetchAll( PDO::FETCH_ASSOC );
6968
6969 $key_column_usage_map = array();
6970 foreach ( $key_column_usage_info as $key_column_usage ) {
6971 $constraint_name = $key_column_usage['CONSTRAINT_NAME'];
6972 if ( ! isset( $key_column_usage_map[ $constraint_name ] ) ) {
6973 $key_column_usage_map[ $constraint_name ] = array();
6974 }
6975 $key_column_usage_map[ $constraint_name ][] = array(
6976 $key_column_usage['COLUMN_NAME'],
6977 $key_column_usage['REFERENCED_COLUMN_NAME'],
6978 );
6979 }
6980 }
6981
6982 // 5. Get CHECK constraint info.
6983 $table_constraints_table = $this->information_schema_builder
6984 ->get_table_name( $table_is_temporary, 'table_constraints' );
6985 $check_constraints_table = $this->information_schema_builder
6986 ->get_table_name( $table_is_temporary, 'check_constraints' );
6987 $check_constraints_info = $this->execute_sqlite_query(
6988 sprintf(
6989 'SELECT tc.*, cc.check_clause
6990 FROM %s tc
6991 JOIN %s cc ON cc.constraint_name = tc.constraint_name
6992 WHERE tc.constraint_schema = ?
6993 AND tc.table_name = ?
6994 ORDER BY tc.constraint_name',
6995 $this->quote_sqlite_identifier( $table_constraints_table ),
6996 $this->quote_sqlite_identifier( $check_constraints_table )
6997 ),
6998 array( $database, $table_name )
6999 )->fetchAll( PDO::FETCH_ASSOC );
7000
7001 // 6. Generate CREATE TABLE statement columns.
7002 $rows = array();
7003 $on_update_queries = array();
7004 $has_autoincrement = false;
7005 foreach ( $column_info as $column ) {
7006 $query = ' ';
7007 $query .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
7008
7009 $type = self::DATA_TYPE_STRING_MAP[ $column['DATA_TYPE'] ];
7010
7011 /*
7012 * In SQLite, there is a PRIMARY KEY quirk for backward compatibility.
7013 * This applies to ROWID tables and single-column primary keys only:
7014 * 1. "INTEGER PRIMARY KEY" creates an alias of ROWID.
7015 * 2. "INT PRIMARY KEY" will not alias of ROWID.
7016 *
7017 * Therefore, we want to:
7018 * 1. Use "INT PRIMARY KEY" when we have a single-column integer
7019 * PRIMARY KEY without AUTOINCREMENT (to avoid the ROWID alias).
7020 * 2. Use "INTEGER PRIMARY KEY" otherwise.
7021 *
7022 * In SQLite, "AUTOINCREMENT" is only allowed on "INTEGER PRIMARY KEY",
7023 * and setting it changes the automatic ROWID assignment algorithm to
7024 * prevent the reuse of ROWIDs. Using "INT PRIMARY KEY" is not allowed.
7025 *
7026 * See:
7027 * - https://www.sqlite.org/autoinc.html
7028 * - https://www.sqlite.org/lang_createtable.html
7029 */
7030 if (
7031 'INTEGER' === $type
7032 && 'PRI' === $column['COLUMN_KEY']
7033 && ! str_contains( $column['EXTRA'], 'auto_increment' )
7034 && count( $grouped_constraints['PRIMARY'] ) === 1
7035 ) {
7036 $type = 'INT';
7037 }
7038
7039 $query .= ' ' . $type;
7040
7041 // In MySQL, text fields are case-insensitive by default.
7042 // COLLATE NOCASE emulates the same behavior in SQLite.
7043 // @TODO: Respect the actual column and index collation.
7044 if ( 'TEXT' === $type ) {
7045 $query .= ' COLLATE NOCASE';
7046 }
7047 if ( 'NO' === $column['IS_NULLABLE'] ) {
7048 $query .= ' NOT NULL';
7049 }
7050 if ( str_contains( $column['EXTRA'], 'auto_increment' ) ) {
7051 $has_autoincrement = true;
7052 $query .= ' PRIMARY KEY AUTOINCREMENT';
7053 }
7054 if ( null !== $column['COLUMN_DEFAULT'] ) {
7055 // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
7056 // and datetime columns. For other column types, it's just a string.
7057 if (
7058 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
7059 && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
7060 ) {
7061 $query .= ' DEFAULT CURRENT_TIMESTAMP';
7062 } elseif ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) {
7063 // Handle DEFAULT values with expressions (DEFAULT_GENERATED).
7064 // Translate the default clause from MySQL to SQLite.
7065 $ast = $this->create_parser( 'SELECT ' . $column['COLUMN_DEFAULT'] )->parse();
7066 $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node();
7067 $default_clause = $this->translate( $expr );
7068 $query .= sprintf( ' DEFAULT (%s)', $default_clause );
7069 } else {
7070 $query .= ' DEFAULT ' . $this->format_sqlite_column_default( $column );
7071 }
7072 }
7073 $rows[] = $query;
7074
7075 if ( 'on update CURRENT_TIMESTAMP' === $column['EXTRA'] ) {
7076 $on_update_queries[] = $this->get_column_on_update_trigger_query(
7077 $table_name,
7078 $column['COLUMN_NAME']
7079 );
7080 }
7081 }
7082
7083 // 6. Generate CREATE TABLE statement constraints, collect indexes.
7084 $create_index_queries = array();
7085 foreach ( $grouped_constraints as $constraint ) {
7086 ksort( $constraint );
7087 $info = $constraint[1];
7088
7089 $column_list = array_map(
7090 function ( $column ) {
7091 $fragment = $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
7092 if ( 'D' === $column['COLLATION'] ) {
7093 $fragment .= ' DESC';
7094 }
7095 return $fragment;
7096 },
7097 $constraint
7098 );
7099
7100 if ( 'PRIMARY' === $info['INDEX_NAME'] ) {
7101 if ( $has_autoincrement ) {
7102 /*
7103 * In MySQL, a compound PRIMARY KEY can have an AUTO_INCREMENT
7104 * column, when it is the first column in the key.
7105 *
7106 * SQLite doesn't support this, but we can emulate it as follows:
7107 * 1. Keep only the first column as a PRIMARY KEY.
7108 * Since this is the column that also has AUTO_INCREMENT,
7109 * it reasonable to assume that its values are unique.
7110 * 2. Create a UNIQUE key for all the PRIMARY KEY columns.
7111 * This is to preserve the index of the compound key.
7112 */
7113 if ( count( $constraint ) > 1 ) {
7114 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, 'primary' );
7115 $create_index_queries[] = sprintf(
7116 'CREATE UNIQUE INDEX %s ON %s (%s)',
7117 self::RESERVED_PREFIX . $sqlite_index_name,
7118 $this->quote_sqlite_identifier( $table_name ),
7119 implode( ', ', $column_list )
7120 );
7121 }
7122
7123 /*
7124 * The PRIMARY KEY was already generated with AUTOINCREMENT,
7125 * as required by SQLite column constraint syntax.
7126 *
7127 * @see https://www.sqlite.org/syntax/column-constraint.html
7128 */
7129 continue;
7130 }
7131 $rows[] = sprintf( ' PRIMARY KEY (%s)', implode( ', ', $column_list ) );
7132 } else {
7133 $is_unique = '0' === $info['NON_UNIQUE'];
7134
7135 // Prefix the original index name with the table name.
7136 // This is to avoid conflicting index names in SQLite.
7137 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $info['INDEX_NAME'] );
7138
7139 $create_index_queries[] = sprintf(
7140 'CREATE %sINDEX %s ON %s (%s)',
7141 $is_unique ? 'UNIQUE ' : '',
7142 $this->quote_sqlite_identifier( $sqlite_index_name ),
7143 $this->quote_sqlite_identifier( $table_name ),
7144 implode( ', ', $column_list )
7145 );
7146 }
7147 }
7148
7149 // 8. Add foreign key constraints.
7150 foreach ( $referential_constraints_info as $referential_constraint ) {
7151 $column_names = array();
7152 $referenced_column_names = array();
7153 foreach ( $key_column_usage_map[ $referential_constraint['CONSTRAINT_NAME'] ] as $info ) {
7154 $column_names[] = $this->quote_sqlite_identifier( $info[0] );
7155 $referenced_column_names[] = $this->quote_sqlite_identifier( $info[1] );
7156 }
7157 $query = sprintf(
7158 ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)',
7159 $this->quote_sqlite_identifier( $referential_constraint['CONSTRAINT_NAME'] ),
7160 implode( ', ', $column_names ),
7161 $this->quote_sqlite_identifier( $referential_constraint['REFERENCED_TABLE_NAME'] ),
7162 implode( ', ', $referenced_column_names )
7163 );
7164
7165 // ON DELETE
7166 $delete_rule = $referential_constraint['DELETE_RULE'];
7167 if ( 'NO ACTION' === $delete_rule ) {
7168 // In MySQL, NO ACTION is equivalent to RESTRICT with InnoDB.
7169 $delete_rule = 'RESTRICT';
7170 }
7171 $query .= sprintf( ' ON DELETE %s', $delete_rule );
7172
7173 // ON UPDATE
7174 $update_rule = $referential_constraint['UPDATE_RULE'];
7175 if ( 'NO ACTION' === $update_rule ) {
7176 // In MySQL, NO ACTION is equivalent to RESTRICT with InnoDB.
7177 $update_rule = 'RESTRICT';
7178 }
7179 $query .= sprintf( ' ON UPDATE %s', $update_rule );
7180
7181 $rows[] = $query;
7182 }
7183
7184 // 9. Add CHECK constraints.
7185 foreach ( $check_constraints_info as $check_constraint ) {
7186 if ( 'NO' === $check_constraint['ENFORCED'] ) {
7187 continue;
7188 }
7189
7190 // Translate the check clause from MySQL to SQLite.
7191 $ast = $this->create_parser( 'SELECT ' . $check_constraint['CHECK_CLAUSE'] )->parse();
7192 $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node();
7193 $check_clause = $this->translate( $expr );
7194
7195 $sql = sprintf(
7196 ' CONSTRAINT %s CHECK (%s)',
7197 $this->quote_sqlite_identifier( $check_constraint['CONSTRAINT_NAME'] ),
7198 $check_clause
7199 );
7200 $rows[] = $sql;
7201 }
7202
7203 // 10. Compose the CREATE TABLE statement.
7204 $create_table_query = sprintf(
7205 "CREATE %sTABLE %s (\n",
7206 $table_is_temporary ? 'TEMPORARY ' : '',
7207 $this->quote_sqlite_identifier( $new_table_name ?? $table_name )
7208 );
7209 $create_table_query .= implode( ",\n", $rows );
7210 $create_table_query .= "\n)";
7211
7212 if ( version_compare( $this->get_sqlite_version(), '3.37.0', '>=' ) ) {
7213 $create_table_query .= ' STRICT';
7214 }
7215
7216 return array_merge( array( $create_table_query ), $create_index_queries, $on_update_queries );
7217 }
7218
7219 /**
7220 * Generate a MySQL CREATE TABLE statement from information schema data.
7221 *
7222 * @param bool $table_is_temporary Whether the table is temporary.
7223 * @param string $table_name The name of the table to create.
7224 * @return string The CREATE TABLE statement.
7225 */
7226 private function get_mysql_create_table_statement( bool $table_is_temporary, string $table_name ): ?string {
7227 // This method is always used with the main database.
7228 $database = $this->get_saved_db_name( $this->main_db_name );
7229
7230 // 1. Get table info.
7231 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
7232 $table_info = $this->execute_sqlite_query(
7233 '
7234 SELECT *
7235 FROM ' . $this->quote_sqlite_identifier( $tables_table ) . "
7236 WHERE table_type = 'BASE TABLE'
7237 AND table_schema = ?
7238 AND table_name = ?
7239 ",
7240 array( $database, $table_name )
7241 )->fetch( PDO::FETCH_ASSOC );
7242
7243 if ( false === $table_info ) {
7244 return null;
7245 }
7246
7247 // 2. Get column info.
7248 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
7249 $column_info = $this->execute_sqlite_query(
7250 sprintf(
7251 '
7252 SELECT *
7253 FROM %s
7254 WHERE table_schema = ?
7255 AND table_name = ?
7256 ORDER BY ordinal_position
7257 ',
7258 $this->quote_sqlite_identifier( $columns_table )
7259 ),
7260 array( $database, $table_name )
7261 )->fetchAll( PDO::FETCH_ASSOC );
7262
7263 // 3. Get index info, grouped by index name.
7264 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
7265 $constraint_info = $this->execute_sqlite_query(
7266 sprintf(
7267 "
7268 SELECT *
7269 FROM %s
7270 WHERE table_schema = ?
7271 AND table_name = ?
7272 ORDER BY
7273 INDEX_NAME = 'PRIMARY' DESC,
7274 NON_UNIQUE = '0' DESC,
7275 INDEX_TYPE = 'SPATIAL' DESC,
7276 INDEX_TYPE = 'BTREE' DESC,
7277 INDEX_TYPE = 'FULLTEXT' DESC,
7278 ROWID,
7279 SEQ_IN_INDEX
7280 ",
7281 $this->quote_sqlite_identifier( $statistics_table )
7282 ),
7283 array( $database, $table_name )
7284 )->fetchAll( PDO::FETCH_ASSOC );
7285
7286 $grouped_constraints = array();
7287 foreach ( $constraint_info as $constraint ) {
7288 $name = $constraint['INDEX_NAME'];
7289 $seq = $constraint['SEQ_IN_INDEX'];
7290 $grouped_constraints[ $name ][ $seq ] = $constraint;
7291 }
7292
7293 // 4. Get foreign key info.
7294 $referential_constraints_table = $this->information_schema_builder
7295 ->get_table_name( $table_is_temporary, 'referential_constraints' );
7296 $referential_constraints_info = $this->execute_sqlite_query(
7297 sprintf(
7298 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name',
7299 $this->quote_sqlite_identifier( $referential_constraints_table )
7300 ),
7301 array( $database, $table_name )
7302 )->fetchAll( PDO::FETCH_ASSOC );
7303
7304 $key_column_usage_map = array();
7305 if ( count( $referential_constraints_info ) > 0 ) {
7306 $key_column_usage_table = $this->information_schema_builder
7307 ->get_table_name( $table_is_temporary, 'key_column_usage' );
7308 $key_column_usage_info = $this->execute_sqlite_query(
7309 sprintf(
7310 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL',
7311 $this->quote_sqlite_identifier( $key_column_usage_table )
7312 ),
7313 array( $database, $table_name )
7314 )->fetchAll( PDO::FETCH_ASSOC );
7315
7316 $key_column_usage_map = array();
7317 foreach ( $key_column_usage_info as $key_column_usage ) {
7318 $constraint_name = $key_column_usage['CONSTRAINT_NAME'];
7319 if ( ! isset( $key_column_usage_map[ $constraint_name ] ) ) {
7320 $key_column_usage_map[ $constraint_name ] = array();
7321 }
7322 $key_column_usage_map[ $constraint_name ][] = array(
7323 $key_column_usage['COLUMN_NAME'],
7324 $key_column_usage['REFERENCED_COLUMN_NAME'],
7325 );
7326 }
7327 }
7328
7329 // 5. Get CHECK constraint info.
7330 $table_constraints_table = $this->information_schema_builder
7331 ->get_table_name( $table_is_temporary, 'table_constraints' );
7332 $check_constraints_table = $this->information_schema_builder
7333 ->get_table_name( $table_is_temporary, 'check_constraints' );
7334 $check_constraints_info = $this->execute_sqlite_query(
7335 sprintf(
7336 'SELECT tc.*, cc.check_clause
7337 FROM %s tc
7338 JOIN %s cc ON cc.constraint_name = tc.constraint_name
7339 WHERE tc.constraint_schema = ?
7340 AND tc.table_name = ?
7341 ORDER BY tc.constraint_name',
7342 $this->quote_sqlite_identifier( $table_constraints_table ),
7343 $this->quote_sqlite_identifier( $check_constraints_table )
7344 ),
7345 array( $database, $table_name )
7346 )->fetchAll( PDO::FETCH_ASSOC );
7347
7348 // 6. Generate CREATE TABLE statement columns.
7349 $rows = array();
7350 $has_auto_increment = false;
7351 foreach ( $column_info as $column ) {
7352 $sql = ' ';
7353 $sql .= $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
7354 $sql .= ' ' . $column['COLUMN_TYPE'];
7355 if ( 'NO' === $column['IS_NULLABLE'] ) {
7356 $sql .= ' NOT NULL';
7357 } elseif ( 'timestamp' === $column['COLUMN_TYPE'] ) {
7358 // Nullable "timestamp" columns dump NULL explicitly.
7359 $sql .= ' NULL';
7360 }
7361 if ( str_contains( $column['EXTRA'], 'auto_increment' ) ) {
7362 $has_auto_increment = true;
7363 $sql .= ' AUTO_INCREMENT';
7364 }
7365
7366 // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
7367 // and datetime columns. For other column types, it's just a string.
7368 if (
7369 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
7370 && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
7371 ) {
7372 $sql .= ' DEFAULT CURRENT_TIMESTAMP';
7373 } elseif ( null !== $column['COLUMN_DEFAULT'] ) {
7374 if ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) {
7375 $sql .= sprintf( ' DEFAULT (%s)', $column['COLUMN_DEFAULT'] );
7376 } else {
7377 $sql .= ' DEFAULT ' . $this->format_mysql_column_default( $column );
7378 }
7379 } elseif ( 'YES' === $column['IS_NULLABLE'] ) {
7380 $sql .= ' DEFAULT NULL';
7381 }
7382
7383 // Handle ON UPDATE CURRENT_TIMESTAMP.
7384 if ( str_contains( $column['EXTRA'], 'on update CURRENT_TIMESTAMP' ) ) {
7385 $sql .= ' ON UPDATE CURRENT_TIMESTAMP';
7386 }
7387
7388 if ( '' !== $column['COLUMN_COMMENT'] ) {
7389 $sql .= sprintf(
7390 ' COMMENT %s',
7391 $this->quote_mysql_utf8_string_literal( $column['COLUMN_COMMENT'] )
7392 );
7393 }
7394
7395 $rows[] = $sql;
7396 }
7397
7398 // 7. Generate CREATE TABLE statement constraints, collect indexes.
7399 $format_index_column = function ( $column ) {
7400 $definition = $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
7401 if ( null !== $column['SUB_PART'] ) {
7402 $definition .= sprintf( '(%d)', $column['SUB_PART'] );
7403 }
7404 if ( 'D' === $column['COLLATION'] ) {
7405 $definition .= ' DESC';
7406 }
7407 return $definition;
7408 };
7409
7410 foreach ( $grouped_constraints as $constraint ) {
7411 ksort( $constraint );
7412 $info = $constraint[1];
7413
7414 if ( 'PRIMARY' === $info['INDEX_NAME'] ) {
7415 $sql = ' PRIMARY KEY (';
7416 $sql .= implode( ', ', array_map( $format_index_column, $constraint ) );
7417 $sql .= ')';
7418 } else {
7419 $is_unique = '0' === $info['NON_UNIQUE'];
7420
7421 $sql = sprintf(
7422 ' %s%s%sKEY ',
7423 $is_unique ? 'UNIQUE ' : '',
7424 'FULLTEXT' === $info['INDEX_TYPE'] ? 'FULLTEXT ' : '',
7425 'SPATIAL' === $info['INDEX_TYPE'] ? 'SPATIAL ' : ''
7426 );
7427 $sql .= $this->quote_mysql_identifier( $info['INDEX_NAME'] );
7428 $sql .= ' (';
7429 $sql .= implode( ', ', array_map( $format_index_column, $constraint ) );
7430 $sql .= ')';
7431 }
7432
7433 if ( '' !== $info['INDEX_COMMENT'] ) {
7434 $sql .= sprintf(
7435 ' COMMENT %s',
7436 $this->quote_mysql_utf8_string_literal( $info['INDEX_COMMENT'] )
7437 );
7438 }
7439
7440 $rows[] = $sql;
7441 }
7442
7443 // 8. Add foreign key constraints.
7444 foreach ( $referential_constraints_info as $referential_constraint ) {
7445 $column_names = array();
7446 $referenced_column_names = array();
7447 foreach ( $key_column_usage_map[ $referential_constraint['CONSTRAINT_NAME'] ] as $info ) {
7448 $column_names[] = $this->quote_mysql_identifier( $info[0] );
7449 $referenced_column_names[] = $this->quote_mysql_identifier( $info[1] );
7450 }
7451 $sql = sprintf(
7452 ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)',
7453 $this->quote_mysql_identifier( $referential_constraint['CONSTRAINT_NAME'] ),
7454 implode( ', ', $column_names ),
7455 $this->quote_mysql_identifier( $referential_constraint['REFERENCED_TABLE_NAME'] ),
7456 implode( ', ', $referenced_column_names )
7457 );
7458 if ( 'NO ACTION' !== $referential_constraint['DELETE_RULE'] ) {
7459 $sql .= sprintf( ' ON DELETE %s', $referential_constraint['DELETE_RULE'] );
7460 }
7461 if ( 'NO ACTION' !== $referential_constraint['UPDATE_RULE'] ) {
7462 $sql .= sprintf( ' ON UPDATE %s', $referential_constraint['UPDATE_RULE'] );
7463 }
7464 $rows[] = $sql;
7465 }
7466
7467 // 9. Add CHECK constraints.
7468 foreach ( $check_constraints_info as $check_constraint ) {
7469 $sql = sprintf(
7470 ' CONSTRAINT %s CHECK (%s)%s',
7471 $this->quote_mysql_identifier( $check_constraint['CONSTRAINT_NAME'] ),
7472 $check_constraint['CHECK_CLAUSE'],
7473 'NO' === $check_constraint['ENFORCED'] ? ' /*!80016 NOT ENFORCED */' : ''
7474 );
7475 $rows[] = $sql;
7476 }
7477
7478 // 10. Compose the CREATE TABLE statement.
7479 $collation = $table_info['TABLE_COLLATION'];
7480 $charset = substr( $collation, 0, strpos( $collation, '_' ) );
7481
7482 $sql = sprintf(
7483 "CREATE %sTABLE %s (\n",
7484 $table_is_temporary ? 'TEMPORARY ' : '',
7485 $this->quote_mysql_identifier( $table_name )
7486 );
7487 $sql .= implode( ",\n", $rows );
7488 $sql .= "\n)";
7489 $sql .= sprintf( ' ENGINE=%s', $table_info['ENGINE'] );
7490
7491 // Add "AUTO_INCREMENT=N" if a sequence exists and has been advanced.
7492 if ( $has_auto_increment ) {
7493 try {
7494 $seq = (int) $this->execute_sqlite_query(
7495 sprintf(
7496 'SELECT seq FROM %s.sqlite_sequence WHERE name = ?',
7497 $table_is_temporary ? 'temp' : 'main'
7498 ),
7499 array( $table_name )
7500 )->fetchColumn();
7501 } catch ( PDOException $e ) {
7502 if ( ! str_contains( $e->getMessage(), 'no such table' ) ) {
7503 throw $e;
7504 }
7505 $seq = 0;
7506 }
7507 if ( $seq > 0 ) {
7508 $sql .= sprintf( ' AUTO_INCREMENT=%d', $seq + 1 );
7509 }
7510 }
7511
7512 $sql .= sprintf( ' DEFAULT CHARSET=%s', $charset );
7513 $sql .= sprintf( ' COLLATE=%s', $collation );
7514 if ( '' !== $table_info['TABLE_COMMENT'] ) {
7515 $sql .= sprintf(
7516 ' COMMENT=%s',
7517 $this->quote_mysql_utf8_string_literal( $table_info['TABLE_COMMENT'] )
7518 );
7519 }
7520 return $sql;
7521 }
7522
7523 /**
7524 * Get an unique SQLite index name from a MySQL table name and index name.
7525 *
7526 * @param string $table_name The MySQL table name.
7527 * @param string $index_name The MySQL index name.
7528 * @return string The SQLite index name.
7529 */
7530 private function get_sqlite_index_name( string $mysql_table_name, string $mysql_index_name ): string {
7531 // Prefix the original index name with the table name.
7532 // This is to avoid conflicting index names in SQLite.
7533 return $mysql_table_name . '__' . $mysql_index_name;
7534 }
7535
7536 /**
7537 * Get an internal savepoint name.
7538 *
7539 * Internal savepoints are used to emulate MySQL transactions that are run
7540 * inside a wrapping SQLite transaction, as transactions can't be nested.
7541 *
7542 * @param string $name The name of the savepoint.
7543 * @return string The internal savepoint name.
7544 */
7545 private function get_internal_savepoint_name( string $name ): string {
7546 return sprintf( '%ssavepoint_%s', self::RESERVED_PREFIX, $name );
7547 }
7548
7549 /**
7550 * Get an SQLite query to emulate MySQL "ON UPDATE CURRENT_TIMESTAMP".
7551 *
7552 * In SQLite, "ON UPDATE CURRENT_TIMESTAMP" is not supported. We need to
7553 * create a trigger to emulate this behavior.
7554 *
7555 * @param string $table The table name.
7556 * @param string $column The column name.
7557 */
7558 private function get_column_on_update_trigger_query( string $table, string $column ): string {
7559 // The trigger wouldn't work for virtual and "WITHOUT ROWID" tables,
7560 // but currently that can't happen as we're not creating such tables.
7561 // See: https://www.sqlite.org/rowidtable.html
7562 $trigger_name = self::RESERVED_PREFIX . "{$table}_{$column}_on_update";
7563 return sprintf(
7564 '
7565 CREATE TRIGGER %s
7566 AFTER UPDATE ON %s
7567 FOR EACH ROW
7568 BEGIN
7569 UPDATE %s SET %s = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;
7570 END
7571 ',
7572 $this->quote_sqlite_identifier( $trigger_name ),
7573 $this->quote_sqlite_identifier( $table ),
7574 $this->quote_sqlite_identifier( $table ),
7575 $this->quote_sqlite_identifier( $column )
7576 );
7577 }
7578
7579 /**
7580 * Format a column default value as a literal for an SQLite CREATE TABLE statement.
7581 *
7582 * @param array $column The information schema column data.
7583 * @return string The default value as an SQLite literal.
7584 */
7585 private function format_sqlite_column_default( array $column ): string {
7586 if ( 'bit' === $column['DATA_TYPE'] ) {
7587 // BIT defaults are stored as b'...' literals; reformat as an integer.
7588 return (string) bindec( substr( $column['COLUMN_DEFAULT'], 2, -1 ) );
7589 }
7590
7591 return $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] );
7592 }
7593
7594 /**
7595 * Format a column default value as a literal for a MySQL SHOW CREATE TABLE statement.
7596 *
7597 * @param array $column The information schema column data.
7598 * @return string The default value as a MySQL literal.
7599 */
7600 private function format_mysql_column_default( array $column ): string {
7601 // BIT defaults are already stored as b'...' literals.
7602 if ( 'bit' === $column['DATA_TYPE'] ) {
7603 return $column['COLUMN_DEFAULT'];
7604 }
7605
7606 return $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] );
7607 }
7608
7609 /**
7610 * Unquote a quoted SQLite identifier.
7611 *
7612 * Remove bounding quotes and replace escaped quotes with their values.
7613 *
7614 * @param string $quoted_identifier The quoted identifier value.
7615 * @return string The unquoted identifier value.
7616 */
7617 private function unquote_sqlite_identifier( string $quoted_identifier ): string {
7618 $first_byte = $quoted_identifier[0] ?? null;
7619 if ( '"' === $first_byte || '`' === $first_byte ) {
7620 $unquoted = substr( $quoted_identifier, 1, -1 );
7621 return str_replace( $first_byte . $first_byte, $first_byte, $unquoted );
7622 }
7623 return $quoted_identifier;
7624 }
7625
7626 /**
7627 * Quote an identifier for use in an SQLite query.
7628 *
7629 * @param string $unquoted_identifier The unquoted identifier value.
7630 * @return string The quoted identifier value.
7631 */
7632 private function quote_sqlite_identifier( string $unquoted_identifier ): string {
7633 return $this->connection->quote_identifier( $unquoted_identifier );
7634 }
7635
7636 /**
7637 * Quote a value for use in an SQLite query.
7638 *
7639 * @param mixed $value The value to quote.
7640 * @return string The quoted value.
7641 */
7642 private function quote_sqlite_value( $value ): string {
7643 return $this->connection->quote( $value );
7644 }
7645
7646 /**
7647 * Quote an identifier for use in a MySQL query.
7648 *
7649 * Wrap the identifier in backticks and escape backtick values within.
7650 *
7651 * @param string $unquoted_identifier The unquoted identifier value.
7652 * @return string The quoted identifier value.
7653 */
7654 private function quote_mysql_identifier( string $unquoted_identifier ): string {
7655 return '`' . str_replace( '`', '``', $unquoted_identifier ) . '`';
7656 }
7657
7658 /**
7659 * Format a MySQL UTF-8 string literal for output in a CREATE TABLE statement.
7660 *
7661 * We expect UTF-8 strings coming from SQLite. The only characters that must
7662 * be escaped in a single-quoted string for a UTF-8 MySQL dump are ' and \.
7663 *
7664 * MySQL SHOW CREATE TABLE command additionally escapes "\0", "\n", and "\r",
7665 * for the mysql CLI, logs, and better readability. This applies to column
7666 * default values, and table, column, and index comments. Other values, such
7667 * as identifiers, don't have these extra characters escaped in the output.
7668 *
7669 * See:
7670 * - https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql/sql_show.cc#L1799
7671 * - https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql/table.cc#L3525
7672 *
7673 * Unfortunately, SQLite doesn't validate the UTF-8 encoding, so other byte
7674 * sequences may come from SQLite as well: https://www.sqlite.org/invalidutf.html
7675 *
7676 * TODO: We may consider stripping invalid UTF-8 characters, but that's likely
7677 * to be a bigger project, as these can appear also in other contexts.
7678 *
7679 * @param string $utf8_literal The UTF-8 string literal to escape.
7680 * @return string The escaped string literal.
7681 */
7682 private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
7683 /*
7684 * We can't use "addcslashes()" here, because it has an unusual handling
7685 * of the ASCII NULL character, escaping it to "\000" instead of "\0".
7686 *
7687 * It is important to use "strtr()" and not "str_replace()", because
7688 * "str_replace()" applies replacements one after another, modifying
7689 * intermediate changes rather than just the original string:
7690 *
7691 * - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
7692 * - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
7693 */
7694 $backslash = chr( 92 );
7695 $replacements = array(
7696 "'" => "''", // A single quote character (').
7697 $backslash => $backslash . $backslash, // A backslash character (\).
7698 chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0).
7699 chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n).
7700 chr( 13 ) => $backslash . 'r', // A carriage return character (\r).
7701 );
7702 return "'" . strtr( $utf8_literal, $replacements ) . "'";
7703 }
7704
7705 /**
7706 * Clear the state of the driver.
7707 */
7708 private function flush(): void {
7709 $this->last_mysql_query = '';
7710 $this->last_sqlite_queries = array();
7711 $this->last_result_statement = null;
7712 $this->last_affected_rows = null;
7713 $this->last_insert_id = '0';
7714 $this->last_column_meta = array();
7715 $this->is_readonly = false;
7716 $this->wrapper_transaction_type = null;
7717 $this->user_defined_functions->flush();
7718 }
7719
7720 /**
7721 * Create a PDO SQLite statement from the specified columns and rows.
7722 *
7723 * Some emulated MySQL queries don't have an SQLite counterpart and their
7724 * result data may be generated without a corresponding SQLite statement.
7725 * In such cases, we can generate a simple SQLite SELECT query that will
7726 * provide us with the PDOStatement API for the given column and row data.
7727 *
7728 * @param array $columns The columns of the result set.
7729 * @param array $rows The rows of the result set.
7730 * @return PDOStatement The corresponding PDO SQLite statement.
7731 */
7732 private function create_result_statement_from_data( array $columns, array $rows ): PDOStatement {
7733 $pdo = $this->connection->get_pdo();
7734
7735 /*
7736 * With 0 columns, we need to create a PDO statement that has no columns.
7737 * This can be done using a noop INSERT statement that modifies no data.
7738 */
7739 if ( 0 === count( $columns ) ) {
7740 /*
7741 * Build a 0-column statement to represent an empty result set.
7742 *
7743 * A no-op "INSERT ... WHERE FALSE" into a regular table still acquires
7744 * SQLite's write lock, even though it writes nothing. That makes
7745 * no-write statements such as "SET sql_mode = ..." fatal with
7746 * "database is locked" whenever another connection holds the write
7747 * lock. Writing to a connection-private TEMP table uses the temp
7748 * database, which has no shared lock, so it never contends.
7749 */
7750 if ( ! $this->empty_result_table_ready ) {
7751 $pdo->exec(
7752 sprintf(
7753 'CREATE TEMP TABLE IF NOT EXISTS %s ( placeholder_for_empty_insert )',
7754 $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
7755 )
7756 );
7757 $this->empty_result_table_ready = true;
7758 }
7759 return $pdo->query(
7760 sprintf(
7761 'INSERT INTO %s ( placeholder_for_empty_insert ) SELECT NULL WHERE FALSE',
7762 $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
7763 )
7764 );
7765 }
7766
7767 /*
7768 * Create an SQLite statement that returns the specified columns and rows.
7769 * This can be done using a SELECT statement in the following form:
7770 *
7771 * -- A dummy header row to assign correct column names.
7772 * SELECT NULL AS `col1`, NULL AS `col2`, ... WHERE FALSE
7773 *
7774 * UNION ALL
7775 *
7776 * -- The actual data rows.
7777 * VALUES
7778 * (val11, val12, ...),
7779 * (val21, val22, ...),
7780 * ...
7781 */
7782
7783 // Construct column header row ("SELECT <column-list> WHERE FALSE").
7784 $query = 'SELECT ';
7785 foreach ( $columns as $i => $column ) {
7786 $query .= $i > 0 ? ', ' : '';
7787 $query .= 'NULL AS ' . $pdo->quote( $column );
7788 }
7789 $query .= ' WHERE FALSE';
7790
7791 // UNION ALL
7792 if ( count( $rows ) > 0 ) {
7793 $query .= ' UNION ALL VALUES ';
7794 }
7795
7796 // Construct data rows ("VALUES <row-list>").
7797 foreach ( $rows as $i => $row ) {
7798 $query .= $i > 0 ? ', ' : '';
7799 $query .= '(';
7800 foreach ( array_values( $row ) as $j => $value ) {
7801 $query .= $j > 0 ? ', ' : '';
7802 if ( null === $value ) {
7803 $query .= 'NULL';
7804 } elseif ( is_string( $value ) && strpos( $value, "\0" ) !== false ) {
7805 // Handle null characters; see self::translate_string_literal().
7806 $query .= sprintf( "CAST(x'%s' AS TEXT)", bin2hex( $value ) );
7807 } elseif ( is_string( $value ) ) {
7808 $query .= $pdo->quote( $value );
7809 } else {
7810 $query .= $value;
7811 }
7812 }
7813 $query .= ')';
7814 }
7815 return $pdo->query( $query );
7816 }
7817
7818 /**
7819 * Create a new MySQL-on-SQLite driver exception.
7820 *
7821 * @param string $message The exception message.
7822 * @param int|string $code The exception code. For PDO errors, a string representing SQLSTATE.
7823 * @param Throwable|null $previous The previous exception.
7824 * @param array|null $error_info PDO-style error information.
7825 * @return WP_MySQL_On_SQLite_Exception
7826 */
7827 private function new_driver_exception(
7828 string $message,
7829 $code = 0,
7830 ?Throwable $previous = null,
7831 ?array $error_info = null
7832 ): WP_MySQL_On_SQLite_Exception {
7833 return new WP_MySQL_On_SQLite_Exception( $this, $message, $code, $previous, $error_info );
7834 }
7835
7836 /**
7837 * Create a new invalid input exception.
7838 *
7839 * This exception can be used to mark cases that should never occur according
7840 * to the MySQL grammar. It may serve as an assertion that should never fail.
7841 *
7842 * @return WP_MySQL_On_SQLite_Exception
7843 */
7844 private function new_invalid_input_exception(): WP_MySQL_On_SQLite_Exception {
7845 return new WP_MySQL_On_SQLite_Exception( $this, 'MySQL query syntax error.' );
7846 }
7847
7848 /**
7849 * Create a new not supported exception.
7850 *
7851 * This exception can be used to mark MySQL constructs that are not supported.
7852 *
7853 * @param string $cause The cause, indicating which construct is not supported.
7854 * @return WP_MySQL_On_SQLite_Exception
7855 */
7856 private function new_not_supported_exception( string $cause ): WP_MySQL_On_SQLite_Exception {
7857 return new WP_MySQL_On_SQLite_Exception(
7858 $this,
7859 sprintf( 'MySQL query not supported. Cause: %s', $cause )
7860 );
7861 }
7862
7863 /**
7864 * Create a MySQL-compatible table-not-found exception.
7865 *
7866 * @param string $table_name The missing table name.
7867 * @param Throwable|null $previous The previous exception.
7868 * @return WP_MySQL_On_SQLite_Exception
7869 */
7870 private function new_table_not_found_exception(
7871 string $table_name,
7872 ?Throwable $previous = null
7873 ): WP_MySQL_On_SQLite_Exception {
7874 $driver_message = sprintf( "Table '%s' doesn't exist", $table_name );
7875 return $this->new_driver_exception(
7876 'SQLSTATE[42S02]: Base table or view not found: 1146 ' . $driver_message,
7877 '42S02',
7878 $previous,
7879 array( '42S02', 1146, $driver_message )
7880 );
7881 }
7882
7883 /**
7884 * Create a MySQL-compatible savepoint-not-found exception.
7885 *
7886 * @param string $savepoint_name The missing savepoint name, as an SQLite identifier.
7887 * @return WP_MySQL_On_SQLite_Exception
7888 */
7889 private function new_savepoint_does_not_exist_exception( string $savepoint_name ): WP_MySQL_On_SQLite_Exception {
7890 $driver_message = sprintf(
7891 'SAVEPOINT %s does not exist',
7892 $this->unquote_sqlite_identifier( $savepoint_name )
7893 );
7894 return $this->new_driver_exception(
7895 'SQLSTATE[42000]: Syntax error or access violation: 1305 ' . $driver_message,
7896 '42000',
7897 null,
7898 array( '42000', 1305, $driver_message )
7899 );
7900 }
7901
7902 /**
7903 * Create a MySQL-compatible exception for an invalid SQL mode value.
7904 *
7905 * @param mixed $value The invalid SQL mode value.
7906 * @return WP_MySQL_On_SQLite_Exception
7907 */
7908 private function new_invalid_sql_mode_exception( $value ): WP_MySQL_On_SQLite_Exception {
7909 return $this->new_driver_exception(
7910 sprintf(
7911 "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of '%s'",
7912 null === $value ? 'NULL' : (string) $value
7913 ),
7914 '42000'
7915 );
7916 }
7917
7918 /**
7919 * Create a new access denied exception for the information schema database.
7920 *
7921 * @return WP_MySQL_On_SQLite_Exception
7922 */
7923 private function new_access_denied_to_information_schema_exception(): WP_MySQL_On_SQLite_Exception {
7924 return $this->new_driver_exception(
7925 "Access denied for user 'root'@'%' to database 'information_schema'",
7926 '42000'
7927 );
7928 }
7929
7930 /**
7931 * Convert a SQLite PDO exception to a MySQL-on-SQLite driver exception.
7932 *
7933 * @param PDOException $e The SQLite PDO exception.
7934 * @return WP_MySQL_On_SQLite_Exception
7935 */
7936 private function convert_sqlite_exception( PDOException $e ): WP_MySQL_On_SQLite_Exception {
7937 $error_info = is_array( $e->errorInfo ) ? $e->errorInfo : array();
7938 $sqlstate = $error_info[0] ?? null;
7939 $error_message = $error_info[2] ?? null;
7940
7941 if ( 'HY000' === $sqlstate && 0 === strpos( $error_message, 'no such table: ' ) ) {
7942 $table_name = substr( $error_message, strlen( 'no such table: ' ) );
7943 return $this->new_table_not_found_exception( $table_name, $e );
7944 }
7945
7946 return $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e );
7947 }
7948
7949 /**
7950 * Convert an information schema exception to a MySQL-like driver exception.
7951 *
7952 * This method is used to convert some information schema exceptions to the
7953 * corresponding MySQL exceptions, as they would be generated by PDO MySQL.
7954 * This conversion mirrors PDO's error messages and SQLSTATE codes.
7955 *
7956 * @param WP_SQLite_Information_Schema_Exception $e The information schema exception.
7957 * @return Throwable The converted exception, or the original
7958 * exception if no conversion was done.
7959 */
7960 private function convert_information_schema_exception( WP_SQLite_Information_Schema_Exception $e ): Throwable {
7961 switch ( $e->get_type() ) {
7962 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_TABLE_NAME:
7963 $driver_message = sprintf( "Table '%s' already exists", $e->get_data()['table_name'] );
7964 return $this->new_driver_exception(
7965 'SQLSTATE[42S01]: Base table or view already exists: 1050 ' . $driver_message,
7966 '42S01',
7967 null,
7968 array( '42S01', 1050, $driver_message )
7969 );
7970 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_COLUMN_NAME:
7971 $driver_message = sprintf( "Duplicate column name '%s'", $e->get_data()['column_name'] );
7972 return $this->new_driver_exception(
7973 'SQLSTATE[42S21]: Column already exists: 1060 ' . $driver_message,
7974 '42S21',
7975 null,
7976 array( '42S21', 1060, $driver_message )
7977 );
7978 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_KEY_NAME:
7979 $driver_message = sprintf( "Duplicate key name '%s'", $e->get_data()['key_name'] );
7980 return $this->new_driver_exception(
7981 'SQLSTATE[42000]: Syntax error or access violation: 1061 ' . $driver_message,
7982 '42000',
7983 null,
7984 array( '42000', 1061, $driver_message )
7985 );
7986 case WP_SQLite_Information_Schema_Exception::TYPE_KEY_COLUMN_NOT_FOUND:
7987 $driver_message = sprintf( "Key column '%s' doesn't exist in table", $e->get_data()['column_name'] );
7988 return $this->new_driver_exception(
7989 'SQLSTATE[42000]: Syntax error or access violation: 1072 ' . $driver_message,
7990 '42000',
7991 null,
7992 array( '42000', 1072, $driver_message )
7993 );
7994 case WP_SQLite_Information_Schema_Exception::TYPE_INVALID_DEFAULT_VALUE:
7995 $driver_message = sprintf( "Invalid default value for '%s'", $e->get_data()['column_name'] );
7996 return $this->new_driver_exception(
7997 'SQLSTATE[42000]: Syntax error or access violation: 1067 ' . $driver_message,
7998 '42000',
7999 null,
8000 array( '42000', 1067, $driver_message )
8001 );
8002 case WP_SQLite_Information_Schema_Exception::TYPE_CONSTRAINT_DOES_NOT_EXIST:
8003 $driver_message = sprintf( "Constraint '%s' does not exist.", $e->get_data()['name'] );
8004 return $this->new_driver_exception(
8005 'SQLSTATE[HY000]: General error: 3940 ' . $driver_message,
8006 'HY000',
8007 null,
8008 array( 'HY000', 3940, $driver_message )
8009 );
8010 case WP_SQLite_Information_Schema_Exception::TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME:
8011 $driver_message = sprintf(
8012 "Table has multiple constraints with the name '%s'. Please use constraint specific 'DROP' clause.",
8013 $e->get_data()['name']
8014 );
8015 return $this->new_driver_exception(
8016 'SQLSTATE[HY000]: General error: 3939 ' . $driver_message,
8017 'HY000',
8018 null,
8019 array( 'HY000', 3939, $driver_message )
8020 );
8021 default:
8022 return $e;
8023 }
8024 }
8025 }
8026