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

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

8,003 lines 280.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /*
4 * The SQLite driver uses PDO. Enable PDO function calls:
5 * phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
6 *
7 * 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::DATABASE_SYMBOL:
5025 return $this->quote_sqlite_value( $this->db_name );
5026 case WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL:
5027 case WP_MySQL_Lexer::NOW_SYMBOL:
5028 /*
5029 * 1) SQLite doesn't support CURRENT_TIMESTAMP() with parentheses.
5030 * 2) In MySQL, CURRENT_TIMESTAMP and CURRENT_TIMESTAMP() are an
5031 * alias of NOW(). In SQLite, there is no NOW() function.
5032 */
5033 return 'CURRENT_TIMESTAMP';
5034 case WP_MySQL_Lexer::DATE_ADD_SYMBOL:
5035 case WP_MySQL_Lexer::DATE_SUB_SYMBOL:
5036 $nodes = $node->get_child_nodes();
5037 $value = $this->translate( $nodes[1] );
5038 $unit = $this->translate( $nodes[2] );
5039 if ( 'WEEK' === $unit ) {
5040 $unit = 'DAY';
5041 $value = 7 * $value;
5042 }
5043 return sprintf(
5044 "DATETIME(%s, '%s' || %s || ' %s')",
5045 $this->translate( $nodes[0] ),
5046 WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id ? '-' : '+',
5047 $value,
5048 $unit
5049 );
5050 case WP_MySQL_Lexer::LEFT_SYMBOL:
5051 $nodes = $node->get_child_nodes();
5052 return sprintf(
5053 'SUBSTR(%s, 1, %s)',
5054 $this->translate( $nodes[0] ),
5055 $this->translate( $nodes[1] )
5056 );
5057 default:
5058 return $this->translate_sequence( $node->get_children() );
5059 }
5060 }
5061
5062 /**
5063 * Translate a MySQL function call to SQLite.
5064 *
5065 * @param WP_Parser_Node $node The "functionCall" AST node.
5066 * @return string The translated value.
5067 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5068 */
5069 private function translate_function_call( WP_Parser_Node $node ): string {
5070 $nodes = $node->get_child_nodes();
5071 $name = strtoupper(
5072 $this->unquote_sqlite_identifier( $this->translate( $nodes[0] ) )
5073 );
5074
5075 $args = array();
5076 if ( isset( $nodes[1] ) ) {
5077 foreach ( $nodes[1]->get_child_nodes() as $child ) {
5078 $args[] = $this->translate( $child );
5079 }
5080 }
5081
5082 switch ( $name ) {
5083 case 'RAND':
5084 /*
5085 * Unseeded RAND() compiles to a fast native SQLite expression.
5086 * In MySQL, unseeded RAND() uses the thread-level random state,
5087 * independent of any RAND(N) seeding, so we don't need PHP UDF.
5088 *
5089 * We map SQLite's RANDOM() to a float in [0, 1) by masking to
5090 * 53 bits (IEEE 754 double mantissa width) and dividing by 2^53.
5091 * This avoids the edge case where masking to 63 bits and dividing
5092 * by 2^63 could round to 1.0 due to loss of precision in double.
5093 *
5094 * Seeded RAND(N) falls through to the UDF, which implements
5095 * MySQL's deterministic LCG (Linear Congruential Generator).
5096 * The UDF also handles RAND(NULL) as RAND(0), matching MySQL.
5097 */
5098 if ( 0 === count( $args ) ) {
5099 return '((RANDOM() & ((1 << 53) - 1)) / ((1 << 53) * 1.0))';
5100 }
5101 return $this->translate_sequence( $node->get_children() );
5102 case 'DATE_FORMAT':
5103 list ( $date, $mysql_format ) = $args;
5104
5105 $format = strtr( $mysql_format, self::MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAP );
5106 if ( ! $format ) {
5107 throw $this->new_driver_exception(
5108 sprintf(
5109 'Could not translate a DATE_FORMAT() format to STRFTIME format (%s)',
5110 $mysql_format
5111 )
5112 );
5113 }
5114
5115 /*
5116 * MySQL supports comparing strings and floats, e.g.
5117 *
5118 * > SELECT '00.42' = 0.4200
5119 * 1
5120 *
5121 * SQLite does not support that. At the same time,
5122 * WordPress likes to filter dates by comparing numeric
5123 * outputs of DATE_FORMAT() to floats, e.g.:
5124 *
5125 * -- Filter by hour and minutes
5126 * DATE_FORMAT(
5127 * STR_TO_DATE('2014-10-21 00:42:29', '%Y-%m-%d %H:%i:%s'),
5128 * '%H.%i'
5129 * ) = 0.4200;
5130 *
5131 * Let's cast the STRFTIME() output to a float if
5132 * the date format is typically used for string
5133 * to float comparisons.
5134 *
5135 * In the future, let's update WordPress to avoid comparing
5136 * strings and floats.
5137 */
5138 $cast_to_float = "'%H.%i'" === $mysql_format;
5139 if ( true === $cast_to_float ) {
5140 return sprintf( 'CAST(STRFTIME(%s, %s) AS FLOAT)', $format, $date );
5141 }
5142 return sprintf( 'STRFTIME(%s, %s)', $format, $date );
5143 case 'CHAR_LENGTH':
5144 // @TODO LENGTH and CHAR_LENGTH aren't always the same in MySQL for utf8 characters.
5145 return 'LENGTH(' . $args[0] . ')';
5146 case 'CONCAT':
5147 return '(' . implode( ' || ', $args ) . ')';
5148 case 'FOUND_ROWS':
5149 $found_rows = $this->found_rows;
5150 if ( is_int( $found_rows ) ) {
5151 return $found_rows;
5152 } elseif ( is_string( $found_rows ) ) {
5153 return (int) $this->execute_sqlite_query(
5154 sprintf( 'SELECT COUNT(*) FROM (%s)', $found_rows )
5155 )->fetchColumn()[0];
5156 } elseif ( is_array( $found_rows ) && isset( $found_rows[0] ) ) {
5157 return (int) $this->execute_sqlite_query(
5158 sprintf( 'SELECT COUNT(*) FROM (%s)', $found_rows[0] ),
5159 $found_rows[1] ?? array()
5160 )->fetchColumn()[0];
5161 } else {
5162 return 0;
5163 }
5164 case 'VERSION':
5165 return $this->quote_sqlite_value( $this->get_mysql_server_version_string() );
5166 default:
5167 return $this->translate_sequence( $node->get_children() );
5168 }
5169 }
5170
5171 /**
5172 * Get the configured MySQL version in dotted notation.
5173 *
5174 * @return string The MySQL version.
5175 */
5176 private function get_mysql_version_string(): string {
5177 return sprintf(
5178 '%d.%d.%d',
5179 intdiv( $this->mysql_version, 10000 ),
5180 intdiv( $this->mysql_version % 10000, 100 ),
5181 $this->mysql_version % 100
5182 );
5183 }
5184
5185 /**
5186 * Get the raw version string of the emulated MySQL server.
5187 *
5188 * @return string The MySQL server version.
5189 */
5190 private function get_mysql_server_version_string(): string {
5191 return sprintf(
5192 '%s-mysql-on-sqlite-%s',
5193 $this->get_mysql_version_string(),
5194 SQLITE_DRIVER_VERSION
5195 );
5196 }
5197
5198 /**
5199 * Get the comment identifying the emulated MySQL server implementation.
5200 *
5201 * @return string The MySQL server version comment.
5202 */
5203 private function get_mysql_server_version_comment(): string {
5204 return 'MySQL on SQLite';
5205 }
5206
5207 /**
5208 * Translate a MySQL datetime literal to SQLite.
5209 *
5210 * @param string $value The MySQL datetime literal.
5211 * @return string The translated value.
5212 */
5213 private function translate_datetime_literal( string $value ): string {
5214 /*
5215 * Normalize MySQL date and datetime literals for SQLite.
5216 *
5217 * MySQL accepts date and time components without zero padding. SQLite
5218 * stores temporal values as text, so they must be normalized for lexical
5219 * comparisons and SQLite date functions to work correctly.
5220 *
5221 * MySQL accepts ISO 8601 date strings: 'YYYY-MM-DDTHH:MM:SSZ'
5222 * SQLite prefers a slightly different format: 'YYYY-MM-DD HH:MM:SS'
5223 *
5224 * SQLite date and time functions can understand the ISO 8601 notation, but
5225 * lookups don't. To keep the lookups working, we need to store all dates
5226 * in UTC without the "T" and "Z" characters.
5227 *
5228 * Caveat: It will adjust every string that matches the pattern, not just dates.
5229 *
5230 * In theory, we could only adjust semantic dates, e.g. the data inserted
5231 * to a date column or compared against a date column.
5232 *
5233 * In practice, this is hard because dates are just text – SQLite has no separate
5234 * datetime field. We'd need to cache the MySQL data type from the original
5235 * CREATE TABLE query and then keep refreshing the cache after each ALTER TABLE query.
5236 *
5237 * That's a lot of complexity that's perhaps not worth it. Let's just convert
5238 * everything for now. The regexp assumes "Z" is always at the end of the string,
5239 * which is true in the unit test suite, but there could also be a timezone offset
5240 * like "+00:00" or "+01:00". We could add support for that later if needed.
5241 */
5242 if (
5243 1 === preg_match(
5244 '/^(\d{4})-(\d{1,2})-(\d{1,2})(?:([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})(Z)?)?$/',
5245 $value,
5246 $matches
5247 )
5248 ) {
5249 $value = sprintf( '%04d-%02d-%02d', $matches[1], $matches[2], $matches[3] );
5250 if ( isset( $matches[4] ) && '' !== $matches[4] ) {
5251 $is_iso_8601 = 'T' === $matches[4] && 'Z' === ( $matches[8] ?? '' );
5252 $value .= sprintf(
5253 '%s%02d:%02d:%02d%s',
5254 $is_iso_8601 ? ' ' : $matches[4],
5255 $matches[5],
5256 $matches[6],
5257 $matches[7],
5258 $is_iso_8601 ? '' : ( $matches[8] ?? '' )
5259 );
5260 }
5261 }
5262
5263 /*
5264 * Mimic MySQL's behavior and truncate invalid dates.
5265 *
5266 * "2020-12-41 14:15:27" becomes "0000-00-00 00:00:00"
5267 *
5268 * WARNING: We have no idea whether the truncated value should
5269 * be treated as a date in the first place.
5270 * In SQLite dates are just strings. This could be a perfectly
5271 * valid string that just happens to contain a date-like value.
5272 *
5273 * At the same time, WordPress seems to rely on MySQL's behavior
5274 * and even tests for it in Tests_Post_wpInsertPost::test_insert_empty_post_date.
5275 * Let's truncate the dates for now.
5276 *
5277 * In the future, let's update WordPress to do its own date validation
5278 * and stop relying on this MySQL feature,
5279 */
5280 if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) {
5281 /*
5282 * Calling strtotime("0000-00-00 00:00:00") in 32-bit environments triggers
5283 * an "out of integer range" warning – let's avoid that call for the popular
5284 * case of "zero" dates.
5285 */
5286 if ( '0000-00-00 00:00:00' !== $value && false === strtotime( $value ) ) {
5287 /*
5288 * Check for dates with zero month/day parts (e.g. '2020-00-15 00:00:00').
5289 *
5290 * When the NO_ZERO_IN_DATE SQL mode is not active, MySQL accepts dates
5291 * where the year is nonzero but the month or day is zero. We must
5292 * preserve these values so that cast_value_for_saving() can handle
5293 * them correctly at the column level.
5294 *
5295 * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date
5296 */
5297 $has_zero_in_date = (
5298 ( '00' === $matches[2] || '00' === $matches[3] ) &&
5299 '0000' !== $matches[1]
5300 );
5301 if ( ! $has_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) {
5302 $value = '0000-00-00 00:00:00';
5303 }
5304 }
5305 }
5306 return $value;
5307 }
5308
5309 /**
5310 * Translate a select item to SQLite.
5311 *
5312 * In some cases, an explicit alias will be added to the select item, so that
5313 * the returned column name is always the same as it would be in MySQL.
5314 *
5315 * @param WP_Parser_Node $node The "selectItem" AST node.
5316 * @return string The translated expression.
5317 */
5318 private function translate_select_item( WP_Parser_Node $node ): string {
5319 /*
5320 * First, let's translate the select item subtree.
5321 *
5322 * [GRAMMAR]
5323 * selectItem: tableWild | (expr selectAlias?)
5324 */
5325 $item = $this->translate_sequence( $node->get_children() );
5326
5327 // A table wildcard (e.g., "SELECT *, t.*, ...") never has an alias.
5328 if ( $node->has_child_node( 'tableWild' ) ) {
5329 return $item;
5330 }
5331
5332 // When an explicit alias is provided, we can use it as is.
5333 $alias = $node->get_first_child_node( 'selectAlias' );
5334 if ( $alias ) {
5335 return $item;
5336 }
5337
5338 /*
5339 * When the select item contains only a column definition, we need to use
5340 * it without change, so that the returned column name reflects the real
5341 * column name in all cases, including when using a fully qualified name.
5342 *
5343 * For example, for "SELECT t.id", the column name in the result set will
5344 * only be "id", not "t.id", as it may appear based on the original query.
5345 *
5346 * In this case, SQLite uses the same logic as MySQL, so using the value
5347 * as is without adding an explicit alias will produce the correct result.
5348 */
5349 $column_ref = $node->get_first_descendant_node( 'columnRef' );
5350 $is_column_ref = $column_ref && $item === $this->translate( $column_ref );
5351 if ( $is_column_ref ) {
5352 return $item;
5353 }
5354
5355 /*
5356 * When the select item is a text string literal, we need to use an alias
5357 * to ensure that the column name is the same as it would be in MySQL.
5358 * In MySQL, the column name is the original text string literal value
5359 * without quotes and escaping, but in SQLite, it is the quoted value.
5360 *
5361 * For example, for "SELECT 'abc'", the resulting column name is "abc"
5362 * in MySQL, but would be "'abc'" in SQLite if an alias was not used.
5363 *
5364 * Descend the AST until we reach a textStringLiteral. If at any level
5365 * we don't have a single child node, bail out; it's not a bare literal.
5366 */
5367 $current = $node;
5368 while ( 'textStringLiteral' !== $current->rule_name ) {
5369 $children = $current->get_children();
5370 if ( 1 !== count( $children ) || ! $children[0] instanceof WP_Parser_Node ) {
5371 break;
5372 }
5373 $current = $children[0];
5374 }
5375 if ( 'textStringLiteral' === $current->rule_name ) {
5376 $alias = $current->get_first_child_token()->get_value();
5377
5378 // When the literal value contains a NULL byte, MySQL truncates the
5379 // resulting identifier at the position of the first one of them.
5380 $fist_null_byte_pos = strpos( $alias, "\0" );
5381 if ( false !== $fist_null_byte_pos ) {
5382 $alias = substr( $alias, 0, $fist_null_byte_pos );
5383 }
5384 return sprintf( '%s AS %s', $item, $this->quote_sqlite_identifier( $alias ) );
5385 }
5386
5387 /*
5388 * When the select item has no explicit alias, we need to ensure that the
5389 * returned column name is equivalent to what MySQL infers from the input.
5390 *
5391 * For example, if we translate "CONCAT('a', 'b')" to "('a' || 'b')", we
5392 * need to use the original "CONCAT('a', 'b')" string as the column name.
5393 * To achieve this, the select item will be translated as follows:
5394 *
5395 * SELECT CONCAT('a', 'b') -> SELECT ('a' || 'b') AS `CONCAT('a', 'b')`
5396 */
5397 $raw_alias = substr( $this->last_mysql_query, $node->get_start(), $node->get_length() );
5398 $alias = $this->quote_sqlite_identifier( $raw_alias );
5399 if ( $alias === $item || $raw_alias === $item ) {
5400 // For the simple case of selecting only columns ("SELECT id FROM t"),
5401 // let's avoid unnecessary aliases ("SELECT `id` AS `id` FROM t").
5402 return $item;
5403 }
5404 return sprintf( '%s AS %s', $item, $alias );
5405 }
5406
5407 /**
5408 * Translate a MySQL table reference to SQLite.
5409 *
5410 * When the table reference targets an information schema table, we replace
5411 * it with a subquery, injecting the configured database name dynamically.
5412 *
5413 * For example, the following query:
5414 *
5415 * SELECT *, t.*, t.table_schema FROM information_schema.tables t
5416 *
5417 * Will be translated to:
5418 *
5419 * SELECT *, `t`.*, `t`.`table_schema` FROM (
5420 * SELECT
5421 * `TABLE_CATALOG`,
5422 * CASE WHEN `TABLE_SCHEMA` = 'information_schema' THEN `TABLE_SCHEMA` ELSE 'database_name' END AS `TABLE_SCHEMA`,
5423 * `TABLE_NAME`,
5424 * ...
5425 * FROM `_wp_sqlite_mysql_information_schema_tables` AS `tables`
5426 * ) `t`
5427 *
5428 * The same logic will be applied to table references in JOIN clauses as well.
5429 *
5430 * @param WP_Parser_Node $node The "tableRef" AST node.
5431 * @return string The translated value.
5432 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5433 */
5434 private function translate_table_ref( WP_Parser_Node $node ): string {
5435 // The table reference is in "<schema>.<table>" or "<table>" format.
5436 $parts = $node->get_descendant_nodes( 'identifier' );
5437 $table = array_pop( $parts );
5438 $schema = array_pop( $parts );
5439
5440 $schema_name = $schema ? $this->unquote_sqlite_identifier( $this->translate( $schema ) ) : null;
5441 $table_name = $this->unquote_sqlite_identifier( $this->translate( $table ) );
5442
5443 // When the table reference targets an information schema table,
5444 // we need to inject some additional values dynamically.
5445 if (
5446 ( null === $schema_name && 'information_schema' === $this->db_name )
5447 || ( null !== $schema_name && 'information_schema' === strtolower( $schema_name ) )
5448 ) {
5449 $table_name = strtolower( $table_name );
5450
5451 // Some information schema tables can be computed on the fly.
5452 if ( 'character_sets' === $table_name || 'collations' === $table_name ) {
5453 $table_definition = $this->information_schema_builder
5454 ->get_computed_information_schema_table_definition( $table_name );
5455 if ( null !== $table_definition ) {
5456 return sprintf( '(%s)', $table_definition );
5457 }
5458 }
5459
5460 $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
5461 $sqlite_table_name = $this->information_schema_builder->get_table_name( $table_is_temporary, $table_name );
5462
5463 // We need to fetch the SQLite column information, because the information
5464 // schema tables don't contain records for the information schema itself.
5465 $columns = $this->execute_sqlite_query(
5466 'SELECT name FROM pragma_table_info(?)',
5467 array( $sqlite_table_name )
5468 )->fetchAll( PDO::FETCH_COLUMN );
5469
5470 if ( count( $columns ) === 0 ) {
5471 return $this->translate_sequence( $node->get_children() );
5472 }
5473
5474 // List all columns in the table, replacing columns targeting database
5475 // name columns with the configured database name.
5476 static $information_schema_db_column_map = array(
5477 'SCHEMA_NAME' => true,
5478 'TABLE_SCHEMA' => true,
5479 'VIEW_SCHEMA' => true,
5480 'INDEX_SCHEMA' => true,
5481 'CONSTRAINT_SCHEMA' => true,
5482 'UNIQUE_CONSTRAINT_SCHEMA' => true,
5483 'REFERENCED_TABLE_SCHEMA' => true,
5484 'TRIGGER_SCHEMA' => true,
5485 );
5486
5487 $expanded_list = array();
5488 foreach ( $columns as $column ) {
5489 $quoted_column = $this->quote_sqlite_identifier( $column );
5490 if ( isset( $information_schema_db_column_map[ $column ] ) ) {
5491 // Replace the database name with the configured database name.
5492 $expanded_list[] = sprintf(
5493 "CASE WHEN %s = 'information_schema' THEN %s ELSE %s END AS %s",
5494 $quoted_column,
5495 $quoted_column,
5496 $this->quote_sqlite_value( $this->main_db_name ),
5497 $quoted_column
5498 );
5499 } elseif ( 'tables' === $table_name && 'AUTO_INCREMENT' === $column ) {
5500 // Inject the auto-increment values.
5501 $columns_table = $this->information_schema_builder->get_table_name( false, 'columns' );
5502 $has_sequence_table = (bool) $this->execute_sqlite_query(
5503 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'"
5504 )->fetchColumn();
5505
5506 $auto_increment_subquery = sprintf(
5507 "(
5508 SELECT COALESCE(s.seq + 1, 1)
5509 FROM %s AS c
5510 %s
5511 WHERE c.extra = 'auto_increment'
5512 AND c.table_schema = %s.table_schema
5513 AND c.table_name = %s.table_name
5514 )",
5515 $this->quote_sqlite_identifier( $columns_table ),
5516 $has_sequence_table
5517 ? 'LEFT JOIN main.sqlite_sequence AS s ON s.name = c.table_name'
5518 : 'LEFT JOIN (SELECT 0 AS seq) AS s',
5519 $this->quote_sqlite_identifier( $table_name ),
5520 $this->quote_sqlite_identifier( $table_name )
5521 );
5522
5523 $expanded_list[] = sprintf( '%s AS %s', $auto_increment_subquery, $quoted_column );
5524 } else {
5525 $expanded_list[] = $quoted_column;
5526 }
5527 }
5528 $column_list = implode( ', ', $expanded_list );
5529
5530 // Compose information schema subquery.
5531 return sprintf(
5532 '(SELECT %s FROM %s AS %s)',
5533 $column_list,
5534 $this->quote_sqlite_identifier( $sqlite_table_name ),
5535 $this->quote_sqlite_identifier( $table_name )
5536 );
5537 }
5538 return $this->translate_sequence( $node->get_children() );
5539 }
5540
5541 /**
5542 * Recreate an existing table using data in the information schema.
5543 *
5544 * This is used for a generic support of ALTER TABLE queries, as well as
5545 * for some other statements like OPTIMIZE TABLE and REPAIR TABLE.
5546 *
5547 * See:
5548 * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
5549 *
5550 * @param bool $table_is_temporary Whether the table is temporary.
5551 * @param string $table_name The name of the table to recreate.
5552 * @param array $column_map Optional. A map of column names (old name -> new name)
5553 * to use when copying data from the original table.
5554 * When not provided, all columns are copied without renaming.
5555 * @throws WP_MySQL_On_SQLite_Exception
5556 */
5557 private function recreate_table_from_information_schema(
5558 bool $table_is_temporary,
5559 string $table_name,
5560 ?array $column_map = null
5561 ): void {
5562 if ( null === $column_map ) {
5563 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
5564 $column_names = $this->execute_sqlite_query(
5565 sprintf(
5566 'SELECT COLUMN_NAME FROM %s WHERE table_schema = ? AND table_name = ?',
5567 $this->quote_sqlite_identifier( $columns_table )
5568 ),
5569 array( $this->get_saved_db_name(), $table_name )
5570 )->fetchAll( PDO::FETCH_COLUMN );
5571 $column_map = array_combine( $column_names, $column_names );
5572 }
5573
5574 // Preserve ROWIDs.
5575 // This also addresses a special case when all original columns are dropped
5576 // and there is nothing to copy. We'll always have at least the ROWID column.
5577 $column_map = array( 'rowid' => 'rowid' ) + $column_map;
5578
5579 /*
5580 * See:
5581 * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
5582 */
5583
5584 // 1. If foreign key constraints are enabled, disable them.
5585 $pragma_foreign_keys = $this->execute_sqlite_query( 'PRAGMA foreign_keys' )->fetchColumn();
5586 $this->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' );
5587
5588 // 2. Create a new table with the new schema.
5589 $tmp_table_name = self::RESERVED_PREFIX . "tmp_{$table_name}_" . uniqid();
5590 $quoted_table_name = $this->quote_sqlite_identifier( $table_name );
5591 $quoted_tmp_table_name = $this->quote_sqlite_identifier( $tmp_table_name );
5592 $queries = $this->get_sqlite_create_table_statement( $table_is_temporary, $table_name, $tmp_table_name );
5593 $create_table_query = $queries[0];
5594 $constraint_queries = array_slice( $queries, 1 );
5595 $this->execute_sqlite_query( $create_table_query );
5596
5597 // 3. Copy data from the original table to the new table.
5598 $this->execute_sqlite_query(
5599 sprintf(
5600 'INSERT INTO %s (%s) SELECT %s FROM %s',
5601 $quoted_tmp_table_name,
5602 implode(
5603 ', ',
5604 array_map( array( $this, 'quote_sqlite_identifier' ), $column_map )
5605 ),
5606 implode(
5607 ', ',
5608 array_map( array( $this, 'quote_sqlite_identifier' ), array_keys( $column_map ) )
5609 ),
5610 $quoted_table_name
5611 )
5612 );
5613
5614 // 4. Drop the original table.
5615 $this->execute_sqlite_query( sprintf( 'DROP TABLE %s', $quoted_table_name ) );
5616
5617 // 5. Rename the new table to the original table name.
5618 $this->execute_sqlite_query(
5619 sprintf(
5620 'ALTER TABLE %s RENAME TO %s',
5621 $quoted_tmp_table_name,
5622 $quoted_table_name
5623 )
5624 );
5625
5626 // 6. Reconstruct indexes, triggers, and views.
5627 foreach ( $constraint_queries as $query ) {
5628 $this->execute_sqlite_query( $query );
5629 }
5630
5631 // 7. If foreign key constraints were enabled, verify and enable them.
5632 if ( '1' === $pragma_foreign_keys ) {
5633 $this->execute_sqlite_query( 'PRAGMA foreign_key_check' );
5634 $this->execute_sqlite_query( 'PRAGMA foreign_keys = ON' );
5635 }
5636
5637 // @TODO: Triggers and views.
5638 }
5639
5640 /**
5641 * Apply the AUTO_INCREMENT table option from a CREATE TABLE or ALTER TABLE
5642 * statement by adjusting the row in SQLite's "sqlite_sequence" table.
5643 *
5644 * @param bool $table_is_temporary Whether the table is temporary.
5645 * @param string $table_name The table name.
5646 * @param WP_Parser_Node $node The "createStatement" or "alterStatement" AST node.
5647 */
5648 private function apply_auto_increment_table_option(
5649 bool $table_is_temporary,
5650 string $table_name,
5651 WP_Parser_Node $node
5652 ): void {
5653 // Find the last AUTO_INCREMENT = N option (MySQL uses the last one).
5654 $value = null;
5655 foreach ( $node->get_descendant_nodes( 'createTableOption' ) as $option ) {
5656 if ( ! $option->has_child_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) {
5657 continue;
5658 }
5659 $number_node = $option->get_first_child_node( 'ulonglong_number' );
5660 if ( null === $number_node ) {
5661 continue;
5662 }
5663 $value = (int) $number_node->get_first_descendant_token()->get_value();
5664 }
5665 if ( null === $value ) {
5666 return;
5667 }
5668
5669 // Find the AUTO_INCREMENT column.
5670 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
5671 $auto_column = $this->execute_sqlite_query(
5672 sprintf(
5673 "SELECT column_name FROM %s
5674 WHERE table_schema = ?
5675 AND table_name = ?
5676 AND extra = 'auto_increment'",
5677 $this->quote_sqlite_identifier( $columns_table )
5678 ),
5679 array( $this->get_saved_db_name(), $table_name )
5680 )->fetchColumn();
5681 if ( false === $auto_column ) {
5682 return;
5683 }
5684
5685 /*
5686 * Prepare an expression for the sequence value.
5687 * 1. Use N - 1. MySQL stores the next value, SQLite the last one.
5688 * 2. Clamp to MAX(col) like MySQL (we can't go below existing values).
5689 *
5690 * The value is inlined as an integer literal because PDO binds PHP ints
5691 * as TEXT, and SQLite's type affinity ranks TEXT above INTEGER in MAX().
5692 */
5693 $schema = $table_is_temporary ? 'temp' : 'main';
5694 $seq_expr = sprintf(
5695 'MAX(%d, COALESCE((SELECT MAX(%s) FROM %s), 0))',
5696 $value - 1,
5697 $this->quote_sqlite_identifier( $auto_column ),
5698 $this->quote_sqlite_identifier( $table_name )
5699 );
5700
5701 // Update the value in the "sqlite_sequence" table.
5702 $updated = $this->execute_sqlite_query(
5703 sprintf( 'UPDATE %s.sqlite_sequence SET seq = %s WHERE name = ?', $schema, $seq_expr ),
5704 array( $table_name )
5705 )->rowCount();
5706
5707 // If the sequence value does not exist yet, insert a new row.
5708 // SQLite reports matched (not affected) rows, so the 0 check is safe.
5709 if ( 0 === $updated ) {
5710 $this->execute_sqlite_query(
5711 sprintf( 'INSERT INTO %s.sqlite_sequence (name, seq) VALUES (?, %s)', $schema, $seq_expr ),
5712 array( $table_name )
5713 );
5714 }
5715 }
5716
5717 /**
5718 * Translate a MySQL SHOW LIKE ... or SHOW WHERE ... condition to SQLite.
5719 *
5720 * @param WP_Parser_Node $like_or_where The "likeOrWhere" AST node.
5721 * @param string $like_column The column name to use in the LIKE clause ("table_name", "column_name", etc.).
5722 * @return string The translated value.
5723 * @throws WP_MySQL_On_SQLite_Exception When the translation fails.
5724 */
5725 private function translate_show_like_or_where_condition( WP_Parser_Node $like_or_where, string $like_column ): string {
5726 $like_clause = $like_or_where->get_first_child_node( 'likeClause' );
5727 if ( null !== $like_clause ) {
5728 $value = $this->translate(
5729 $like_clause->get_first_child_node( 'textStringLiteral' )
5730 );
5731 return sprintf(
5732 "AND %s LIKE %s ESCAPE '\\'",
5733 $this->quote_sqlite_identifier( $like_column ),
5734 $value
5735 );
5736 }
5737
5738 $where_clause = $like_or_where->get_first_child_node( 'whereClause' );
5739 if ( null !== $where_clause ) {
5740 $value = $this->translate(
5741 $where_clause->get_first_child_node( 'expr' )
5742 );
5743 return sprintf( 'AND %s', $value );
5744 }
5745
5746 return '';
5747 }
5748
5749 /**
5750 * Translate INSERT or REPLACE statement body to SQLite, while emulating
5751 * MySQL column type casting and implicit default values when saving data.
5752 *
5753 * This method rewrites an INSERT or REPLACE statement body from:
5754 * INSERT INTO table (optionally some columns) <select-or-values>
5755 * To a statement body with the following structure:
5756 * INSERT INTO table (table columns)
5757 * SELECT <adjusted-values> FROM (<select-or-values>) WHERE true
5758 *
5759 * In MySQL, the behavior of INSERT and UPDATE statements depends on whether
5760 * the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
5761 *
5762 * This method applies relevant type casting and emulates IMPLICIT DEFAULT
5763 * value behavior as follows:
5764 * 1. In STRICT mode:
5765 * - Apply relevant type casting based on the column data type.
5766 * 2. In non-STRICT mode:
5767 * - Apply relevant type casting based on the column data type.
5768 * - Replace invalid values with IMPLICIT DEFAULTs.
5769 * - Replace missing values without defaults with IMPLICIT DEFAULTs.
5770 *
5771 * The strict SQL modes can be set per session, and can be changed at runtime.
5772 * In SQLite, we can emulate this using the knowledge of the table structure.
5773 *
5774 * -----
5775 *
5776 * Here's a summary of the strict vs. non-strict IMPLICIT DEFAULT behavior:
5777 *
5778 * When STRICT_TRANS_TABLES or STRICT_ALL_TABLES is enabled:
5779 * 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
5780 * 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
5781 * 3. NOT NULL + NO DEFAULT: No value is rejected, NULL is rejected, DEFAULT is rejected.
5782 * 4. NOT NULL + DEFAULT: No value saves DEFAULT, NULL is rejected, DEFAULT saves DEFAULT.
5783 *
5784 * When STRICT_TRANS_TABLES and STRICT_ALL_TABLES are disabled:
5785 * 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
5786 * 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
5787 * 3. NOT NULL + NO DEFAULT: No value saves IMPLICIT DEFAULT.
5788 * NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
5789 * DEFAULT saves IMPLICIT DEFAULT.
5790 * 4. NOT NULL + DEFAULT: No value saves DEFAULT.
5791 * NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
5792 * DEFAULT saves DEFAULT.
5793 *
5794 * For more information about STRICT mode in MySQL, see:
5795 * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-strict
5796 *
5797 * For more information about IMPLICIT DEFAULT values in MySQL, see:
5798 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
5799 *
5800 * @param string $table_name The name of the target table.
5801 * @param WP_Parser_Node $node The "insertQueryExpression" or "insertValues" AST node.
5802 * @return string The translated INSERT query body.
5803 */
5804 private function translate_insert_or_replace_body(
5805 string $table_name,
5806 WP_Parser_Node $node
5807 ): string {
5808 // This method is always used with the main database.
5809 $database = $this->get_saved_db_name( $this->main_db_name );
5810
5811 // Check if strict mode is enabled.
5812 $is_strict_mode = (
5813 $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
5814 || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
5815 );
5816
5817 // Get column metadata for the target table from the information schema.
5818 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
5819 $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
5820 $columns = $this->execute_sqlite_query(
5821 '
5822 SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, extra
5823 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
5824 WHERE table_schema = ?
5825 AND table_name = ?
5826 ORDER BY ordinal_position
5827 ',
5828 array( $database, $table_name )
5829 )->fetchAll( PDO::FETCH_ASSOC );
5830
5831 // Check if the table exists.
5832 if ( 0 === count( $columns ) ) {
5833 throw $this->new_table_not_found_exception( $table_name );
5834 }
5835
5836 // Get a list of columns that are targeted by the INSERT or REPLACE query.
5837 // This is either an explicit column list, or all columns of the table.
5838 $insert_list = array();
5839 $fields_node = $node->get_first_child_node( 'fields' );
5840 if ( $fields_node ) {
5841 // "INSERT INTO ... (column1, column2, ...)"
5842 foreach ( $fields_node->get_child_nodes() as $field ) {
5843 $column_name = $this->unquote_sqlite_identifier( $this->translate( $field ) );
5844 $insert_list[] = strtolower( $column_name );
5845 }
5846 } elseif ( 'updateList' === $node->rule_name ) {
5847 // "INSERT INTO ... SET column1 = value1, column2 = value2, ..."
5848 foreach ( $node->get_child_nodes( 'updateElement' ) as $update_element ) {
5849 $column_ref = $update_element->get_first_child_node( 'columnRef' );
5850 $column_name = $this->unquote_sqlite_identifier( $this->translate( $column_ref ) );
5851 $insert_list[] = strtolower( $column_name );
5852 }
5853 } else {
5854 // "INSERT INTO ... VALUES(...)" or "INSERT INTO ... SELECT ..."
5855 // No explicit column list is provided; we need to list all columns.
5856 foreach ( array_column( $columns, 'COLUMN_NAME' ) as $column_name ) {
5857 $insert_list[] = strtolower( $column_name );
5858 }
5859 }
5860
5861 // Check if all listed columns exist.
5862 $unknown_columns = array_diff( $insert_list, array_column( $columns, 'COLUMN_NAME' ) );
5863 if ( count( $unknown_columns ) > 0 ) {
5864 throw $this->new_driver_exception(
5865 sprintf(
5866 "SQLSTATE[42S22]: Column not found: 1054 Unknown column '%s' in 'field list'",
5867 $unknown_columns[0]
5868 ),
5869 '42S22'
5870 );
5871 }
5872
5873 // Prepare a helper map of columns that are included in the INSERT list.
5874 $insert_map = array_combine( $insert_list, $insert_list );
5875
5876 /*
5877 * Filter out columns that were omitted in the INSERT list:
5878 * 1. In strict mode, filter out all omitted columns.
5879 * 2. In non-strict mode, filter out omitted columns that will get a
5880 * value from the SQLite engine. That is, nullable columns, columns
5881 * with defaults, and generated columns.
5882 */
5883 $columns = array_values(
5884 array_filter(
5885 $columns,
5886 function ( $column ) use ( $is_strict_mode, $insert_map ) {
5887 $is_omitted = ! isset( $insert_map[ $column['COLUMN_NAME'] ] );
5888 if ( ! $is_omitted ) {
5889 return true;
5890 }
5891 if ( $is_strict_mode ) {
5892 return false;
5893 }
5894 $is_nullable = 'YES' === $column['IS_NULLABLE'];
5895 $has_default = $column['COLUMN_DEFAULT'];
5896 $is_generated = str_contains( $column['EXTRA'], 'auto_increment' );
5897 return ! ( $is_nullable || $has_default || $is_generated );
5898 }
5899 )
5900 );
5901
5902 /*
5903 * Get a list of column names for the INSERT or REPLACE values clause.
5904 * These are the columns that will be used in a SELECT statement when
5905 * the values clause is wrapped in a subquery:
5906 *
5907 * INSERT INTO ... SELECT <select-list> FROM (<values-from-original-query>)
5908 */
5909 $select_list = array();
5910 if ( 'insertQueryExpression' === $node->rule_name ) {
5911 // When inserting from a SELECT query, we don't know the column names.
5912 // Let's wrap the query with a "SELECT (...) LIMIT 0" to obtain them.
5913 $expr = $node->get_first_child_node( 'queryExpressionOrParens' );
5914 $stmt = $this->execute_sqlite_query(
5915 'SELECT * FROM (' . $this->translate( $expr ) . ') LIMIT 1'
5916 );
5917 $stmt->execute();
5918
5919 for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
5920 /*
5921 * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3.
5922 * See also: https://github.com/php/php-src/pull/5654
5923 */
5924 if ( PHP_VERSION_ID < 70300 ) {
5925 try {
5926 $column_meta = $stmt->getColumnMeta( $i );
5927 } catch ( Throwable $e ) {
5928 $column_meta = false;
5929 }
5930 if ( false === $column_meta ) {
5931 // Due to a PDO bug in PHP < 7.3, we get no column metadata
5932 // when no rows are returned. In that case, no data will be
5933 // inserted, so we can bail out using a simple translation.
5934 return $this->translate( $node );
5935 }
5936 }
5937 $select_list[] = $stmt->getColumnMeta( $i )['name'];
5938 }
5939 } else {
5940 // When inserting from a VALUES list, SQLite uses a "columnN" naming.
5941 // This also applies to the SET syntax, which is converted to VALUES.
5942 foreach ( array_keys( $insert_list ) as $position ) {
5943 $select_list[] = 'column' . ( $position + 1 );
5944 }
5945 }
5946
5947 // Compose a new INSERT column list with all columns from the table.
5948 $fragment = '(';
5949 foreach ( $columns as $i => $column ) {
5950 $fragment .= $i > 0 ? ', ' : '';
5951 $fragment .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
5952 }
5953 $fragment .= ')';
5954
5955 // Compose a wrapper SELECT statement emulating MySQL-like type casting,
5956 // and, in non-strict mode, IMPLICIT DEFAULT values for omitted columns.
5957 $fragment .= ' SELECT ';
5958 foreach ( $columns as $i => $column ) {
5959 $is_omitted = ! isset( $insert_map[ $column['COLUMN_NAME'] ] );
5960 $fragment .= $i > 0 ? ', ' : '';
5961 if ( $is_omitted ) {
5962 /*
5963 * This path only applies to non-strict mode. In strict mode,
5964 * omitted columns get no IMPLICIT DEFAULT values, and they were
5965 * previously filtered out from the columns list.
5966 *
5967 * When a column is omitted from the INSERT list, we need to use
5968 * an IMPLICIT DEFAULT value. Note that at this point, all omitted
5969 * columns that will not get an implicit default are filtered out.
5970 * (That is, nullable, generated, and columns with true defaults.)
5971 */
5972 $default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null;
5973 $fragment .= null === $default ? 'NULL' : $this->quote_sqlite_value( $default );
5974 } else {
5975 // When a column value is included, we need to apply type casting.
5976 $position = array_search( $column['COLUMN_NAME'], $insert_list, true );
5977 $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] );
5978 $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier );
5979 $is_auto_increment = str_contains( $column['EXTRA'], 'auto_increment' );
5980
5981 /*
5982 * In MySQL, inserting 0 into an AUTO_INCREMENT column increments
5983 * the sequence, unless the NO_AUTO_VALUE_ON_ZERO SQL mode is set.
5984 *
5985 * In SQLite, we need to rewrite 0 to NULL to advance the sequence.
5986 * The value is cast to INTEGER before the comparison, because
5987 * SQLite treats values of different types as unequal (0 != '0').
5988 *
5989 * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_auto_value_on_zero
5990 */
5991 if ( $is_auto_increment && ! $this->is_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) ) {
5992 $value = sprintf( 'NULLIF(CAST(%s AS INTEGER), 0)', $value );
5993 }
5994
5995 /*
5996 * In MySQL non-STRICT mode, when inserting from a SELECT query:
5997 *
5998 * When a column is declared as NOT NULL, inserting a NULL value
5999 * saves an IMPLICIT DEFAULT value instead. This behavior only
6000 * applies to the INSERT ... SELECT syntax (not VALUES or SET).
6001 *
6002 * AUTO_INCREMENT columns are excluded. A NULL value advances
6003 * the sequence regardless of the column's nullability.
6004 */
6005 $is_insert_from_select = 'insertQueryExpression' === $node->rule_name;
6006 if (
6007 ! $is_strict_mode
6008 && ! $is_auto_increment
6009 && $is_insert_from_select
6010 && 'NO' === $column['IS_NULLABLE']
6011 ) {
6012 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null;
6013 if ( null !== $implicit_default ) {
6014 $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) );
6015 }
6016 }
6017 $fragment .= $value;
6018 }
6019 }
6020
6021 // Wrap the original insert VALUES, SELECT, or SET list in a FROM clause.
6022 if ( 'insertFromConstructor' === $node->rule_name ) {
6023 // VALUES (...)
6024 $insert_values = $node->get_first_child_node( 'insertValues' );
6025 $from = $this->translate( $insert_values );
6026
6027 /**
6028 * The automatic "columnN" naming for VALUES lists is supported only
6029 * from SQLite 3.33.0. For older versions, we need to emulate it by
6030 * prepending a dummy VALUES list header via the UNION ALL operator:
6031 *
6032 * SELECT
6033 * NULL AS `column1`, NULL AS `column2`, ... WHERE FALSE
6034 * UNION ALL
6035 * VALUES (value1, value2, ...)
6036 */
6037 $is_values_naming_supported = version_compare( $this->get_sqlite_version(), '3.33.0', '>=' );
6038 if ( ! $is_values_naming_supported ) {
6039 $values_list = $insert_values->get_first_child_node( 'valueList' );
6040 $values = $values_list->get_first_child_node( 'values' );
6041 $value_count = (
6042 count( $values->get_child_nodes( 'expr' ) )
6043 + count( $values->get_child_nodes( WP_MySQL_Lexer::DEFAULT_SYMBOL ) )
6044 );
6045
6046 $columns_list = '';
6047 for ( $i = 1; $i <= $value_count; $i++ ) {
6048 $columns_list .= $i > 1 ? ', ' : '';
6049 $columns_list .= 'NULL AS ' . $this->quote_sqlite_identifier( 'column' . $i );
6050 }
6051 $from = 'SELECT ' . $columns_list . ' WHERE FALSE UNION ALL ' . $from;
6052 }
6053 } elseif ( 'insertQueryExpression' === $node->rule_name ) {
6054 // SELECT ...
6055 $from = $this->translate(
6056 $node->get_first_child_node( 'queryExpressionOrParens' )
6057 );
6058 } else {
6059 // SET c1 = v1, c2 = v2, ...
6060 $values = array();
6061 foreach ( $node->get_child_nodes( 'updateElement' ) as $update_element ) {
6062 $values[] = $this->translate( $update_element->get_first_child_node( 'expr' ) );
6063 }
6064 $from = 'VALUES (' . implode( ', ', $values ) . ')';
6065 }
6066
6067 /*
6068 * The "WHERE true" suffix is used to avoid parsing ambiguity in SQLite.
6069 * When an "ON CONFLICT" clause is used and there is no "WHERE", SQLite
6070 * doesn't know if "ON" belongs to a "JOIN" or an "ON CONFLICT" clause.
6071 *
6072 * See: https://www.sqlite.org/lang_insert.html
6073 */
6074 $fragment .= ' FROM (' . $from . ') WHERE true';
6075
6076 return $fragment;
6077 }
6078
6079 /**
6080 * Translate UPDATE statement SET value list to SQLite, while emulating
6081 * MySQL column type casting and implicit default values when saving data.
6082 *
6083 * Rewrites an UPDATE statement list in the following form:
6084 * UPDATE table SET <column> = <value>
6085 * To a list with the following structure:
6086 * UPDATE table SET <column> = <adjusted-value>
6087 *
6088 * In MySQL, the behavior of INSERT and UPDATE statements depends on whether
6089 * the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
6090 *
6091 * This method applies relevant type casting and emulates IMPLICIT DEFAULT
6092 * value behavior as follows:
6093 * 1. In STRICT mode:
6094 * - Apply relevant type casting based on the column data type.
6095 * 2. In NON-STRICT mode:
6096 * - Apply relevant type casting based on the column data type.
6097 * - Replace invalid values with IMPLICIT DEFAULTs.
6098 * - Replace NULL values without defaults with IMPLICIT DEFAULTs.
6099 * (Updating a NOT NULL column to NULL saves as an IMPLICIT DEFAULT.)
6100 *
6101 * The strict SQL modes can be set per session, and can be changed at runtime.
6102 * In SQLite, we can emulate this using the knowledge of the table structure.
6103 *
6104 * For more information about STRICT mode in MySQL, see:
6105 * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-strict
6106 *
6107 * For more information about IMPLICIT DEFAULT values in MySQL, see:
6108 * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
6109 *
6110 * @param string $table_name The name of the target table.
6111 * @param WP_Parser_Node $parent_node The "updateList" AST node parent node.
6112 * @return string The translated UPDATE list.
6113 */
6114 private function translate_update_list( string $table_name, WP_Parser_Node $parent_node ): string {
6115 $node = $parent_node->get_first_child_node( 'updateList' );
6116
6117 // This method is always used with the main database.
6118 $database = $this->get_saved_db_name( $this->main_db_name );
6119
6120 // Check if strict mode is enabled.
6121 $is_strict_mode = (
6122 $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
6123 || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
6124 );
6125
6126 // Get column metadata from the information schema.
6127 $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
6128 $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
6129 $columns = $this->execute_sqlite_query(
6130 '
6131 SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, column_default
6132 FROM ' . $this->quote_sqlite_identifier( $columns_table ) . '
6133 WHERE table_schema = ?
6134 AND table_name = ?
6135 ',
6136 array( $database, $table_name )
6137 )->fetchAll( PDO::FETCH_ASSOC );
6138
6139 // Check if the table exists.
6140 if ( 0 === count( $columns ) ) {
6141 throw $this->new_table_not_found_exception( $table_name );
6142 }
6143
6144 $column_map = array_combine( array_column( $columns, 'COLUMN_NAME' ), $columns );
6145
6146 // Translate the UPDATE list, emulating IMPLICIT DEFAULTs for NULL values.
6147 $fragment = '';
6148 foreach ( $node->get_child_nodes() as $i => $update_element ) {
6149 $column_ref = $update_element->get_first_child_node( 'columnRef' );
6150 $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' );
6151 $expr = $update_element->get_first_child_node( 'expr' );
6152
6153 // Get column info.
6154 $column_name = $this->unquote_sqlite_identifier( $this->translate( end( $column_ref_parts ) ) );
6155 $column_info = $column_map[ strtolower( $column_name ) ] ?? null;
6156 if ( ! $column_info ) {
6157 throw $this->new_driver_exception(
6158 sprintf(
6159 "SQLSTATE[42S22]: Column not found: 1054 Unknown column '%s' in 'field list'",
6160 $column_name
6161 ),
6162 '42S22'
6163 );
6164 }
6165
6166 $data_type = $column_info['DATA_TYPE'];
6167 $is_nullable = 'YES' === $column_info['IS_NULLABLE'];
6168 $default = $column_info['COLUMN_DEFAULT'];
6169
6170 // Get the UPDATE value. It's either an expression or a DEFAULT keyword.
6171 if ( null === $expr ) {
6172 // Emulate "column = DEFAULT".
6173 $value = null === $default ? 'NULL' : $this->format_sqlite_column_default( $column_info );
6174 } else {
6175 $value = $this->translate( $expr );
6176 }
6177
6178 // Apply type casting.
6179 $value = $this->cast_value_for_saving( $data_type, $value );
6180
6181 /*
6182 * In MySQL non-STRICT mode, when a column is declared as NOT NULL,
6183 * updating to a NULL value saves an IMPLICIT DEFAULT value instead.
6184 * This behavior does not apply to ON DUPLICATE KEY UPDATE clauses.
6185 */
6186 $is_on_duplicate_key_update = 'insertUpdateList' === $parent_node->rule_name;
6187 if ( ! $is_strict_mode && ! $is_nullable && ! $is_on_duplicate_key_update ) {
6188 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $data_type ] ?? null;
6189 if ( null !== $implicit_default ) {
6190 $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) );
6191 }
6192 }
6193
6194 // Compose the UPDATE list item.
6195 $fragment .= $i > 0 ? ', ' : '';
6196 $fragment .= $this->translate( end( $column_ref_parts ) );
6197 $fragment .= ' = ';
6198 $fragment .= $value;
6199 }
6200 return $fragment;
6201 }
6202
6203 /**
6204 * Get the default SQL modes for the emulated MySQL version.
6205 *
6206 * @return string[] Default SQL mode names.
6207 */
6208 private function get_default_sql_modes(): array {
6209 $sql_modes = self::DEFAULT_SQL_MODES;
6210 if ( $this->mysql_version < 80000 ) {
6211 $sql_modes[] = 'NO_AUTO_CREATE_USER';
6212 }
6213
6214 return $sql_modes;
6215 }
6216
6217 /**
6218 * Set the active SQL modes from a name list or numeric bitmask.
6219 *
6220 * @param string[]|int $modes SQL mode names or a numeric bitmask.
6221 */
6222 private function set_sql_modes( $modes ): void {
6223 if ( null === $modes ) {
6224 throw $this->new_invalid_sql_mode_exception( $modes );
6225 }
6226
6227 $known_sql_modes_mask = array_sum( self::SQL_MODES );
6228
6229 // TIME_TRUNCATE_FRACTIONAL was introduced in MySQL 8.0.1.
6230 if ( $this->mysql_version < 80001 ) {
6231 $known_sql_modes_mask &= ~self::SQL_MODES['TIME_TRUNCATE_FRACTIONAL'];
6232 }
6233
6234 // Numeric SQL mode bitmap assignment (e.g., "SET sql_mode = 4" for ANSI_QUOTES).
6235 if ( is_int( $modes ) ) {
6236 // Ensure the bitmap contains only SQL mode bits known to the emulated MySQL version.
6237 if ( $modes < 0 || ( $modes & ~$known_sql_modes_mask ) !== 0 ) {
6238 throw $this->new_invalid_sql_mode_exception( $modes );
6239 }
6240
6241 // Reject recognized but no longer supported SQL modes.
6242 $unsupported_sql_modes = 0;
6243 foreach ( self::SQL_MODES as $mode => $value ) {
6244 if ( ( $modes & $value ) !== 0 && $this->is_sql_mode_removed( $mode ) ) {
6245 $unsupported_sql_modes |= $value;
6246 }
6247 }
6248
6249 if ( 0 !== $unsupported_sql_modes ) {
6250 throw $this->new_driver_exception(
6251 sprintf(
6252 'SQLSTATE[HY000]: General error: 3899 sql_mode=0x%08x is not supported.',
6253 $unsupported_sql_modes
6254 ),
6255 'HY000'
6256 );
6257 }
6258
6259 $sql_modes = $modes;
6260 } elseif ( is_array( $modes ) ) {
6261 // String SQL mode assignment (e.g., "SET sql_mode = 'ANSI_QUOTES,STRICT_TRANS_TABLES'").
6262 $sql_modes = 0;
6263 foreach ( $modes as $mode ) {
6264 if ( '' === $mode ) {
6265 continue;
6266 }
6267
6268 $normalized_mode = strtoupper( $mode );
6269 $mode_value = self::SQL_MODES[ $normalized_mode ] ?? 0;
6270 if (
6271 0 === ( $mode_value & $known_sql_modes_mask )
6272 || ( 'NOT_USED' === $normalized_mode && $this->mysql_version < 80000 )
6273 || $this->is_sql_mode_removed( $normalized_mode )
6274 ) {
6275 throw $this->new_invalid_sql_mode_exception( $mode );
6276 }
6277
6278 $sql_modes |= $mode_value;
6279 }
6280 } else {
6281 throw $this->new_driver_exception(
6282 "SQLSTATE[42000]: Syntax error or access violation: 1232 Incorrect argument type to variable 'sql_mode'",
6283 '42000'
6284 );
6285 }
6286
6287 if ( ( $sql_modes & self::SQL_MODES['NO_BACKSLASH_ESCAPES'] ) !== 0 ) {
6288 throw $this->new_not_supported_exception( "SQL mode 'NO_BACKSLASH_ESCAPES'" );
6289 }
6290
6291 /*
6292 * MySQL retains composite SQL modes while enabling their component modes.
6293 * Store both so "@@sql_mode" and individual mode checks match MySQL, even
6294 * though not all resulting modes are respected by the emulation yet.
6295 *
6296 * See:
6297 * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-combo
6298 * https://github.com/mysql/mysql-server/blob/8.4/sql/sys_vars.cc
6299 */
6300 if ( ( $sql_modes & self::SQL_MODES['ANSI'] ) !== 0 ) {
6301 $sql_modes |= self::SQL_MODES['REAL_AS_FLOAT']
6302 | self::SQL_MODES['PIPES_AS_CONCAT']
6303 | self::SQL_MODES['ANSI_QUOTES']
6304 | self::SQL_MODES['IGNORE_SPACE']
6305 | self::SQL_MODES['ONLY_FULL_GROUP_BY'];
6306 }
6307
6308 $this->active_sql_modes = $sql_modes;
6309 }
6310
6311 /**
6312 * Check whether an SQL mode was removed from the emulated MySQL version.
6313 *
6314 * @param string $mode Normalized SQL mode name.
6315 * @return bool Whether the SQL mode was removed.
6316 */
6317 private function is_sql_mode_removed( string $mode ): bool {
6318 /*
6319 * MySQL still recognizes the legacy bits for modes removed in 8.0.11
6320 * so it can report them as unsupported, rather than unknown.
6321 */
6322 return $this->mysql_version >= 80011
6323 && in_array(
6324 $mode,
6325 array(
6326 'POSTGRESQL',
6327 'ORACLE',
6328 'MSSQL',
6329 'DB2',
6330 'MAXDB',
6331 'NO_KEY_OPTIONS',
6332 'NO_TABLE_OPTIONS',
6333 'NO_FIELD_OPTIONS',
6334 'MYSQL323',
6335 'MYSQL40',
6336 'NO_AUTO_CREATE_USER',
6337 ),
6338 true
6339 );
6340 }
6341
6342 /**
6343 * Get the active SQL mode names in canonical bitmask order.
6344 *
6345 * @return string[] Active SQL mode names.
6346 */
6347 private function get_active_sql_mode_names(): array {
6348 $active_modes = array();
6349 foreach ( self::SQL_MODES as $mode => $value ) {
6350 if ( ( $this->active_sql_modes & $value ) !== 0 ) {
6351 if ( 'NOT_USED' === $mode && $this->mysql_version < 80000 ) {
6352 /*
6353 * MySQL 5.7 represents reserved bit 4 with a comma in its mode
6354 * name table. Two empty components preserve that serialization
6355 * when the final list is joined with commas.
6356 */
6357 $active_modes[] = '';
6358 $active_modes[] = '';
6359 } else {
6360 $active_modes[] = $mode;
6361 }
6362 }
6363 }
6364
6365 return $active_modes;
6366 }
6367
6368 /**
6369 * Store column metadata for the last SQLite statement.
6370 *
6371 * This function stores the original SQLite column metadata as-is, without
6372 * converting it into MySQL column metadata. That is done only when needed.
6373 *
6374 * @param PDOStatement $stmt The PDOStatement object containing the SQLite column metadata.
6375 */
6376 private function store_last_column_meta_from_statement( PDOStatement $stmt ): void {
6377 $this->last_column_meta = array();
6378 for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
6379 /*
6380 * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3.
6381 * See also: https://github.com/php/php-src/pull/5654
6382 */
6383 if ( PHP_VERSION_ID < 70300 ) {
6384 try {
6385 $this->last_column_meta[] = $stmt->getColumnMeta( $i );
6386 } catch ( Throwable $e ) {
6387 $this->last_column_meta[] = array(
6388 'native_type' => 'null',
6389 'pdo_type' => PDO::PARAM_NULL,
6390 'flags' => array(),
6391 'table' => '',
6392 'name' => '',
6393 'len' => -1,
6394 'precision' => 0,
6395 );
6396 }
6397 continue;
6398 }
6399
6400 $this->last_column_meta[] = $stmt->getColumnMeta( $i );
6401 }
6402 }
6403
6404 /**
6405 * Unnest parenthesized MySQL expression node.
6406 *
6407 * In MySQL, extra parentheses around simple expressions are not considered.
6408 *
6409 * For example, the "SELECT (((id)))" clause is equivalent to "SELECT id".
6410 * This means that the "(((id)))" part will behave as a column name rather
6411 * than as an expression, and the resulting column name will be just "id".
6412 *
6413 * @param WP_Parser_Node $node The expression AST node.
6414 * @return WP_Parser_Node The unnested expression.
6415 */
6416 private function unnest_parenthesized_expression( WP_Parser_Node $node ): WP_Parser_Node {
6417 $children = $node->get_children();
6418
6419 // Descend the "expr -> boolPri -> predicate -> bitExpr -> simpleExpr" -> "simpleExprBody"
6420 // tree, when on each level we have only a single child node (expression nesting).
6421 if (
6422 1 === count( $children )
6423 && $children[0] instanceof WP_Parser_Node
6424 && in_array( $children[0]->rule_name, array( 'expr', 'boolPri', 'predicate', 'bitExpr', 'simpleExpr', 'simpleExprBody' ), true )
6425 ) {
6426 $unnested = $this->unnest_parenthesized_expression( $children[0] );
6427 return $unnested === $children[0] ? $node : $unnested;
6428 }
6429
6430 // Unnest "OPEN_PAR_SYMBOL exprList CLOSE_PAR_SYMBOL" to "exprList".
6431 if (
6432 count( $children ) === 3
6433 && $children[0] instanceof WP_MySQL_Token && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $children[0]->id
6434 && $children[1] instanceof WP_Parser_Node && 'exprList' === $children[1]->rule_name
6435 && $children[2] instanceof WP_MySQL_Token && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $children[2]->id
6436 && 1 === count( $children[1]->get_children() )
6437 ) {
6438 return $this->unnest_parenthesized_expression( $children[1] );
6439 }
6440
6441 return $node;
6442 }
6443
6444 /**
6445 * Disambiguate and translate an expression with a simple or parenthesized
6446 * column reference for use within an ORDER BY, GROUP BY, or HAVING clause.
6447 *
6448 * In SQLite, columns that exist in multiple tables used within a query must
6449 * be fully qualified when used in the ORDER BY, GROUP BY, or HAVING clause.
6450 * In MySQL, these can be disambiguated using the SELECT item list.
6451 *
6452 * For example, when tables "t1" and "t2" both have a column called "name",
6453 * the following query will cause an "ambiguous column name" error in SQLite,
6454 * but it will succeed in MySQL, using the "t1.name" from the SELECT clause:
6455 *
6456 * SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY name
6457 *
6458 * This is because MySQL primarily considers the "name" column that was used
6459 * in the SELECT list - when it is unambiguous, it will be used in ORDER BY.
6460 *
6461 * To emulate this behavior in SQLite, we will search for unqualified column
6462 * references in the ORDER BY, GROUP BY, or HAVING item expression, and try
6463 * to qualify them using the SELECT item list.
6464 *
6465 * In other words, the above query will be rewritten as follows:
6466 *
6467 * SELECT t1.name FROM t1 JOIN t2 ON t2.t1_id = t1.id ORDER BY t1.name
6468 *
6469 * Note that the ORDER BY column was rewritten from "name" to "t1.name".
6470 *
6471 * @TODO: When multi-database support is implemented, we'll also need to
6472 * consider column references in forms like "db.table.column".
6473 *
6474 * @param array $disambiguation_map The SELECT item disambiguation map (column name => array of select items).
6475 * @see WP_MySQL_On_SQLite::create_select_item_disambiguation_map()
6476 * @param WP_Parser_Node $expr The expression AST node or subnode.
6477 * @return string|null The disambiguated and translated expression;
6478 * null when the expression cannot be disambiguated.
6479 */
6480 private function disambiguate_item( array $disambiguation_map, WP_Parser_Node $expr ) {
6481 // Skip when there is no column in the expression (no "columnRef" node),
6482 // or when the column is already qualified (has a "dotIdentifier" node).
6483 $column_ref = $expr->get_first_descendant_node( 'columnRef' );
6484 if ( ! $column_ref || $column_ref->get_first_descendant_node( 'dotIdentifier' ) ) {
6485 return null;
6486 }
6487
6488 // Support also parenthesized column references (e.g. "(id)").
6489 $expr = $this->unnest_parenthesized_expression( $expr );
6490
6491 // Consider only simple and parenthesized column references (as per MySQL).
6492 $expr_value = $this->translate( $expr );
6493 $column_value = $this->translate( $column_ref );
6494 if ( $expr_value !== $column_value ) {
6495 return null;
6496 }
6497
6498 // Look for SELECT items that match the column reference.
6499 $column_name = $this->translate( $column_ref );
6500 $select_item_matches = $disambiguation_map[ $column_name ] ?? array();
6501
6502 // When we find exactly one matching SELECT list item, we can disambiguate
6503 // the column reference. Otherwise, fall back to the original expression.
6504 if ( 1 === count( $select_item_matches ) ) {
6505 return $select_item_matches[0];
6506 }
6507 return null;
6508 }
6509
6510 /**
6511 * Create a SELECT item disambiguation map from a SELECT item list for use
6512 * with the ORDER BY, GROUP BY, and HAVING clause disambiguation algorithm.
6513 *
6514 * @see WP_MySQL_On_SQLite::disambiguate_item()
6515 *
6516 * @param WP_Parser_Node $select_item_list The "selectItemList" AST node.
6517 * @return array The SELECT item disambiguation map (column name => array of select items).
6518 */
6519 private function create_select_item_disambiguation_map( WP_Parser_Node $select_item_list ): array {
6520 // Create a map of SELECT item column names to their qualified values.
6521 $disambiguation_map = array();
6522 foreach ( $select_item_list->get_child_nodes() as $select_item ) {
6523 /*
6524 * [GRAMMAR]
6525 * selectItem: tableWild | (expr selectAlias?)
6526 */
6527
6528 // Skip when a "tableWild" node is used (no "expr" node).
6529 $select_item_expr = $select_item->get_first_child_node( 'expr' );
6530 if ( ! $select_item_expr ) {
6531 continue;
6532 }
6533
6534 // A SELECT item alias always needs to be preserved as-is.
6535 $alias = $select_item->get_first_child_node( 'selectAlias' );
6536 if ( $alias ) {
6537 $alias_value = $this->translate( $alias->get_first_child_node() );
6538 $disambiguation_map[ $alias_value ] = array( $alias_value );
6539 continue;
6540 }
6541
6542 // Skip when there is no column listed (no "columnRef" node).
6543 $select_column_ref = $select_item_expr->get_first_descendant_node( 'columnRef' );
6544 if ( ! $select_column_ref ) {
6545 continue;
6546 }
6547
6548 // Skip when the column reference is not qualified (no "dotIdentifier" node).
6549 $dot_identifiers = $select_column_ref->get_descendant_nodes( 'dotIdentifier' );
6550 if ( 0 === count( $dot_identifiers ) ) {
6551 continue;
6552 }
6553
6554 // Support also parenthesized column references (e.g. "(t.id)").
6555 $select_item_expr = $this->unnest_parenthesized_expression( $select_item_expr );
6556
6557 // Consider only simple and parenthesized column references (as per MySQL).
6558 $expr_value = $this->translate( $select_item_expr );
6559 $column_value = $this->translate( $select_column_ref );
6560 if ( $expr_value !== $column_value ) {
6561 continue;
6562 }
6563
6564 // The column name is the last "dotIdentifier" node.
6565 $key = $this->translate( end( $dot_identifiers )->get_first_child_node() );
6566
6567 $disambiguation_map[ $key ] = $disambiguation_map[ $key ] ?? array();
6568 $disambiguation_map[ $key ][] = $column_value;
6569 }
6570 return $disambiguation_map;
6571 }
6572
6573 /**
6574 * Analyze a "tableReferenceList" AST node and extract table data.
6575 *
6576 * This method extracts table data for all tables that are used at the root
6577 * level of a given query, including tables that are referenced using JOINs.
6578 *
6579 * The returned array maps table aliases to table names and additional data:
6580 * - key: table alias, or name if no alias is used
6581 * - value: an array of table data
6582 * - database: the database name of the table (null for derived tables)
6583 * - table_name: the real name of the table (null for derived tables)
6584 * - table_expr: the table expression for a derived table (null for regular tables)
6585 * - join_expr: the join expression used for the table (null when no join is used)
6586 *
6587 * MySQL has a non-stand ardsyntax extension where a comma-separated list of
6588 * table references is allowed as a table reference in itself, for instance:
6589 * SELECT * FROM (t1, t2) JOIN t3 ON 1
6590 *
6591 * Which is equivalent to:
6592 * SELECT * FROM (t1 CROSS JOIN t2) JOIN t3 ON 1
6593 *
6594 * @param WP_Parser_Node $node The "tableReferenceList" AST node.
6595 * @return array The table reference map (table alias => array of table data).
6596 */
6597 private function create_table_reference_map( WP_Parser_Node $node ): array {
6598 $table_map = array();
6599
6600 // Collect all table references, including the ones used in JOINs.
6601 $table_refs = array();
6602 foreach ( $node->get_child_nodes( 'tableReference' ) as $table_ref ) {
6603 $table_refs[] = $table_ref;
6604 foreach ( $table_ref->get_child_nodes( 'joinedTable' ) as $joined_table ) {
6605 $table_refs[] = $joined_table;
6606 }
6607 }
6608
6609 // Process each table reference, extracting table data.
6610 foreach ( $table_refs as $table_ref ) {
6611 $table_factor = $table_ref->get_first_descendant_node( 'tableFactor' );
6612 $join_expr = $table_ref->get_first_child_node( 'expr' );
6613 $child = $table_factor->get_first_child_node();
6614
6615 // Descend all "singleTableParens" nodes to get the "singleTable" node.
6616 if ( 'singleTableParens' === $child->rule_name ) {
6617 $child = $child->get_first_descendant_node( 'singleTable' );
6618 }
6619
6620 if ( 'singleTable' === $child->rule_name ) {
6621 // Extract data from the "singleTable" node.
6622 $table_ref = $child->get_first_child_node( 'tableRef' );
6623 $name = $this->translate( $table_ref );
6624 $alias_node = $child->get_first_child_node( 'tableAlias' );
6625 $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null;
6626
6627 $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array(
6628 'database' => $this->get_database_name( $table_ref ),
6629 'table_name' => $this->unquote_sqlite_identifier( $name ),
6630 'table_expr' => null,
6631 'join_expr' => $this->translate( $join_expr ),
6632 );
6633 } elseif ( 'derivedTable' === $child->rule_name ) {
6634 // Extract data from the "derivedTable" node.
6635 $subquery = $child->get_first_descendant_node( 'subquery' );
6636 $alias_node = $child->get_first_child_node( 'tableAlias' );
6637 $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null;
6638
6639 $table_map[ $this->unquote_sqlite_identifier( $alias ) ] = array(
6640 'database' => null,
6641 'table_name' => null,
6642 'table_expr' => $this->translate( $subquery ),
6643 'join_expr' => $this->translate( $join_expr ),
6644 );
6645 } elseif ( 'tableReferenceListParens' === $child->rule_name ) {
6646 // Recursively process the "tableReferenceListParens" node.
6647 $table_ref_list = $child->get_first_descendant_node( 'tableReferenceList' );
6648 $table_map = array_merge( $table_map, $this->create_table_reference_map( $table_ref_list ) );
6649 }
6650 }
6651 return $table_map;
6652 }
6653
6654 /**
6655 * Emulate MySQL type casting for values to be saved to the database
6656 * using INSERT, REPLACE, or UPDATE statements.
6657 *
6658 * @param string $mysql_data_type The MySQL data type.
6659 * @param string $translated_value The original translated value.
6660 * @return string The translated value.
6661 */
6662 private function cast_value_for_saving(
6663 string $mysql_data_type,
6664 string $translated_value
6665 ): string {
6666 // TODO: This is also a good place to implement checks for maximum column
6667 // lengths with truncating or bailing out depending on the SQL mode.
6668
6669 // Check if strict mode is enabled.
6670 $is_strict_mode = (
6671 $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' )
6672 || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' )
6673 );
6674
6675 $mysql_data_type = strtolower( $mysql_data_type );
6676 $sqlite_data_type = self::DATA_TYPE_STRING_MAP[ $mysql_data_type ];
6677
6678 /*
6679 * In MySQL, when saving a value via INSERT or UPDATE in non-strict mode,
6680 * 1. MySQL attempts to cast the value to the target column data type.
6681 * 2. When casting can't be done, MySQL saves an IMPLICIT DEFAULT.
6682 */
6683 switch ( $mysql_data_type ) {
6684 case 'date':
6685 case 'time':
6686 case 'datetime':
6687 case 'timestamp':
6688 case 'year':
6689 /*
6690 * MySQL supports date and time components without a zero padding,
6691 * but that doesn't work with date and time functions in SQLite.
6692 * E.g.: "2025-3-7 9:5:2" is a valid datetime/timestamp value in
6693 * in MySQL, but SQLite requires it to be "2025-03-07 09:05:02".
6694 *
6695 * A solution to this would need to be done on the SQL level to
6696 * address computed values, and it should be done for the strict
6697 * mode as well. This may require a user-defined function.
6698 *
6699 * TODO: Handle zero padding for date and time functions, while
6700 * supporting both strict and non-strict modes.
6701 */
6702
6703 if ( 'date' === $mysql_data_type ) {
6704 $function_call = sprintf( 'DATE(%s)', $translated_value );
6705 } elseif ( 'time' === $mysql_data_type ) {
6706 $function_call = sprintf( 'TIME(%s)', $translated_value );
6707 } elseif ( 'datetime' === $mysql_data_type || 'timestamp' === $mysql_data_type ) {
6708 $function_call = sprintf( 'DATETIME(%s)', $translated_value );
6709 } elseif ( 'year' === $mysql_data_type ) {
6710 /*
6711 * The YEAR type in MySQL only uses 1 byte and therefore
6712 * covers only 256 values from 1901 to 2155 included.
6713 * Additionally:
6714 * - Numbers from 0 to 69 correspond to years 2000 to 2069.
6715 * - Numbers from 70 to 99 correspond to years 1970 to 1999.
6716 */
6717 return sprintf(
6718 "(
6719 SELECT CASE
6720 WHEN value IS NULL THEN NULL
6721 WHEN value = 0 THEN '0000'
6722 WHEN value BETWEEN 1901 AND 2155 THEN value
6723 WHEN value BETWEEN 1 AND 69 THEN 2000 + value
6724 WHEN value BETWEEN 70 AND 99 THEN 1900 + value
6725 ELSE %s
6726 END
6727 FROM (SELECT CAST(%s AS INTEGER) AS value)
6728 )",
6729 $is_strict_mode
6730 ? sprintf( "THROW('Out of range value: ''' || %s || '''')", $translated_value )
6731 : "'0000'",
6732 $translated_value
6733 );
6734 }
6735
6736 // In strict mode, invalid date/time values are rejected.
6737 // In non-strict mode, they get an IMPLICIT DEFAULT value.
6738 if ( $is_strict_mode ) {
6739 $fallback = sprintf(
6740 "THROW('Incorrect %s value: ''' || %s || '''')",
6741 $mysql_data_type,
6742 $translated_value
6743 );
6744 } else {
6745 $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $mysql_data_type ] ?? null;
6746 $fallback = null === $implicit_default
6747 ? 'NULL'
6748 : $this->quote_sqlite_value( $implicit_default );
6749 }
6750
6751 /*
6752 * Build the CASE expression for date/time validation.
6753 *
6754 * SQLite's DATE()/DATETIME() functions return NULL for zero
6755 * dates, so the CASE includes explicit checks controlled by
6756 * the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes.
6757 *
6758 * In MySQL, the behavior of zero dates depends on these modes:
6759 *
6760 * NO_ZERO_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
6761 * - Disabled: '0000-00-00' is permitted and produces no warning.
6762 * - Enabled without strict mode: '0000-00-00' is permitted but produces a warning.
6763 * - Enabled with strict mode: '0000-00-00' is not permitted and produces an error.
6764 *
6765 * NO_ZERO_IN_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date):
6766 * - Disabled: dates with zero month/day parts (e.g. '2020-00-15') are permitted.
6767 * - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'.
6768 * - Enabled with strict mode: zero-part dates produce an error.
6769 */
6770 return strtr(
6771 "CASE
6772 WHEN {value} IS NULL THEN NULL
6773 WHEN {value} IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT {reject_zero_date} THEN {zero_date_value}
6774 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}
6775 WHEN {function_call} > '0' THEN {function_call}
6776 ELSE {fallback}
6777 END",
6778 array(
6779 '{value}' => $translated_value,
6780 '{reject_zero_date}' => (
6781 $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode
6782 ) ? 1 : 0,
6783 '{zero_date_value}' => 'date' === $mysql_data_type
6784 ? "'0000-00-00'"
6785 : "'0000-00-00 00:00:00'",
6786 '{reject_zero_in_date}' => $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0,
6787 '{function_call}' => $function_call,
6788 '{fallback}' => $fallback,
6789 )
6790 );
6791 default:
6792 /*
6793 * For all other data types, cast to the SQLite types as follows:
6794 * 1. In strict mode, cast only values for TEXT and BLOB columns.
6795 * Numeric types accept string notation in SQLite as well.
6796 * 2. In non-strict mode, cast all values.
6797 *
6798 * TODO: While close to MySQL behavior, this doesn't exactly match
6799 * all special cases. We may improve this further to accept
6800 * BLOBs for numeric types, and other special behaviors.
6801 */
6802 if ( ! $is_strict_mode || 'TEXT' === $sqlite_data_type || 'BLOB' === $sqlite_data_type ) {
6803 return sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type );
6804 }
6805 return $translated_value;
6806 }
6807 }
6808
6809 /**
6810 * Get the database name as it is saved in the information schema tables.
6811 *
6812 * @param string|null $db_name Optional. The database name to use. Defaults to the current database name.
6813 * @return string The database name as it is saved in the information schema tables.
6814 */
6815 private function get_saved_db_name( ?string $db_name = null ): string {
6816 if ( null === $db_name ) {
6817 $db_name = $this->db_name;
6818 }
6819 return $this->main_db_name === $db_name
6820 ? WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME
6821 : $db_name;
6822 }
6823
6824 /**
6825 * Get the database name from one of fully-qualified name AST nodes.
6826 *
6827 * @param WP_Parser_Node $node The AST node. One of "tableName", "tableRef", or "inDb".
6828 * @return string The database name.
6829 */
6830 private function get_database_name( WP_Parser_Node $node ): string {
6831 if ( 'tableName' === $node->rule_name || 'tableRef' === $node->rule_name ) {
6832 $parts = $node->get_descendant_nodes( 'identifier' );
6833 if ( count( $parts ) > 1 ) {
6834 return $this->unquote_sqlite_identifier( $this->translate( $parts[0] ) );
6835 } else {
6836 return $this->db_name;
6837 }
6838 } elseif ( 'inDb' === $node->rule_name ) {
6839 return $this->unquote_sqlite_identifier(
6840 $this->translate( $node->get_first_child_node( 'identifier' ) )
6841 );
6842 }
6843
6844 throw $this->new_driver_exception(
6845 sprintf( 'Could not get database name from node: %s', $node->rule_name )
6846 );
6847 }
6848
6849 /**
6850 * Generate a SQLite CREATE TABLE statement from information schema data.
6851 *
6852 * @param bool $table_is_temporary Whether the table is temporary.
6853 * @param string $table_name The name of the table to create.
6854 * @param string|null $new_table_name Override the original table name for ALTER TABLE emulation.
6855 * @return string[] Queries to create the table, indexes, and constraints.
6856 * @throws WP_MySQL_On_SQLite_Exception When the table information is missing.
6857 */
6858 private function get_sqlite_create_table_statement(
6859 bool $table_is_temporary,
6860 string $table_name,
6861 ?string $new_table_name = null
6862 ): array {
6863 // This method is always used with the main database.
6864 $database = $this->get_saved_db_name( $this->main_db_name );
6865
6866 // 1. Get table info.
6867 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
6868 $table_info = $this->execute_sqlite_query(
6869 '
6870 SELECT *
6871 FROM ' . $this->quote_sqlite_identifier( $tables_table ) . "
6872 WHERE table_type = 'BASE TABLE'
6873 AND table_schema = ?
6874 AND table_name = ?
6875 ",
6876 array( $database, $table_name )
6877 )->fetch( PDO::FETCH_ASSOC );
6878
6879 if ( false === $table_info ) {
6880 throw $this->new_table_not_found_exception( $table_name );
6881 }
6882
6883 // 2. Get column info.
6884 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
6885 $column_info = $this->execute_sqlite_query(
6886 sprintf(
6887 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position',
6888 $this->quote_sqlite_identifier( $columns_table )
6889 ),
6890 array( $database, $table_name )
6891 )->fetchAll( PDO::FETCH_ASSOC );
6892
6893 // 3. Get index info, grouped by index name.
6894 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
6895 $constraint_info = $this->execute_sqlite_query(
6896 sprintf(
6897 "
6898 SELECT *
6899 FROM %s
6900 WHERE table_schema = ?
6901 AND table_name = ?
6902 ORDER BY
6903 INDEX_NAME = 'PRIMARY' DESC,
6904 NON_UNIQUE = '0' DESC,
6905 INDEX_TYPE = 'SPATIAL' DESC,
6906 INDEX_TYPE = 'BTREE' DESC,
6907 INDEX_TYPE = 'FULLTEXT' DESC,
6908 ROWID,
6909 SEQ_IN_INDEX
6910 ",
6911 $this->quote_sqlite_identifier( $statistics_table )
6912 ),
6913 array( $database, $table_name )
6914 )->fetchAll( PDO::FETCH_ASSOC );
6915
6916 $grouped_constraints = array();
6917 foreach ( $constraint_info as $constraint ) {
6918 $name = $constraint['INDEX_NAME'];
6919 $seq = $constraint['SEQ_IN_INDEX'];
6920 $grouped_constraints[ $name ][ $seq ] = $constraint;
6921 }
6922
6923 // 4. Get foreign key info.
6924 $referential_constraints_table = $this->information_schema_builder
6925 ->get_table_name( $table_is_temporary, 'referential_constraints' );
6926 $referential_constraints_info = $this->execute_sqlite_query(
6927 sprintf(
6928 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name',
6929 $this->quote_sqlite_identifier( $referential_constraints_table )
6930 ),
6931 array( $database, $table_name )
6932 )->fetchAll( PDO::FETCH_ASSOC );
6933
6934 $key_column_usage_map = array();
6935 if ( count( $referential_constraints_info ) > 0 ) {
6936 $key_column_usage_table = $this->information_schema_builder
6937 ->get_table_name( $table_is_temporary, 'key_column_usage' );
6938 $key_column_usage_info = $this->execute_sqlite_query(
6939 sprintf(
6940 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL',
6941 $this->quote_sqlite_identifier( $key_column_usage_table )
6942 ),
6943 array( $database, $table_name )
6944 )->fetchAll( PDO::FETCH_ASSOC );
6945
6946 $key_column_usage_map = array();
6947 foreach ( $key_column_usage_info as $key_column_usage ) {
6948 $constraint_name = $key_column_usage['CONSTRAINT_NAME'];
6949 if ( ! isset( $key_column_usage_map[ $constraint_name ] ) ) {
6950 $key_column_usage_map[ $constraint_name ] = array();
6951 }
6952 $key_column_usage_map[ $constraint_name ][] = array(
6953 $key_column_usage['COLUMN_NAME'],
6954 $key_column_usage['REFERENCED_COLUMN_NAME'],
6955 );
6956 }
6957 }
6958
6959 // 5. Get CHECK constraint info.
6960 $table_constraints_table = $this->information_schema_builder
6961 ->get_table_name( $table_is_temporary, 'table_constraints' );
6962 $check_constraints_table = $this->information_schema_builder
6963 ->get_table_name( $table_is_temporary, 'check_constraints' );
6964 $check_constraints_info = $this->execute_sqlite_query(
6965 sprintf(
6966 'SELECT tc.*, cc.check_clause
6967 FROM %s tc
6968 JOIN %s cc ON cc.constraint_name = tc.constraint_name
6969 WHERE tc.constraint_schema = ?
6970 AND tc.table_name = ?
6971 ORDER BY tc.constraint_name',
6972 $this->quote_sqlite_identifier( $table_constraints_table ),
6973 $this->quote_sqlite_identifier( $check_constraints_table )
6974 ),
6975 array( $database, $table_name )
6976 )->fetchAll( PDO::FETCH_ASSOC );
6977
6978 // 6. Generate CREATE TABLE statement columns.
6979 $rows = array();
6980 $on_update_queries = array();
6981 $has_autoincrement = false;
6982 foreach ( $column_info as $column ) {
6983 $query = ' ';
6984 $query .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
6985
6986 $type = self::DATA_TYPE_STRING_MAP[ $column['DATA_TYPE'] ];
6987
6988 /*
6989 * In SQLite, there is a PRIMARY KEY quirk for backward compatibility.
6990 * This applies to ROWID tables and single-column primary keys only:
6991 * 1. "INTEGER PRIMARY KEY" creates an alias of ROWID.
6992 * 2. "INT PRIMARY KEY" will not alias of ROWID.
6993 *
6994 * Therefore, we want to:
6995 * 1. Use "INT PRIMARY KEY" when we have a single-column integer
6996 * PRIMARY KEY without AUTOINCREMENT (to avoid the ROWID alias).
6997 * 2. Use "INTEGER PRIMARY KEY" otherwise.
6998 *
6999 * In SQLite, "AUTOINCREMENT" is only allowed on "INTEGER PRIMARY KEY",
7000 * and setting it changes the automatic ROWID assignment algorithm to
7001 * prevent the reuse of ROWIDs. Using "INT PRIMARY KEY" is not allowed.
7002 *
7003 * See:
7004 * - https://www.sqlite.org/autoinc.html
7005 * - https://www.sqlite.org/lang_createtable.html
7006 */
7007 if (
7008 'INTEGER' === $type
7009 && 'PRI' === $column['COLUMN_KEY']
7010 && ! str_contains( $column['EXTRA'], 'auto_increment' )
7011 && count( $grouped_constraints['PRIMARY'] ) === 1
7012 ) {
7013 $type = 'INT';
7014 }
7015
7016 $query .= ' ' . $type;
7017
7018 // In MySQL, text fields are case-insensitive by default.
7019 // COLLATE NOCASE emulates the same behavior in SQLite.
7020 // @TODO: Respect the actual column and index collation.
7021 if ( 'TEXT' === $type ) {
7022 $query .= ' COLLATE NOCASE';
7023 }
7024 if ( 'NO' === $column['IS_NULLABLE'] ) {
7025 $query .= ' NOT NULL';
7026 }
7027 if ( str_contains( $column['EXTRA'], 'auto_increment' ) ) {
7028 $has_autoincrement = true;
7029 $query .= ' PRIMARY KEY AUTOINCREMENT';
7030 }
7031 if ( null !== $column['COLUMN_DEFAULT'] ) {
7032 // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
7033 // and datetime columns. For other column types, it's just a string.
7034 if (
7035 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
7036 && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
7037 ) {
7038 $query .= ' DEFAULT CURRENT_TIMESTAMP';
7039 } elseif ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) {
7040 // Handle DEFAULT values with expressions (DEFAULT_GENERATED).
7041 // Translate the default clause from MySQL to SQLite.
7042 $ast = $this->create_parser( 'SELECT ' . $column['COLUMN_DEFAULT'] )->parse();
7043 $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node();
7044 $default_clause = $this->translate( $expr );
7045 $query .= sprintf( ' DEFAULT (%s)', $default_clause );
7046 } else {
7047 $query .= ' DEFAULT ' . $this->format_sqlite_column_default( $column );
7048 }
7049 }
7050 $rows[] = $query;
7051
7052 if ( 'on update CURRENT_TIMESTAMP' === $column['EXTRA'] ) {
7053 $on_update_queries[] = $this->get_column_on_update_trigger_query(
7054 $table_name,
7055 $column['COLUMN_NAME']
7056 );
7057 }
7058 }
7059
7060 // 6. Generate CREATE TABLE statement constraints, collect indexes.
7061 $create_index_queries = array();
7062 foreach ( $grouped_constraints as $constraint ) {
7063 ksort( $constraint );
7064 $info = $constraint[1];
7065
7066 $column_list = array_map(
7067 function ( $column ) {
7068 $fragment = $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
7069 if ( 'D' === $column['COLLATION'] ) {
7070 $fragment .= ' DESC';
7071 }
7072 return $fragment;
7073 },
7074 $constraint
7075 );
7076
7077 if ( 'PRIMARY' === $info['INDEX_NAME'] ) {
7078 if ( $has_autoincrement ) {
7079 /*
7080 * In MySQL, a compound PRIMARY KEY can have an AUTO_INCREMENT
7081 * column, when it is the first column in the key.
7082 *
7083 * SQLite doesn't support this, but we can emulate it as follows:
7084 * 1. Keep only the first column as a PRIMARY KEY.
7085 * Since this is the column that also has AUTO_INCREMENT,
7086 * it reasonable to assume that its values are unique.
7087 * 2. Create a UNIQUE key for all the PRIMARY KEY columns.
7088 * This is to preserve the index of the compound key.
7089 */
7090 if ( count( $constraint ) > 1 ) {
7091 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, 'primary' );
7092 $create_index_queries[] = sprintf(
7093 'CREATE UNIQUE INDEX %s ON %s (%s)',
7094 self::RESERVED_PREFIX . $sqlite_index_name,
7095 $this->quote_sqlite_identifier( $table_name ),
7096 implode( ', ', $column_list )
7097 );
7098 }
7099
7100 /*
7101 * The PRIMARY KEY was already generated with AUTOINCREMENT,
7102 * as required by SQLite column constraint syntax.
7103 *
7104 * @see https://www.sqlite.org/syntax/column-constraint.html
7105 */
7106 continue;
7107 }
7108 $rows[] = sprintf( ' PRIMARY KEY (%s)', implode( ', ', $column_list ) );
7109 } else {
7110 $is_unique = '0' === $info['NON_UNIQUE'];
7111
7112 // Prefix the original index name with the table name.
7113 // This is to avoid conflicting index names in SQLite.
7114 $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $info['INDEX_NAME'] );
7115
7116 $create_index_queries[] = sprintf(
7117 'CREATE %sINDEX %s ON %s (%s)',
7118 $is_unique ? 'UNIQUE ' : '',
7119 $this->quote_sqlite_identifier( $sqlite_index_name ),
7120 $this->quote_sqlite_identifier( $table_name ),
7121 implode( ', ', $column_list )
7122 );
7123 }
7124 }
7125
7126 // 8. Add foreign key constraints.
7127 foreach ( $referential_constraints_info as $referential_constraint ) {
7128 $column_names = array();
7129 $referenced_column_names = array();
7130 foreach ( $key_column_usage_map[ $referential_constraint['CONSTRAINT_NAME'] ] as $info ) {
7131 $column_names[] = $this->quote_sqlite_identifier( $info[0] );
7132 $referenced_column_names[] = $this->quote_sqlite_identifier( $info[1] );
7133 }
7134 $query = sprintf(
7135 ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)',
7136 $this->quote_sqlite_identifier( $referential_constraint['CONSTRAINT_NAME'] ),
7137 implode( ', ', $column_names ),
7138 $this->quote_sqlite_identifier( $referential_constraint['REFERENCED_TABLE_NAME'] ),
7139 implode( ', ', $referenced_column_names )
7140 );
7141
7142 // ON DELETE
7143 $delete_rule = $referential_constraint['DELETE_RULE'];
7144 if ( 'NO ACTION' === $delete_rule ) {
7145 // In MySQL, NO ACTION is equivalent to RESTRICT with InnoDB.
7146 $delete_rule = 'RESTRICT';
7147 }
7148 $query .= sprintf( ' ON DELETE %s', $delete_rule );
7149
7150 // ON UPDATE
7151 $update_rule = $referential_constraint['UPDATE_RULE'];
7152 if ( 'NO ACTION' === $update_rule ) {
7153 // In MySQL, NO ACTION is equivalent to RESTRICT with InnoDB.
7154 $update_rule = 'RESTRICT';
7155 }
7156 $query .= sprintf( ' ON UPDATE %s', $update_rule );
7157
7158 $rows[] = $query;
7159 }
7160
7161 // 9. Add CHECK constraints.
7162 foreach ( $check_constraints_info as $check_constraint ) {
7163 if ( 'NO' === $check_constraint['ENFORCED'] ) {
7164 continue;
7165 }
7166
7167 // Translate the check clause from MySQL to SQLite.
7168 $ast = $this->create_parser( 'SELECT ' . $check_constraint['CHECK_CLAUSE'] )->parse();
7169 $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node();
7170 $check_clause = $this->translate( $expr );
7171
7172 $sql = sprintf(
7173 ' CONSTRAINT %s CHECK (%s)',
7174 $this->quote_sqlite_identifier( $check_constraint['CONSTRAINT_NAME'] ),
7175 $check_clause
7176 );
7177 $rows[] = $sql;
7178 }
7179
7180 // 10. Compose the CREATE TABLE statement.
7181 $create_table_query = sprintf(
7182 "CREATE %sTABLE %s (\n",
7183 $table_is_temporary ? 'TEMPORARY ' : '',
7184 $this->quote_sqlite_identifier( $new_table_name ?? $table_name )
7185 );
7186 $create_table_query .= implode( ",\n", $rows );
7187 $create_table_query .= "\n)";
7188
7189 if ( version_compare( $this->get_sqlite_version(), '3.37.0', '>=' ) ) {
7190 $create_table_query .= ' STRICT';
7191 }
7192
7193 return array_merge( array( $create_table_query ), $create_index_queries, $on_update_queries );
7194 }
7195
7196 /**
7197 * Generate a MySQL CREATE TABLE statement from information schema data.
7198 *
7199 * @param bool $table_is_temporary Whether the table is temporary.
7200 * @param string $table_name The name of the table to create.
7201 * @return string The CREATE TABLE statement.
7202 */
7203 private function get_mysql_create_table_statement( bool $table_is_temporary, string $table_name ): ?string {
7204 // This method is always used with the main database.
7205 $database = $this->get_saved_db_name( $this->main_db_name );
7206
7207 // 1. Get table info.
7208 $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' );
7209 $table_info = $this->execute_sqlite_query(
7210 '
7211 SELECT *
7212 FROM ' . $this->quote_sqlite_identifier( $tables_table ) . "
7213 WHERE table_type = 'BASE TABLE'
7214 AND table_schema = ?
7215 AND table_name = ?
7216 ",
7217 array( $database, $table_name )
7218 )->fetch( PDO::FETCH_ASSOC );
7219
7220 if ( false === $table_info ) {
7221 return null;
7222 }
7223
7224 // 2. Get column info.
7225 $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' );
7226 $column_info = $this->execute_sqlite_query(
7227 sprintf(
7228 '
7229 SELECT *
7230 FROM %s
7231 WHERE table_schema = ?
7232 AND table_name = ?
7233 ORDER BY ordinal_position
7234 ',
7235 $this->quote_sqlite_identifier( $columns_table )
7236 ),
7237 array( $database, $table_name )
7238 )->fetchAll( PDO::FETCH_ASSOC );
7239
7240 // 3. Get index info, grouped by index name.
7241 $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' );
7242 $constraint_info = $this->execute_sqlite_query(
7243 sprintf(
7244 "
7245 SELECT *
7246 FROM %s
7247 WHERE table_schema = ?
7248 AND table_name = ?
7249 ORDER BY
7250 INDEX_NAME = 'PRIMARY' DESC,
7251 NON_UNIQUE = '0' DESC,
7252 INDEX_TYPE = 'SPATIAL' DESC,
7253 INDEX_TYPE = 'BTREE' DESC,
7254 INDEX_TYPE = 'FULLTEXT' DESC,
7255 ROWID,
7256 SEQ_IN_INDEX
7257 ",
7258 $this->quote_sqlite_identifier( $statistics_table )
7259 ),
7260 array( $database, $table_name )
7261 )->fetchAll( PDO::FETCH_ASSOC );
7262
7263 $grouped_constraints = array();
7264 foreach ( $constraint_info as $constraint ) {
7265 $name = $constraint['INDEX_NAME'];
7266 $seq = $constraint['SEQ_IN_INDEX'];
7267 $grouped_constraints[ $name ][ $seq ] = $constraint;
7268 }
7269
7270 // 4. Get foreign key info.
7271 $referential_constraints_table = $this->information_schema_builder
7272 ->get_table_name( $table_is_temporary, 'referential_constraints' );
7273 $referential_constraints_info = $this->execute_sqlite_query(
7274 sprintf(
7275 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name',
7276 $this->quote_sqlite_identifier( $referential_constraints_table )
7277 ),
7278 array( $database, $table_name )
7279 )->fetchAll( PDO::FETCH_ASSOC );
7280
7281 $key_column_usage_map = array();
7282 if ( count( $referential_constraints_info ) > 0 ) {
7283 $key_column_usage_table = $this->information_schema_builder
7284 ->get_table_name( $table_is_temporary, 'key_column_usage' );
7285 $key_column_usage_info = $this->execute_sqlite_query(
7286 sprintf(
7287 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL',
7288 $this->quote_sqlite_identifier( $key_column_usage_table )
7289 ),
7290 array( $database, $table_name )
7291 )->fetchAll( PDO::FETCH_ASSOC );
7292
7293 $key_column_usage_map = array();
7294 foreach ( $key_column_usage_info as $key_column_usage ) {
7295 $constraint_name = $key_column_usage['CONSTRAINT_NAME'];
7296 if ( ! isset( $key_column_usage_map[ $constraint_name ] ) ) {
7297 $key_column_usage_map[ $constraint_name ] = array();
7298 }
7299 $key_column_usage_map[ $constraint_name ][] = array(
7300 $key_column_usage['COLUMN_NAME'],
7301 $key_column_usage['REFERENCED_COLUMN_NAME'],
7302 );
7303 }
7304 }
7305
7306 // 5. Get CHECK constraint info.
7307 $table_constraints_table = $this->information_schema_builder
7308 ->get_table_name( $table_is_temporary, 'table_constraints' );
7309 $check_constraints_table = $this->information_schema_builder
7310 ->get_table_name( $table_is_temporary, 'check_constraints' );
7311 $check_constraints_info = $this->execute_sqlite_query(
7312 sprintf(
7313 'SELECT tc.*, cc.check_clause
7314 FROM %s tc
7315 JOIN %s cc ON cc.constraint_name = tc.constraint_name
7316 WHERE tc.constraint_schema = ?
7317 AND tc.table_name = ?
7318 ORDER BY tc.constraint_name',
7319 $this->quote_sqlite_identifier( $table_constraints_table ),
7320 $this->quote_sqlite_identifier( $check_constraints_table )
7321 ),
7322 array( $database, $table_name )
7323 )->fetchAll( PDO::FETCH_ASSOC );
7324
7325 // 6. Generate CREATE TABLE statement columns.
7326 $rows = array();
7327 $has_auto_increment = false;
7328 foreach ( $column_info as $column ) {
7329 $sql = ' ';
7330 $sql .= $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
7331 $sql .= ' ' . $column['COLUMN_TYPE'];
7332 if ( 'NO' === $column['IS_NULLABLE'] ) {
7333 $sql .= ' NOT NULL';
7334 } elseif ( 'timestamp' === $column['COLUMN_TYPE'] ) {
7335 // Nullable "timestamp" columns dump NULL explicitly.
7336 $sql .= ' NULL';
7337 }
7338 if ( str_contains( $column['EXTRA'], 'auto_increment' ) ) {
7339 $has_auto_increment = true;
7340 $sql .= ' AUTO_INCREMENT';
7341 }
7342
7343 // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
7344 // and datetime columns. For other column types, it's just a string.
7345 if (
7346 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
7347 && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
7348 ) {
7349 $sql .= ' DEFAULT CURRENT_TIMESTAMP';
7350 } elseif ( null !== $column['COLUMN_DEFAULT'] ) {
7351 if ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) {
7352 $sql .= sprintf( ' DEFAULT (%s)', $column['COLUMN_DEFAULT'] );
7353 } else {
7354 $sql .= ' DEFAULT ' . $this->format_mysql_column_default( $column );
7355 }
7356 } elseif ( 'YES' === $column['IS_NULLABLE'] ) {
7357 $sql .= ' DEFAULT NULL';
7358 }
7359
7360 // Handle ON UPDATE CURRENT_TIMESTAMP.
7361 if ( str_contains( $column['EXTRA'], 'on update CURRENT_TIMESTAMP' ) ) {
7362 $sql .= ' ON UPDATE CURRENT_TIMESTAMP';
7363 }
7364
7365 if ( '' !== $column['COLUMN_COMMENT'] ) {
7366 $sql .= sprintf(
7367 ' COMMENT %s',
7368 $this->quote_mysql_utf8_string_literal( $column['COLUMN_COMMENT'] )
7369 );
7370 }
7371
7372 $rows[] = $sql;
7373 }
7374
7375 // 7. Generate CREATE TABLE statement constraints, collect indexes.
7376 $format_index_column = function ( $column ) {
7377 $definition = $this->quote_mysql_identifier( $column['COLUMN_NAME'] );
7378 if ( null !== $column['SUB_PART'] ) {
7379 $definition .= sprintf( '(%d)', $column['SUB_PART'] );
7380 }
7381 if ( 'D' === $column['COLLATION'] ) {
7382 $definition .= ' DESC';
7383 }
7384 return $definition;
7385 };
7386
7387 foreach ( $grouped_constraints as $constraint ) {
7388 ksort( $constraint );
7389 $info = $constraint[1];
7390
7391 if ( 'PRIMARY' === $info['INDEX_NAME'] ) {
7392 $sql = ' PRIMARY KEY (';
7393 $sql .= implode( ', ', array_map( $format_index_column, $constraint ) );
7394 $sql .= ')';
7395 } else {
7396 $is_unique = '0' === $info['NON_UNIQUE'];
7397
7398 $sql = sprintf(
7399 ' %s%s%sKEY ',
7400 $is_unique ? 'UNIQUE ' : '',
7401 'FULLTEXT' === $info['INDEX_TYPE'] ? 'FULLTEXT ' : '',
7402 'SPATIAL' === $info['INDEX_TYPE'] ? 'SPATIAL ' : ''
7403 );
7404 $sql .= $this->quote_mysql_identifier( $info['INDEX_NAME'] );
7405 $sql .= ' (';
7406 $sql .= implode( ', ', array_map( $format_index_column, $constraint ) );
7407 $sql .= ')';
7408 }
7409
7410 if ( '' !== $info['INDEX_COMMENT'] ) {
7411 $sql .= sprintf(
7412 ' COMMENT %s',
7413 $this->quote_mysql_utf8_string_literal( $info['INDEX_COMMENT'] )
7414 );
7415 }
7416
7417 $rows[] = $sql;
7418 }
7419
7420 // 8. Add foreign key constraints.
7421 foreach ( $referential_constraints_info as $referential_constraint ) {
7422 $column_names = array();
7423 $referenced_column_names = array();
7424 foreach ( $key_column_usage_map[ $referential_constraint['CONSTRAINT_NAME'] ] as $info ) {
7425 $column_names[] = $this->quote_mysql_identifier( $info[0] );
7426 $referenced_column_names[] = $this->quote_mysql_identifier( $info[1] );
7427 }
7428 $sql = sprintf(
7429 ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)',
7430 $this->quote_mysql_identifier( $referential_constraint['CONSTRAINT_NAME'] ),
7431 implode( ', ', $column_names ),
7432 $this->quote_mysql_identifier( $referential_constraint['REFERENCED_TABLE_NAME'] ),
7433 implode( ', ', $referenced_column_names )
7434 );
7435 if ( 'NO ACTION' !== $referential_constraint['DELETE_RULE'] ) {
7436 $sql .= sprintf( ' ON DELETE %s', $referential_constraint['DELETE_RULE'] );
7437 }
7438 if ( 'NO ACTION' !== $referential_constraint['UPDATE_RULE'] ) {
7439 $sql .= sprintf( ' ON UPDATE %s', $referential_constraint['UPDATE_RULE'] );
7440 }
7441 $rows[] = $sql;
7442 }
7443
7444 // 9. Add CHECK constraints.
7445 foreach ( $check_constraints_info as $check_constraint ) {
7446 $sql = sprintf(
7447 ' CONSTRAINT %s CHECK (%s)%s',
7448 $this->quote_mysql_identifier( $check_constraint['CONSTRAINT_NAME'] ),
7449 $check_constraint['CHECK_CLAUSE'],
7450 'NO' === $check_constraint['ENFORCED'] ? ' /*!80016 NOT ENFORCED */' : ''
7451 );
7452 $rows[] = $sql;
7453 }
7454
7455 // 10. Compose the CREATE TABLE statement.
7456 $collation = $table_info['TABLE_COLLATION'];
7457 $charset = substr( $collation, 0, strpos( $collation, '_' ) );
7458
7459 $sql = sprintf(
7460 "CREATE %sTABLE %s (\n",
7461 $table_is_temporary ? 'TEMPORARY ' : '',
7462 $this->quote_mysql_identifier( $table_name )
7463 );
7464 $sql .= implode( ",\n", $rows );
7465 $sql .= "\n)";
7466 $sql .= sprintf( ' ENGINE=%s', $table_info['ENGINE'] );
7467
7468 // Add "AUTO_INCREMENT=N" if a sequence exists and has been advanced.
7469 if ( $has_auto_increment ) {
7470 try {
7471 $seq = (int) $this->execute_sqlite_query(
7472 sprintf(
7473 'SELECT seq FROM %s.sqlite_sequence WHERE name = ?',
7474 $table_is_temporary ? 'temp' : 'main'
7475 ),
7476 array( $table_name )
7477 )->fetchColumn();
7478 } catch ( PDOException $e ) {
7479 if ( ! str_contains( $e->getMessage(), 'no such table' ) ) {
7480 throw $e;
7481 }
7482 $seq = 0;
7483 }
7484 if ( $seq > 0 ) {
7485 $sql .= sprintf( ' AUTO_INCREMENT=%d', $seq + 1 );
7486 }
7487 }
7488
7489 $sql .= sprintf( ' DEFAULT CHARSET=%s', $charset );
7490 $sql .= sprintf( ' COLLATE=%s', $collation );
7491 if ( '' !== $table_info['TABLE_COMMENT'] ) {
7492 $sql .= sprintf(
7493 ' COMMENT=%s',
7494 $this->quote_mysql_utf8_string_literal( $table_info['TABLE_COMMENT'] )
7495 );
7496 }
7497 return $sql;
7498 }
7499
7500 /**
7501 * Get an unique SQLite index name from a MySQL table name and index name.
7502 *
7503 * @param string $table_name The MySQL table name.
7504 * @param string $index_name The MySQL index name.
7505 * @return string The SQLite index name.
7506 */
7507 private function get_sqlite_index_name( string $mysql_table_name, string $mysql_index_name ): string {
7508 // Prefix the original index name with the table name.
7509 // This is to avoid conflicting index names in SQLite.
7510 return $mysql_table_name . '__' . $mysql_index_name;
7511 }
7512
7513 /**
7514 * Get an internal savepoint name.
7515 *
7516 * Internal savepoints are used to emulate MySQL transactions that are run
7517 * inside a wrapping SQLite transaction, as transactions can't be nested.
7518 *
7519 * @param string $name The name of the savepoint.
7520 * @return string The internal savepoint name.
7521 */
7522 private function get_internal_savepoint_name( string $name ): string {
7523 return sprintf( '%ssavepoint_%s', self::RESERVED_PREFIX, $name );
7524 }
7525
7526 /**
7527 * Get an SQLite query to emulate MySQL "ON UPDATE CURRENT_TIMESTAMP".
7528 *
7529 * In SQLite, "ON UPDATE CURRENT_TIMESTAMP" is not supported. We need to
7530 * create a trigger to emulate this behavior.
7531 *
7532 * @param string $table The table name.
7533 * @param string $column The column name.
7534 */
7535 private function get_column_on_update_trigger_query( string $table, string $column ): string {
7536 // The trigger wouldn't work for virtual and "WITHOUT ROWID" tables,
7537 // but currently that can't happen as we're not creating such tables.
7538 // See: https://www.sqlite.org/rowidtable.html
7539 $trigger_name = self::RESERVED_PREFIX . "{$table}_{$column}_on_update";
7540 return sprintf(
7541 '
7542 CREATE TRIGGER %s
7543 AFTER UPDATE ON %s
7544 FOR EACH ROW
7545 BEGIN
7546 UPDATE %s SET %s = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;
7547 END
7548 ',
7549 $this->quote_sqlite_identifier( $trigger_name ),
7550 $this->quote_sqlite_identifier( $table ),
7551 $this->quote_sqlite_identifier( $table ),
7552 $this->quote_sqlite_identifier( $column )
7553 );
7554 }
7555
7556 /**
7557 * Format a column default value as a literal for an SQLite CREATE TABLE statement.
7558 *
7559 * @param array $column The information schema column data.
7560 * @return string The default value as an SQLite literal.
7561 */
7562 private function format_sqlite_column_default( array $column ): string {
7563 if ( 'bit' === $column['DATA_TYPE'] ) {
7564 // BIT defaults are stored as b'...' literals; reformat as an integer.
7565 return (string) bindec( substr( $column['COLUMN_DEFAULT'], 2, -1 ) );
7566 }
7567
7568 return $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] );
7569 }
7570
7571 /**
7572 * Format a column default value as a literal for a MySQL SHOW CREATE TABLE statement.
7573 *
7574 * @param array $column The information schema column data.
7575 * @return string The default value as a MySQL literal.
7576 */
7577 private function format_mysql_column_default( array $column ): string {
7578 // BIT defaults are already stored as b'...' literals.
7579 if ( 'bit' === $column['DATA_TYPE'] ) {
7580 return $column['COLUMN_DEFAULT'];
7581 }
7582
7583 return $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] );
7584 }
7585
7586 /**
7587 * Unquote a quoted SQLite identifier.
7588 *
7589 * Remove bounding quotes and replace escaped quotes with their values.
7590 *
7591 * @param string $quoted_identifier The quoted identifier value.
7592 * @return string The unquoted identifier value.
7593 */
7594 private function unquote_sqlite_identifier( string $quoted_identifier ): string {
7595 $first_byte = $quoted_identifier[0] ?? null;
7596 if ( '"' === $first_byte || '`' === $first_byte ) {
7597 $unquoted = substr( $quoted_identifier, 1, -1 );
7598 return str_replace( $first_byte . $first_byte, $first_byte, $unquoted );
7599 }
7600 return $quoted_identifier;
7601 }
7602
7603 /**
7604 * Quote an identifier for use in an SQLite query.
7605 *
7606 * @param string $unquoted_identifier The unquoted identifier value.
7607 * @return string The quoted identifier value.
7608 */
7609 private function quote_sqlite_identifier( string $unquoted_identifier ): string {
7610 return $this->connection->quote_identifier( $unquoted_identifier );
7611 }
7612
7613 /**
7614 * Quote a value for use in an SQLite query.
7615 *
7616 * @param mixed $value The value to quote.
7617 * @return string The quoted value.
7618 */
7619 private function quote_sqlite_value( $value ): string {
7620 return $this->connection->quote( $value );
7621 }
7622
7623 /**
7624 * Quote an identifier for use in a MySQL query.
7625 *
7626 * Wrap the identifier in backticks and escape backtick values within.
7627 *
7628 * @param string $unquoted_identifier The unquoted identifier value.
7629 * @return string The quoted identifier value.
7630 */
7631 private function quote_mysql_identifier( string $unquoted_identifier ): string {
7632 return '`' . str_replace( '`', '``', $unquoted_identifier ) . '`';
7633 }
7634
7635 /**
7636 * Format a MySQL UTF-8 string literal for output in a CREATE TABLE statement.
7637 *
7638 * We expect UTF-8 strings coming from SQLite. The only characters that must
7639 * be escaped in a single-quoted string for a UTF-8 MySQL dump are ' and \.
7640 *
7641 * MySQL SHOW CREATE TABLE command additionally escapes "\0", "\n", and "\r",
7642 * for the mysql CLI, logs, and better readability. This applies to column
7643 * default values, and table, column, and index comments. Other values, such
7644 * as identifiers, don't have these extra characters escaped in the output.
7645 *
7646 * See:
7647 * - https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql/sql_show.cc#L1799
7648 * - https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql/table.cc#L3525
7649 *
7650 * Unfortunately, SQLite doesn't validate the UTF-8 encoding, so other byte
7651 * sequences may come from SQLite as well: https://www.sqlite.org/invalidutf.html
7652 *
7653 * TODO: We may consider stripping invalid UTF-8 characters, but that's likely
7654 * to be a bigger project, as these can appear also in other contexts.
7655 *
7656 * @param string $utf8_literal The UTF-8 string literal to escape.
7657 * @return string The escaped string literal.
7658 */
7659 private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
7660 /*
7661 * We can't use "addcslashes()" here, because it has an unusual handling
7662 * of the ASCII NULL character, escaping it to "\000" instead of "\0".
7663 *
7664 * It is important to use "strtr()" and not "str_replace()", because
7665 * "str_replace()" applies replacements one after another, modifying
7666 * intermediate changes rather than just the original string:
7667 *
7668 * - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
7669 * - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
7670 */
7671 $backslash = chr( 92 );
7672 $replacements = array(
7673 "'" => "''", // A single quote character (').
7674 $backslash => $backslash . $backslash, // A backslash character (\).
7675 chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0).
7676 chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n).
7677 chr( 13 ) => $backslash . 'r', // A carriage return character (\r).
7678 );
7679 return "'" . strtr( $utf8_literal, $replacements ) . "'";
7680 }
7681
7682 /**
7683 * Clear the state of the driver.
7684 */
7685 private function flush(): void {
7686 $this->last_mysql_query = '';
7687 $this->last_sqlite_queries = array();
7688 $this->last_result_statement = null;
7689 $this->last_affected_rows = null;
7690 $this->last_insert_id = '0';
7691 $this->last_column_meta = array();
7692 $this->is_readonly = false;
7693 $this->wrapper_transaction_type = null;
7694 $this->user_defined_functions->flush();
7695 }
7696
7697 /**
7698 * Create a PDO SQLite statement from the specified columns and rows.
7699 *
7700 * Some emulated MySQL queries don't have an SQLite counterpart and their
7701 * result data may be generated without a corresponding SQLite statement.
7702 * In such cases, we can generate a simple SQLite SELECT query that will
7703 * provide us with the PDOStatement API for the given column and row data.
7704 *
7705 * @param array $columns The columns of the result set.
7706 * @param array $rows The rows of the result set.
7707 * @return PDOStatement The corresponding PDO SQLite statement.
7708 */
7709 private function create_result_statement_from_data( array $columns, array $rows ): PDOStatement {
7710 $pdo = $this->connection->get_pdo();
7711
7712 /*
7713 * With 0 columns, we need to create a PDO statement that has no columns.
7714 * This can be done using a noop INSERT statement that modifies no data.
7715 */
7716 if ( 0 === count( $columns ) ) {
7717 /*
7718 * Build a 0-column statement to represent an empty result set.
7719 *
7720 * A no-op "INSERT ... WHERE FALSE" into a regular table still acquires
7721 * SQLite's write lock, even though it writes nothing. That makes
7722 * no-write statements such as "SET sql_mode = ..." fatal with
7723 * "database is locked" whenever another connection holds the write
7724 * lock. Writing to a connection-private TEMP table uses the temp
7725 * database, which has no shared lock, so it never contends.
7726 */
7727 if ( ! $this->empty_result_table_ready ) {
7728 $pdo->exec(
7729 sprintf(
7730 'CREATE TEMP TABLE IF NOT EXISTS %s ( placeholder_for_empty_insert )',
7731 $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
7732 )
7733 );
7734 $this->empty_result_table_ready = true;
7735 }
7736 return $pdo->query(
7737 sprintf(
7738 'INSERT INTO %s ( placeholder_for_empty_insert ) SELECT NULL WHERE FALSE',
7739 $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
7740 )
7741 );
7742 }
7743
7744 /*
7745 * Create an SQLite statement that returns the specified columns and rows.
7746 * This can be done using a SELECT statement in the following form:
7747 *
7748 * -- A dummy header row to assign correct column names.
7749 * SELECT NULL AS `col1`, NULL AS `col2`, ... WHERE FALSE
7750 *
7751 * UNION ALL
7752 *
7753 * -- The actual data rows.
7754 * VALUES
7755 * (val11, val12, ...),
7756 * (val21, val22, ...),
7757 * ...
7758 */
7759
7760 // Construct column header row ("SELECT <column-list> WHERE FALSE").
7761 $query = 'SELECT ';
7762 foreach ( $columns as $i => $column ) {
7763 $query .= $i > 0 ? ', ' : '';
7764 $query .= 'NULL AS ' . $pdo->quote( $column );
7765 }
7766 $query .= ' WHERE FALSE';
7767
7768 // UNION ALL
7769 if ( count( $rows ) > 0 ) {
7770 $query .= ' UNION ALL VALUES ';
7771 }
7772
7773 // Construct data rows ("VALUES <row-list>").
7774 foreach ( $rows as $i => $row ) {
7775 $query .= $i > 0 ? ', ' : '';
7776 $query .= '(';
7777 foreach ( array_values( $row ) as $j => $value ) {
7778 $query .= $j > 0 ? ', ' : '';
7779 if ( null === $value ) {
7780 $query .= 'NULL';
7781 } elseif ( is_string( $value ) && strpos( $value, "\0" ) !== false ) {
7782 // Handle null characters; see self::translate_string_literal().
7783 $query .= sprintf( "CAST(x'%s' AS TEXT)", bin2hex( $value ) );
7784 } elseif ( is_string( $value ) ) {
7785 $query .= $pdo->quote( $value );
7786 } else {
7787 $query .= $value;
7788 }
7789 }
7790 $query .= ')';
7791 }
7792 return $pdo->query( $query );
7793 }
7794
7795 /**
7796 * Create a new MySQL-on-SQLite driver exception.
7797 *
7798 * @param string $message The exception message.
7799 * @param int|string $code The exception code. For PDO errors, a string representing SQLSTATE.
7800 * @param Throwable|null $previous The previous exception.
7801 * @param array|null $error_info PDO-style error information.
7802 * @return WP_MySQL_On_SQLite_Exception
7803 */
7804 private function new_driver_exception(
7805 string $message,
7806 $code = 0,
7807 ?Throwable $previous = null,
7808 ?array $error_info = null
7809 ): WP_MySQL_On_SQLite_Exception {
7810 return new WP_MySQL_On_SQLite_Exception( $this, $message, $code, $previous, $error_info );
7811 }
7812
7813 /**
7814 * Create a new invalid input exception.
7815 *
7816 * This exception can be used to mark cases that should never occur according
7817 * to the MySQL grammar. It may serve as an assertion that should never fail.
7818 *
7819 * @return WP_MySQL_On_SQLite_Exception
7820 */
7821 private function new_invalid_input_exception(): WP_MySQL_On_SQLite_Exception {
7822 return new WP_MySQL_On_SQLite_Exception( $this, 'MySQL query syntax error.' );
7823 }
7824
7825 /**
7826 * Create a new not supported exception.
7827 *
7828 * This exception can be used to mark MySQL constructs that are not supported.
7829 *
7830 * @param string $cause The cause, indicating which construct is not supported.
7831 * @return WP_MySQL_On_SQLite_Exception
7832 */
7833 private function new_not_supported_exception( string $cause ): WP_MySQL_On_SQLite_Exception {
7834 return new WP_MySQL_On_SQLite_Exception(
7835 $this,
7836 sprintf( 'MySQL query not supported. Cause: %s', $cause )
7837 );
7838 }
7839
7840 /**
7841 * Create a MySQL-compatible table-not-found exception.
7842 *
7843 * @param string $table_name The missing table name.
7844 * @param Throwable|null $previous The previous exception.
7845 * @return WP_MySQL_On_SQLite_Exception
7846 */
7847 private function new_table_not_found_exception(
7848 string $table_name,
7849 ?Throwable $previous = null
7850 ): WP_MySQL_On_SQLite_Exception {
7851 $driver_message = sprintf( "Table '%s' doesn't exist", $table_name );
7852 return $this->new_driver_exception(
7853 'SQLSTATE[42S02]: Base table or view not found: 1146 ' . $driver_message,
7854 '42S02',
7855 $previous,
7856 array( '42S02', 1146, $driver_message )
7857 );
7858 }
7859
7860 /**
7861 * Create a MySQL-compatible savepoint-not-found exception.
7862 *
7863 * @param string $savepoint_name The missing savepoint name, as an SQLite identifier.
7864 * @return WP_MySQL_On_SQLite_Exception
7865 */
7866 private function new_savepoint_does_not_exist_exception( string $savepoint_name ): WP_MySQL_On_SQLite_Exception {
7867 $driver_message = sprintf(
7868 'SAVEPOINT %s does not exist',
7869 $this->unquote_sqlite_identifier( $savepoint_name )
7870 );
7871 return $this->new_driver_exception(
7872 'SQLSTATE[42000]: Syntax error or access violation: 1305 ' . $driver_message,
7873 '42000',
7874 null,
7875 array( '42000', 1305, $driver_message )
7876 );
7877 }
7878
7879 /**
7880 * Create a MySQL-compatible exception for an invalid SQL mode value.
7881 *
7882 * @param mixed $value The invalid SQL mode value.
7883 * @return WP_MySQL_On_SQLite_Exception
7884 */
7885 private function new_invalid_sql_mode_exception( $value ): WP_MySQL_On_SQLite_Exception {
7886 return $this->new_driver_exception(
7887 sprintf(
7888 "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of '%s'",
7889 null === $value ? 'NULL' : (string) $value
7890 ),
7891 '42000'
7892 );
7893 }
7894
7895 /**
7896 * Create a new access denied exception for the information schema database.
7897 *
7898 * @return WP_MySQL_On_SQLite_Exception
7899 */
7900 private function new_access_denied_to_information_schema_exception(): WP_MySQL_On_SQLite_Exception {
7901 return $this->new_driver_exception(
7902 "Access denied for user 'root'@'%' to database 'information_schema'",
7903 '42000'
7904 );
7905 }
7906
7907 /**
7908 * Convert a SQLite PDO exception to a MySQL-on-SQLite driver exception.
7909 *
7910 * @param PDOException $e The SQLite PDO exception.
7911 * @return WP_MySQL_On_SQLite_Exception
7912 */
7913 private function convert_sqlite_exception( PDOException $e ): WP_MySQL_On_SQLite_Exception {
7914 $error_info = is_array( $e->errorInfo ) ? $e->errorInfo : array();
7915 $sqlstate = $error_info[0] ?? null;
7916 $error_message = $error_info[2] ?? null;
7917
7918 if ( 'HY000' === $sqlstate && 0 === strpos( $error_message, 'no such table: ' ) ) {
7919 $table_name = substr( $error_message, strlen( 'no such table: ' ) );
7920 return $this->new_table_not_found_exception( $table_name, $e );
7921 }
7922
7923 return $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e );
7924 }
7925
7926 /**
7927 * Convert an information schema exception to a MySQL-like driver exception.
7928 *
7929 * This method is used to convert some information schema exceptions to the
7930 * corresponding MySQL exceptions, as they would be generated by PDO MySQL.
7931 * This conversion mirrors PDO's error messages and SQLSTATE codes.
7932 *
7933 * @param WP_SQLite_Information_Schema_Exception $e The information schema exception.
7934 * @return Throwable The converted exception, or the original
7935 * exception if no conversion was done.
7936 */
7937 private function convert_information_schema_exception( WP_SQLite_Information_Schema_Exception $e ): Throwable {
7938 switch ( $e->get_type() ) {
7939 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_TABLE_NAME:
7940 $driver_message = sprintf( "Table '%s' already exists", $e->get_data()['table_name'] );
7941 return $this->new_driver_exception(
7942 'SQLSTATE[42S01]: Base table or view already exists: 1050 ' . $driver_message,
7943 '42S01',
7944 null,
7945 array( '42S01', 1050, $driver_message )
7946 );
7947 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_COLUMN_NAME:
7948 $driver_message = sprintf( "Duplicate column name '%s'", $e->get_data()['column_name'] );
7949 return $this->new_driver_exception(
7950 'SQLSTATE[42S21]: Column already exists: 1060 ' . $driver_message,
7951 '42S21',
7952 null,
7953 array( '42S21', 1060, $driver_message )
7954 );
7955 case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_KEY_NAME:
7956 $driver_message = sprintf( "Duplicate key name '%s'", $e->get_data()['key_name'] );
7957 return $this->new_driver_exception(
7958 'SQLSTATE[42000]: Syntax error or access violation: 1061 ' . $driver_message,
7959 '42000',
7960 null,
7961 array( '42000', 1061, $driver_message )
7962 );
7963 case WP_SQLite_Information_Schema_Exception::TYPE_KEY_COLUMN_NOT_FOUND:
7964 $driver_message = sprintf( "Key column '%s' doesn't exist in table", $e->get_data()['column_name'] );
7965 return $this->new_driver_exception(
7966 'SQLSTATE[42000]: Syntax error or access violation: 1072 ' . $driver_message,
7967 '42000',
7968 null,
7969 array( '42000', 1072, $driver_message )
7970 );
7971 case WP_SQLite_Information_Schema_Exception::TYPE_INVALID_DEFAULT_VALUE:
7972 $driver_message = sprintf( "Invalid default value for '%s'", $e->get_data()['column_name'] );
7973 return $this->new_driver_exception(
7974 'SQLSTATE[42000]: Syntax error or access violation: 1067 ' . $driver_message,
7975 '42000',
7976 null,
7977 array( '42000', 1067, $driver_message )
7978 );
7979 case WP_SQLite_Information_Schema_Exception::TYPE_CONSTRAINT_DOES_NOT_EXIST:
7980 $driver_message = sprintf( "Constraint '%s' does not exist.", $e->get_data()['name'] );
7981 return $this->new_driver_exception(
7982 'SQLSTATE[HY000]: General error: 3940 ' . $driver_message,
7983 'HY000',
7984 null,
7985 array( 'HY000', 3940, $driver_message )
7986 );
7987 case WP_SQLite_Information_Schema_Exception::TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME:
7988 $driver_message = sprintf(
7989 "Table has multiple constraints with the name '%s'. Please use constraint specific 'DROP' clause.",
7990 $e->get_data()['name']
7991 );
7992 return $this->new_driver_exception(
7993 'SQLSTATE[HY000]: General error: 3939 ' . $driver_message,
7994 'HY000',
7995 null,
7996 array( 'HY000', 3939, $driver_message )
7997 );
7998 default:
7999 return $e;
8000 }
8001 }
8002 }
8003