PluginProbe
SQLite Database Integration / 3.0.1
SQLite Database Integration v3.0.1
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.1, at wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php

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