PluginProbe
SQLite Database Integration / 3.1.0
SQLite Database Integration v3.1.0
3.1.0 3.0.2 3.0.1 trunk 2.1.13 2.1.14 2.1.15 2.1.16 2.2.0 2.2.1 2.2.10 2.2.11 2.2.12 2.2.13 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.2.2 2.2.20 2.2.21 2.2.22 2.2.23 All 33 releases
← All changes | wp-includes/sqlite/class-wp-sqlite-db.php +329 -212 2.2.19 → 3.1.0 View file →
@@ -1,10 +1,7 @@
1 1 <?php
2 2 /**
3 3 * Extend and replace the wpdb class.
4 - *
5 - * @package wp-sqlite-integration
6 - * @since 1.0.0
7 4 */
8 5
9 6 /**
10 7 * This class extends wpdb and replaces it.
@@ -15,9 +12,9 @@
15 12
16 13 /**
17 14 * Database Handle
18 15 *
19 - * @var WP_SQLite_Translator
16 + * @var WP_MySQL_On_SQLite
20 17 */
21 18 protected $dbh;
22 19
23 20 /**
@@ -30,15 +27,8 @@
30 27 */
31 28 private $allow_unsafe_unquoted_parameters = true;
32 29
33 30 /**
34 - * The application ID for the SQLite database.
35 - *
36 - * @see https://www.sqlite.org/pragma.html#pragma_application_id
37 - */
38 - const SQLITE_DB_APPLICATION_ID = 3948349;
39 -
40 - /**
41 31 * Connects to the SQLite database.
42 32 *
43 33 * Unlike for MySQL, no credentials and host are needed.
44 34 *
@@ -58,13 +48,29 @@
58 48 * which creates a circular dependency.
59 49 */
60 50 $GLOBALS['wpdb'] = $this;
61 51
52 + // The SQLite driver may use the charset and collation while connecting.
53 + $this->init_charset();
54 +
62 55 parent::__construct( '', '', $dbname, '' );
63 - $this->charset = 'utf8mb4';
64 56 }
65 57
66 58 /**
59 + * Returns the active MySQL-on-SQLite driver.
60 + *
61 + * @return WP_MySQL_On_SQLite The active driver.
62 + * @throws RuntimeException When there is no active database connection.
63 + */
64 + public function get_driver(): WP_MySQL_On_SQLite {
65 + if ( ! $this->dbh ) {
66 + throw new RuntimeException( 'Cannot access the driver without an active database connection.' );
67 + }
68 +
69 + return $this->dbh;
70 + }
71 +
72 + /**
67 73 * Method to set character set for the database.
68 74 *
69 75 * This overrides wpdb::set_charset(), only to dummy out the MySQL function.
70 76 *
@@ -77,22 +83,64 @@
77 83 public function set_charset( $dbh, $charset = null, $collate = null ) {
78 84 }
79 85
80 86 /**
81 - * Method to get the character set for the database.
82 - * Hardcoded to utf8mb4 for now.
87 + * Retrieves the character set for the given column.
83 88 *
84 - * @param string $table The table name.
85 - * @param string $column The column name.
89 + * This overrides wpdb::get_col_charset() to enable the parent's implementation
90 + * for SQLite by temporarily setting the is_mysql flag.
86 91 *
87 - * @return string The character set.
92 + * @see wpdb::get_col_charset()
93 + *
94 + * @param string $table Table name.
95 + * @param string $column Column name.
96 + * @return string|false|WP_Error Column character set as a string. False if the column has
97 + * no character set. WP_Error object on failure.
88 98 */
89 99 public function get_col_charset( $table, $column ) {
90 - // Hardcoded for now.
91 - return 'utf8mb4';
100 + $original_is_mysql = $this->is_mysql ?? null;
101 +
102 + /*
103 + * The parent method returns early when `$this->is_mysql` is falsy.
104 + * Since SQLite doesn't set this flag, we enable it temporarily so
105 + * the parent can run its full logic — querying column metadata via
106 + * SHOW FULL COLUMNS (which the SQLite driver translates) and
107 + * populating the `$this->col_meta` cache.
108 + */
109 + try {
110 + $this->is_mysql = true;
111 + return parent::get_col_charset( $table, $column );
112 + } finally {
113 + $this->is_mysql = $original_is_mysql;
114 + }
92 115 }
93 116
94 117 /**
118 + * Retrieves the maximum string length allowed in a given column.
119 + *
120 + * This overrides wpdb::get_col_length() to enable the parent's implementation
121 + * for SQLite by temporarily setting the is_mysql flag.
122 + *
123 + * @see wpdb::get_col_length()
124 + *
125 + * @param string $table Table name.
126 + * @param string $column Column name.
127 + * @return array|false|WP_Error Column length information, false if the column has
128 + * no length. WP_Error object on failure.
129 + */
130 + public function get_col_length( $table, $column ) {
131 + $original_is_mysql = $this->is_mysql ?? null;
132 +
133 + // See get_col_charset() for an explanation of the is_mysql flag.
134 + try {
135 + $this->is_mysql = true;
136 + return parent::get_col_length( $table, $column );
137 + } finally {
138 + $this->is_mysql = $original_is_mysql;
139 + }
140 + }
141 +
142 + /**
95 143 * Changes the current SQL mode, and ensures its WordPress compatibility.
96 144 *
97 145 * If no modes are passed, it will ensure the current MySQL server modes are compatible.
98 146 *
@@ -100,14 +148,10 @@
100 148 *
101 149 * @param array $modes Optional. A list of SQL modes to set. Default empty array.
102 150 */
103 151 public function set_sql_mode( $modes = array() ) {
104 - if ( ! $this->dbh instanceof WP_SQLite_Driver ) {
105 - return;
106 - }
107 -
108 152 if ( empty( $modes ) ) {
109 - $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' );
153 + $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' )->fetchAll( PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
110 154 if ( ! isset( $result[0] ) ) {
111 155 return;
112 156 }
113 157
@@ -140,17 +184,113 @@
140 184 }
141 185
142 186 /**
143 187 * Closes the current database connection.
144 - * Noop in SQLite.
145 188 *
146 - * @return bool True to indicate the connection was successfully closed.
189 + * This overrides wpdb::close() while closely mirroring its implementation.
190 + *
191 + * @see wpdb::close()
192 + *
193 + * @return bool True if the connection was successfully closed,
194 + * false if it wasn't, or if the connection doesn't exist.
147 195 */
148 196 public function close() {
197 + if ( ! $this->dbh ) {
198 + return false;
199 + }
200 +
201 + $connection = $this->dbh->get_connection();
202 + $pdo = $connection->get_pdo();
203 +
204 + try {
205 + if ( $this->dbh->inTransaction() ) {
206 + $this->dbh->rollBack();
207 + } elseif ( $pdo->inTransaction() ) {
208 + $pdo->rollBack();
209 + } else {
210 + /*
211 + * On PHP < 8.4, PDO cannot detect transactions started via SQL.
212 + * A savepoint ensures ROLLBACK succeeds with or without one.
213 + */
214 + $pdo->exec( 'SAVEPOINT wp_sqlite_db_close' );
215 + $pdo->exec( 'ROLLBACK' );
216 + }
217 + } catch ( Throwable $e ) {
218 + return false;
219 + }
220 +
221 + if (
222 + isset( $GLOBALS['@pdo'] )
223 + && $GLOBALS['@pdo'] === $pdo
224 + ) {
225 + unset( $GLOBALS['@pdo'] );
226 + }
227 +
228 + $connection->set_query_logger( null );
229 + $this->result = null;
230 + $this->dbh = null;
231 + $this->ready = false;
232 + $this->has_connected = false;
233 +
149 234 return true;
150 235 }
151 236
152 237 /**
238 + * Sets $this->charset and $this->collate.
239 + *
240 + * This overrides wpdb::init_charset(). SQLite stores all text as UTF-8, and
241 + * the emulated MySQL connection always uses utf8mb4 (see set_charset()), so
242 + * the charset is always utf8mb4, and only a UTF-8 DB_COLLATE applies.
243 + *
244 + * Unlike in wpdb, the result doesn't depend on the database connection.
245 + * This is important, as the SQLite driver may use the charset and collation
246 + * while connecting (see the constructor).
247 + *
248 + * @see wpdb::init_charset()
249 + */
250 + public function init_charset() {
251 + $collate = defined( 'DB_COLLATE' ) ? strtolower( (string) DB_COLLATE ) : '';
252 +
253 + // MySQL 8.0.30 and newer name the utf8 collations "utf8mb3_*".
254 + $collate = preg_replace( '/^utf8mb3_/', 'utf8_', $collate );
255 +
256 + // Collations of other charsets don't apply to the utf8mb4 connection.
257 + if ( ! preg_match( '/^utf8(mb4)?_/', $collate ) ) {
258 + $collate = '';
259 + }
260 +
261 + $charset_collate = $this->resolve_charset( 'utf8mb4', $collate );
262 +
263 + $this->charset = $charset_collate['charset'];
264 + $this->collate = $charset_collate['collate'];
265 + }
266 +
267 + /**
268 + * Determines the best charset and collation to use given a charset and collation.
269 + *
270 + * For example, when able, utf8mb4 should be used instead of utf8.
271 + *
272 + * This overrides wpdb::determine_charset() while closely mirroring its implementation.
273 + * The override is needed because the parent checks for a mysqli connection object.
274 + *
275 + * @param string $charset The character set to check.
276 + * @param string $collate The collation to check.
277 + * @return array {
278 + * The most appropriate character set and collation to use.
279 + *
280 + * @type string $charset Character set.
281 + * @type string $collate Collation.
282 + * }
283 + */
284 + public function determine_charset( $charset, $collate ) {
285 + if ( ! $this->dbh ) {
286 + return compact( 'charset', 'collate' );
287 + }
288 +
289 + return $this->resolve_charset( $charset, $collate );
290 + }
291 +
292 + /**
153 293 * Method to select the database connection.
154 294 *
155 295 * This overrides wpdb::select(), only to dummy out the MySQL function.
156 296 *
@@ -172,37 +312,23 @@
172 312 *
173 313 * @param string $data The string to escape.
174 314 *
175 315 * @return string escaped
316 + * @throws RuntimeException When the database connection is not initialized.
176 317 */
177 318 public function _real_escape( $data ) {
178 319 if ( ! is_scalar( $data ) ) {
179 320 return '';
180 321 }
181 - $escaped = addslashes( $data );
182 - return $this->add_placeholder_escape( $escaped );
183 - }
184 322
185 - /**
186 - * Method to dummy out wpdb::esc_like() function.
187 - *
188 - * WordPress 4.0.0 introduced esc_like() function that adds backslashes to %,
189 - * underscore and backslash, which is not interpreted as escape character
190 - * by SQLite. So we override it and dummy out this function.
191 - *
192 - * @param string $text The raw text to be escaped. The input typed by the user should have no
193 - * extra or deleted slashes.
194 - *
195 - * @return string Text in the form of a LIKE phrase. The output is not SQL safe. Call $wpdb::prepare()
196 - * or real_escape next.
197 - */
198 - public function esc_like( $text ) {
199 - // The new driver adds "ESCAPE '\\'" to every LIKE expression by default.
200 - // We only need to overload this function to a no-op for the old driver.
201 - if ( $this->dbh instanceof WP_SQLite_Driver ) {
202 - return parent::esc_like( $text );
323 + if ( ! $this->dbh ) {
324 + throw new RuntimeException( 'Cannot escape data without an active database connection.' );
203 325 }
204 - return $text;
326 +
327 + // Escape the string without bounding quotes to mirror mysqli_real_escape_string().
328 + $quoted = $this->dbh->quote( (string) $data );
329 + $escaped = substr( $quoted, 1, -1 );
330 + return $this->add_placeholder_escape( $escaped );
205 331 }
206 332
207 333 /**
208 334 * Prints SQL/DB error.
@@ -300,89 +426,83 @@
300 426 *
301 427 * @see wpdb::db_connect()
302 428 *
303 429 * @param bool $allow_bail Not used.
304 - * @return void
430 + * @return bool True on a successful connection, false on failure.
305 431 */
306 432 public function db_connect( $allow_bail = true ) {
307 433 if ( $this->dbh ) {
308 - return;
434 + return $this->ready;
309 435 }
310 - $this->init_charset();
311 436
312 - $pdo = null;
437 + $this->last_error = '';
313 438 if ( isset( $GLOBALS['@pdo'] ) ) {
314 - $pdo = $GLOBALS['@pdo'];
439 + trigger_error(
440 + 'PDO injection via $GLOBALS[\'@pdo\'] is no longer supported. The existing PDO will be ignored and a new connection will be created.',
441 + E_USER_WARNING
442 + );
315 443 }
316 444
317 - // Migrate the database file from the legacy default name (".ht.sqlite") to
318 - // the new default name (".ht.sqlite.php"). This only runs when using the
319 - // default file name and the new file does not already exist.
320 - if ( ! defined( 'DB_FILE' ) && ! file_exists( FQDB ) ) {
321 - $old_db_path = FQDBDIR . '.ht.sqlite';
322 -
323 - if ( file_exists( $old_db_path ) ) {
324 - if ( ! rename( $old_db_path, FQDB ) ) {
325 - wp_die( 'Failed to rename database file.', 'Error!' );
326 - }
327 -
328 - foreach ( array( '-wal', '-shm' ) as $suffix ) {
329 - if ( file_exists( $old_db_path . $suffix ) ) {
330 - if ( ! rename( $old_db_path . $suffix, FQDB . $suffix ) ) {
331 - wp_die( 'Failed to rename database file.', 'Error!' );
332 - }
333 - }
334 - }
335 - }
445 + if ( null === $this->dbname || '' === $this->dbname ) {
446 + $this->bail(
447 + 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.',
448 + 'db_connect_fail'
449 + );
450 + return false;
336 451 }
337 452
338 - if ( defined( 'WP_SQLITE_AST_DRIVER' ) && WP_SQLITE_AST_DRIVER ) {
339 - if ( null === $this->dbname || '' === $this->dbname ) {
340 - $this->bail(
341 - 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.',
342 - 'db_connect_fail'
343 - );
344 - return false;
345 - }
453 + try {
454 + $options = array(
455 + 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
456 + );
457 + $dbh = new WP_MySQL_On_SQLite(
458 + sprintf(
459 + 'mysql-on-sqlite:path=%s;dbname=%s',
460 + str_replace( ';', ';;', DB_PATH ),
461 + str_replace( ';', ';;', $this->dbname )
462 + ),
463 + null,
464 + null,
465 + $options
466 + );
467 + $dbh->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
468 + $this->dbh = $dbh;
346 469
347 - require_once __DIR__ . '/../../wp-pdo-mysql-on-sqlite.php';
348 - $this->ensure_database_directory( FQDB );
349 -
350 - try {
351 - $connection = new WP_SQLite_Connection(
352 - array(
353 - 'pdo' => $pdo,
354 - 'path' => FQDB,
355 - 'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
356 - 'application_id' => self::SQLITE_DB_APPLICATION_ID,
357 - )
358 - );
359 - $this->dbh = new WP_SQLite_Driver( $connection, $this->dbname );
360 - $GLOBALS['@pdo'] = $this->dbh->get_connection()->get_pdo();
361 - } catch ( Throwable $e ) {
362 - $this->last_error = $this->format_error_message( $e );
363 - }
364 - } else {
365 - $this->dbh = new WP_SQLite_Translator( $pdo );
366 - $this->last_error = $this->dbh->get_error_message();
367 - $GLOBALS['@pdo'] = $this->dbh->get_pdo();
470 + /**
471 + * Exposes the underlying PDO SQLite connection for backward compatibility.
472 + *
473 + * @deprecated 3.0.0 Use WP_SQLite_DB::get_driver() with
474 + * WP_MySQL_On_SQLite::get_sqlite_pdo() instead.
475 + */
476 + $GLOBALS['@pdo'] = $dbh->get_sqlite_pdo();
477 + } catch ( Throwable $e ) {
478 + $this->last_error = $this->format_error_message( $e );
368 479 }
369 480 if ( $this->last_error ) {
370 481 return false;
371 482 }
483 +
484 + $this->has_connected = true;
485 + $this->set_charset( $this->dbh );
486 +
372 487 $this->ready = true;
373 488 $this->set_sql_mode();
489 + return true;
374 490 }
375 491
376 492 /**
377 - * Method to dummy out wpdb::check_connection()
493 + * Checks that the database connection is available.
378 494 *
379 495 * @param bool $allow_bail Not used.
380 496 *
381 - * @return bool
497 + * @return bool True when the connection is available, false otherwise.
382 498 */
383 499 public function check_connection( $allow_bail = true ) {
384 - return true;
500 + if ( $this->dbh ) {
501 + return true;
502 + }
503 +
504 + return $this->db_connect( $allow_bail );
385 505 }
386 506
387 507 /**
388 508 * Prepares a SQL query for safe execution.
@@ -431,8 +551,9 @@
431 551 $this->hide_errors();
432 552 }
433 553
434 554 if ( ! $this->ready ) {
555 + $this->check_current_query = true;
435 556 return false;
436 557 }
437 558
438 559 $query = apply_filters( 'query', $query );
@@ -446,24 +567,32 @@
446 567
447 568 // Log how the function was called.
448 569 $this->func_call = "\$db->query(\"$query\")";
449 570
571 + /*
572 + * Mirror wpdb's query text validation.
573 + * TODO: Add full charset enforcement to MySQL on SQLite, where column
574 + * types and SQL mode are known, so all callers are protected.
575 + */
576 + if ( $this->check_current_query && ! $this->check_ascii( $query ) ) {
577 + $stripped_query = $this->strip_invalid_text_from_query( $query );
578 + // Charset discovery can run queries, so clear their results.
579 + $this->flush();
580 + if ( $stripped_query !== $query ) {
581 + $this->insert_id = 0;
582 + $this->last_query = $query;
583 + wp_load_translations_early();
584 + $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' );
585 + return false;
586 + }
587 + }
588 + $this->check_current_query = true;
589 +
450 590 // Keep track of the last query for debug.
451 591 $this->last_query = $query;
452 592
453 - // Save the query count before running another query.
593 + // Save the query count after any charset discovery queries.
454 594 $last_query_count = count( $this->queries ?? array() );
455 -
456 - /*
457 - * @TODO: WPDB uses "$this->check_current_query" to check table/column
458 - * charset and strip all invalid characters from the query.
459 - * This is an involved process that we can bypass for SQLite,
460 - * if we simply strip all invalid UTF-8 characters from the query.
461 - *
462 - * To do so, mb_convert_encoding can be used with an optional
463 - * fallback to a htmlspecialchars method. E.g.:
464 - * https://github.com/nette/utils/blob/be534713c227aeef57ce1883fc17bc9f9e29eca2/src/Utils/Strings.php#L42
465 - */
466 595 $this->_do_query( $query );
467 596
468 597 if ( $this->last_error ) {
469 598 // Clear insert_id on a subsequent failed insert.
@@ -477,17 +606,13 @@
477 606
478 607 if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) {
479 608 $return_val = true;
480 609 } elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) {
481 - if ( $this->dbh instanceof WP_SQLite_Driver ) {
482 - $this->rows_affected = $this->dbh->get_last_return_value();
483 - } else {
484 - $this->rows_affected = $this->dbh->get_affected_rows();
485 - }
610 + $this->rows_affected = $this->result->rowCount();
486 611
487 612 // Take note of the insert_id.
488 613 if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
489 - $this->insert_id = $this->dbh->get_insert_id();
614 + $this->insert_id = (int) $this->dbh->lastInsertId();
490 615 }
491 616
492 617 // Return number of rows affected.
493 618 $return_val = $this->rows_affected;
@@ -493,11 +618,11 @@
493 618 $return_val = $this->rows_affected;
494 619 } else {
495 620 $num_rows = 0;
496 621
497 - if ( is_array( $this->result ) ) {
498 - $this->last_result = $this->result;
499 - $num_rows = count( $this->result );
622 + if ( $this->result->columnCount() > 0 ) {
623 + $this->last_result = $this->result->fetchAll();
624 + $num_rows = count( $this->last_result );
500 625 }
501 626
502 627 // Log and return the number of rows selected.
503 628 $this->num_rows = $num_rows;
@@ -526,13 +651,9 @@
526 651 $this->queries[ $i ]['result'] = (int) $return_val;
527 652 }
528 653
529 654 // Add SQLite query data.
530 - if ( $this->dbh instanceof WP_SQLite_Driver ) {
531 - $this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries();
532 - } else {
533 - $this->queries[ $i ]['sqlite_queries'] = $this->dbh->executed_sqlite_queries;
534 - }
655 + $this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries();
535 656 }
536 657 return $return_val;
537 658 }
538 659
@@ -550,17 +671,13 @@
550 671 $this->timer_start();
551 672 }
552 673
553 674 try {
554 - $this->result = $this->dbh->query( $query );
675 + $this->result = $this->dbh->query( $query, PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
555 676 } catch ( Throwable $e ) {
556 677 $this->last_error = $this->format_error_message( $e );
557 678 }
558 679
559 - if ( $this->dbh instanceof WP_SQLite_Translator ) {
560 - $this->last_error = $this->dbh->get_error_message();
561 - }
562 -
563 680 ++$this->num_queries;
564 681
565 682 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
566 683 $this->log_query(
@@ -583,122 +700,122 @@
583 700 protected function load_col_info() {
584 701 if ( $this->col_info ) {
585 702 return;
586 703 }
587 - if ( $this->dbh instanceof WP_SQLite_Driver ) {
588 - $this->col_info = array();
589 - foreach ( $this->dbh->get_last_column_meta() as $column ) {
590 - $this->col_info[] = (object) array(
591 - 'name' => $column['name'],
592 - 'orgname' => $column['mysqli:orgname'],
593 - 'table' => $column['table'],
594 - 'orgtable' => $column['mysqli:orgtable'],
595 - 'def' => '', // Unused, always ''.
596 - 'db' => $column['mysqli:db'],
597 - 'catalog' => 'def', // Unused, always 'def'.
598 - 'max_length' => 0, // As of PHP 8.1, this is always 0.
599 - 'length' => $column['len'],
600 - 'charsetnr' => $column['mysqli:charsetnr'],
601 - 'flags' => $column['mysqli:flags'],
602 - 'type' => $column['mysqli:type'],
603 - 'decimals' => $column['precision'],
604 - );
605 - }
606 - } else {
607 - $this->col_info = $this->dbh->get_columns();
704 + $this->col_info = array();
705 + if ( null === $this->result ) {
706 + return;
608 707 }
708 + for ( $i = 0; $i < $this->result->columnCount(); $i++ ) {
709 + $column = $this->result->getColumnMeta( $i );
710 + $this->col_info[] = (object) array(
711 + 'name' => $column['name'],
712 + 'orgname' => $column['mysqli:orgname'],
713 + 'table' => $column['table'],
714 + 'orgtable' => $column['mysqli:orgtable'],
715 + 'def' => '', // Unused, always ''.
716 + 'db' => $column['mysqli:db'],
717 + 'catalog' => 'def', // Unused, always 'def'.
718 + 'max_length' => 0, // As of PHP 8.1, this is always 0.
719 + 'length' => $column['len'],
720 + 'charsetnr' => $column['mysqli:charsetnr'],
721 + 'flags' => $column['mysqli:flags'],
722 + 'type' => $column['mysqli:type'],
723 + 'decimals' => $column['precision'],
724 + );
725 + }
609 726 }
610 727
611 728 /**
612 - * Method to return what the database can do.
729 + * Determines whether the database supports a given feature.
613 730 *
614 - * This overrides wpdb::has_cap() to avoid using MySQL functions.
615 - * SQLite supports subqueries, but not support collation, group_concat and set_charset.
731 + * The utf8mb4 check is handled here because older WordPress versions inspect
732 + * the MySQL client library. All other capabilities use the parent logic.
616 733 *
617 734 * @see wpdb::has_cap()
618 735 *
619 - * @param string $db_cap The feature to check for. Accepts 'collation',
620 - * 'group_concat', 'subqueries', 'set_charset',
621 - * 'utf8mb4', or 'utf8mb4_520'.
622 - *
623 - * @return bool Whether the database feature is supported, false otherwise.
736 + * @param string $db_cap The feature to check for.
737 + * @return bool True when the database feature is supported, false otherwise.
624 738 */
625 739 public function has_cap( $db_cap ) {
626 - return 'subqueries' === strtolower( $db_cap );
740 + if ( 'utf8mb4' === strtolower( $db_cap ) ) {
741 + return true;
742 + }
743 +
744 + return parent::has_cap( $db_cap );
627 745 }
628 746
629 747 /**
630 - * Method to return database version number.
748 + * Retrieves the emulated database server version number.
631 749 *
632 - * This overrides wpdb::db_version() to avoid using MySQL function.
633 - * It returns mysql version number, but it means nothing for SQLite.
634 - * So it return the newest mysql version.
750 + * This mirrors wpdb::db_version(), but must also be defined here because
751 + * WordPress 5.4 and older fetch server information directly from the MySQL
752 + * extension instead of delegating to wpdb::db_server_info().
635 753 *
636 754 * @see wpdb::db_version()
755 + *
756 + * @return string Version number on success, or an empty string while disconnected.
637 757 */
638 758 public function db_version() {
639 - return '8.0';
759 + return preg_replace( '/[^0-9.].*/', '', $this->db_server_info() );
640 760 }
641 761
642 762 /**
643 - * Returns the version of the SQLite engine.
763 + * Returns the raw version string of the emulated MySQL server.
644 764 *
645 - * @return string SQLite engine version as a string.
765 + * @see wpdb::db_server_info()
766 + *
767 + * @return string Emulated MySQL server version, or an empty string while disconnected.
646 768 */
647 769 public function db_server_info() {
648 - return $this->dbh->get_sqlite_version();
770 + if ( ! $this->dbh ) {
771 + return '';
772 + }
773 +
774 + return $this->dbh->getAttribute( PDO::ATTR_SERVER_VERSION ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
649 775 }
650 776
651 777 /**
652 - * Make sure the SQLite database directory exists and is writable.
653 - * Create .htaccess and index.php files to prevent direct access.
778 + * Resolves the charset and collation as wpdb::determine_charset() does.
654 779 *
655 - * @param string $database_path The path to the SQLite database file.
780 + * Unlike in wpdb, this doesn't need a database connection. The emulated
781 + * MySQL server (5.7 or newer) always supports utf8mb4_unicode_520_ci.
782 + *
783 + * @see wpdb::determine_charset()
784 + *
785 + * @param string $charset The character set to check.
786 + * @param string $collate The collation to check.
787 + * @return array {
788 + * The most appropriate character set and collation to use.
789 + *
790 + * @type string $charset Character set.
791 + * @type string $collate Collation.
792 + * }
656 793 */
657 - private function ensure_database_directory( string $database_path ) {
658 - $dir = dirname( $database_path );
659 -
660 - // Set the umask to 0000 to apply permissions exactly as specified.
661 - // A non-zero umask affects new file and directory permissions.
662 - $umask = umask( 0 );
663 -
664 - // Ensure database directory.
665 - if ( ! is_dir( $dir ) ) {
666 - if ( ! @mkdir( $dir, 0700, true ) ) {
667 - wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' );
668 - }
794 + private function resolve_charset( $charset, $collate ) {
795 + if ( 'utf8' === $charset ) {
796 + $charset = 'utf8mb4';
669 797 }
670 - if ( ! is_writable( $dir ) ) {
671 - wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' );
672 - }
673 798
674 - // Ensure .htaccess file to prevent direct access.
675 - $path = $dir . DIRECTORY_SEPARATOR . '.htaccess';
676 - if ( ! is_file( $path ) ) {
677 - $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX );
678 - if ( false === $result ) {
679 - wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
799 + if ( 'utf8mb4' === $charset ) {
800 + // _general_ is outdated, so we can upgrade it to _unicode_, instead.
801 + if ( ! $collate || 'utf8_general_ci' === $collate ) {
802 + $collate = 'utf8mb4_unicode_ci';
803 + } else {
804 + $collate = str_replace( 'utf8_', 'utf8mb4_', $collate );
680 805 }
681 - chmod( $path, 0600 );
682 806 }
683 807
684 - // Ensure index.php file to prevent direct access.
685 - $path = $dir . DIRECTORY_SEPARATOR . 'index.php';
686 - if ( ! is_file( $path ) ) {
687 - $result = file_put_contents( $path, '<?php // Silence is gold. ?>', LOCK_EX );
688 - if ( false === $result ) {
689 - wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
690 - }
691 - chmod( $path, 0600 );
808 + // _unicode_520_ is a better collation, we should use that when it's available.
809 + if ( 'utf8mb4_unicode_ci' === $collate ) {
810 + $collate = 'utf8mb4_unicode_520_ci';
692 811 }
693 812
694 - // Restore the original umask value.
695 - umask( $umask );
813 + return compact( 'charset', 'collate' );
696 814 }
697 815
698 -
699 816 /**
700 - * Format SQLite driver error message.
817 + * Format MySQL-on-SQLite driver error message.
701 818 *
702 819 * @return string
703 820 */
704 821 private function format_error_message( Throwable $e ) {
@@ -704,10 +821,10 @@
704 821 private function format_error_message( Throwable $e ) {
705 822 $output = '<div style="clear:both">&nbsp;</div>' . PHP_EOL;
706 823
707 824 // Queries.
708 - if ( $e instanceof WP_SQLite_Driver_Exception ) {
709 - $driver = $e->getDriver();
825 + if ( $e instanceof WP_MySQL_On_SQLite_Exception ) {
826 + $driver = $e->get_driver();
710 827
711 828 $output .= '<div class="queries" style="clear:both;margin-bottom:2px;border:red dotted thin;">' . PHP_EOL;
712 829 $output .= '<p>MySQL query:</p>' . PHP_EOL;
713 830 $output .= '<p>' . $driver->get_last_mysql_query() . '</p>' . PHP_EOL;