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

class-wp-sqlite-db.php in SQLite Database Integration 3.0.2, at wp-includes/sqlite/class-wp-sqlite-db.php

872 lines 25.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Extend and replace the wpdb class.
4 */
5
6 /**
7 * This class extends wpdb and replaces it.
8 *
9 * It also rewrites some methods that use mysql specific functions.
10 */
11 class WP_SQLite_DB extends wpdb {
12
13 /**
14 * Database Handle
15 *
16 * @var WP_MySQL_On_SQLite
17 */
18 protected $dbh;
19
20 /**
21 * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters.
22 *
23 * This property is mirroring "wpdb::$allow_unsafe_unquoted_parameters",
24 * because some tests are accessing it externally using PHP reflection.
25 *
26 * @var
27 */
28 private $allow_unsafe_unquoted_parameters = true;
29
30 /**
31 * Connects to the SQLite database.
32 *
33 * Unlike for MySQL, no credentials and host are needed.
34 *
35 * @param string $dbname Database name.
36 */
37 public function __construct( $dbname ) {
38 /**
39 * We need to initialize the "$wpdb" global early, so that the SQLite
40 * driver can configure the database. The call stack goes like this:
41 *
42 * 1. The "parent::__construct()" call executes "$this->db_connect()".
43 * 2. The database connection call initializes the SQLite driver.
44 * 3. The SQLite driver initializes and runs "WP_SQLite_Configurator".
45 * 4. The configurator uses "WP_SQLite_Information_Schema_Reconstructor",
46 * which requires "wp-admin/includes/schema.php" when in WordPress.
47 * 5. The "wp-admin/includes/schema.php" requires the "$wpdb" global,
48 * which creates a circular dependency.
49 */
50 $GLOBALS['wpdb'] = $this;
51
52 parent::__construct( '', '', $dbname, '' );
53 $this->charset = 'utf8mb4';
54 }
55
56 /**
57 * Returns the active MySQL-on-SQLite driver.
58 *
59 * @return WP_MySQL_On_SQLite The active driver.
60 * @throws RuntimeException When there is no active database connection.
61 */
62 public function get_driver(): WP_MySQL_On_SQLite {
63 if ( ! $this->dbh ) {
64 throw new RuntimeException( 'Cannot access the driver without an active database connection.' );
65 }
66
67 return $this->dbh;
68 }
69
70 /**
71 * Method to set character set for the database.
72 *
73 * This overrides wpdb::set_charset(), only to dummy out the MySQL function.
74 *
75 * @see wpdb::set_charset()
76 *
77 * @param resource $dbh The resource given by mysql_connect.
78 * @param string $charset Optional. The character set. Default null.
79 * @param string $collate Optional. The collation. Default null.
80 */
81 public function set_charset( $dbh, $charset = null, $collate = null ) {
82 }
83
84 /**
85 * Retrieves the character set for the given column.
86 *
87 * This overrides wpdb::get_col_charset() to enable the parent's implementation
88 * for SQLite by temporarily setting the is_mysql flag.
89 *
90 * @see wpdb::get_col_charset()
91 *
92 * @param string $table Table name.
93 * @param string $column Column name.
94 * @return string|false|WP_Error Column character set as a string. False if the column has
95 * no character set. WP_Error object on failure.
96 */
97 public function get_col_charset( $table, $column ) {
98 $original_is_mysql = $this->is_mysql ?? null;
99
100 /*
101 * The parent method returns early when `$this->is_mysql` is falsy.
102 * Since SQLite doesn't set this flag, we enable it temporarily so
103 * the parent can run its full logic — querying column metadata via
104 * SHOW FULL COLUMNS (which the SQLite driver translates) and
105 * populating the `$this->col_meta` cache.
106 */
107 try {
108 $this->is_mysql = true;
109 return parent::get_col_charset( $table, $column );
110 } finally {
111 $this->is_mysql = $original_is_mysql;
112 }
113 }
114
115 /**
116 * Retrieves the maximum string length allowed in a given column.
117 *
118 * This overrides wpdb::get_col_length() to enable the parent's implementation
119 * for SQLite by temporarily setting the is_mysql flag.
120 *
121 * @see wpdb::get_col_length()
122 *
123 * @param string $table Table name.
124 * @param string $column Column name.
125 * @return array|false|WP_Error Column length information, false if the column has
126 * no length. WP_Error object on failure.
127 */
128 public function get_col_length( $table, $column ) {
129 $original_is_mysql = $this->is_mysql ?? null;
130
131 // See get_col_charset() for an explanation of the is_mysql flag.
132 try {
133 $this->is_mysql = true;
134 return parent::get_col_length( $table, $column );
135 } finally {
136 $this->is_mysql = $original_is_mysql;
137 }
138 }
139
140 /**
141 * Changes the current SQL mode, and ensures its WordPress compatibility.
142 *
143 * If no modes are passed, it will ensure the current MySQL server modes are compatible.
144 *
145 * This overrides wpdb::set_sql_mode() while closely mirroring its implementation.
146 *
147 * @param array $modes Optional. A list of SQL modes to set. Default empty array.
148 */
149 public function set_sql_mode( $modes = array() ) {
150 if ( empty( $modes ) ) {
151 $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' )->fetchAll( PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
152 if ( ! isset( $result[0] ) ) {
153 return;
154 }
155
156 $modes_str = $result[0]->{'@@SESSION.sql_mode'};
157 if ( empty( $modes_str ) ) {
158 return;
159 }
160 $modes = explode( ',', $modes_str );
161 }
162
163 $modes = array_change_key_case( $modes, CASE_UPPER );
164
165 /**
166 * Filters the list of incompatible SQL modes to exclude.
167 *
168 * @since 3.9.0
169 *
170 * @param array $incompatible_modes An array of incompatible modes.
171 */
172 $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $this->incompatible_modes );
173
174 foreach ( $modes as $i => $mode ) {
175 if ( in_array( $mode, $incompatible_modes, true ) ) {
176 unset( $modes[ $i ] );
177 }
178 }
179 $modes_str = implode( ',', $modes );
180
181 $this->dbh->query( "SET SESSION sql_mode='$modes_str'" );
182 }
183
184 /**
185 * Closes the current database connection.
186 *
187 * This overrides wpdb::close() while closely mirroring its implementation.
188 *
189 * @see wpdb::close()
190 *
191 * @return bool True if the connection was successfully closed,
192 * false if it wasn't, or if the connection doesn't exist.
193 */
194 public function close() {
195 if ( ! $this->dbh ) {
196 return false;
197 }
198
199 $connection = $this->dbh->get_connection();
200 $pdo = $connection->get_pdo();
201
202 try {
203 if ( $this->dbh->inTransaction() ) {
204 $this->dbh->rollBack();
205 } elseif ( $pdo->inTransaction() ) {
206 $pdo->rollBack();
207 } else {
208 /*
209 * On PHP < 8.4, PDO cannot detect transactions started via SQL.
210 * A savepoint ensures ROLLBACK succeeds with or without one.
211 */
212 $pdo->exec( 'SAVEPOINT wp_sqlite_db_close' );
213 $pdo->exec( 'ROLLBACK' );
214 }
215 } catch ( Throwable $e ) {
216 return false;
217 }
218
219 if (
220 isset( $GLOBALS['@pdo'] )
221 && $GLOBALS['@pdo'] === $pdo
222 ) {
223 unset( $GLOBALS['@pdo'] );
224 }
225
226 $connection->set_query_logger( null );
227 $this->result = null;
228 $this->dbh = null;
229 $this->ready = false;
230 $this->has_connected = false;
231
232 return true;
233 }
234
235 /**
236 * Determines the best charset and collation to use given a charset and collation.
237 *
238 * For example, when able, utf8mb4 should be used instead of utf8.
239 *
240 * This overrides wpdb::determine_charset() while closely mirroring its implementation.
241 * The override is needed because the parent checks for a mysqli connection object.
242 *
243 * @param string $charset The character set to check.
244 * @param string $collate The collation to check.
245 * @return array {
246 * The most appropriate character set and collation to use.
247 *
248 * @type string $charset Character set.
249 * @type string $collate Collation.
250 * }
251 */
252 public function determine_charset( $charset, $collate ) {
253 if ( ! $this->dbh ) {
254 return compact( 'charset', 'collate' );
255 }
256
257 if ( 'utf8' === $charset ) {
258 $charset = 'utf8mb4';
259 }
260
261 if ( 'utf8mb4' === $charset ) {
262 // _general_ is outdated, so we can upgrade it to _unicode_, instead.
263 if ( ! $collate || 'utf8_general_ci' === $collate ) {
264 $collate = 'utf8mb4_unicode_ci';
265 } else {
266 $collate = str_replace( 'utf8_', 'utf8mb4_', $collate );
267 }
268 }
269
270 // _unicode_520_ is a better collation, we should use that when it's available.
271 if ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) {
272 $collate = 'utf8mb4_unicode_520_ci';
273 }
274
275 return compact( 'charset', 'collate' );
276 }
277
278 /**
279 * Method to select the database connection.
280 *
281 * This overrides wpdb::select(), only to dummy out the MySQL function.
282 *
283 * @see wpdb::select()
284 *
285 * @param string $db MySQL database name. Not used.
286 * @param resource|null $dbh Optional link identifier.
287 */
288 public function select( $db, $dbh = null ) {
289 $this->ready = true;
290 }
291
292 /**
293 * Method to escape characters.
294 *
295 * This overrides wpdb::_real_escape() to avoid using mysql_real_escape_string().
296 *
297 * @see wpdb::_real_escape()
298 *
299 * @param string $data The string to escape.
300 *
301 * @return string escaped
302 * @throws RuntimeException When the database connection is not initialized.
303 */
304 public function _real_escape( $data ) {
305 if ( ! is_scalar( $data ) ) {
306 return '';
307 }
308
309 if ( ! $this->dbh ) {
310 throw new RuntimeException( 'Cannot escape data without an active database connection.' );
311 }
312
313 // Escape the string without bounding quotes to mirror mysqli_real_escape_string().
314 $quoted = $this->dbh->quote( (string) $data );
315 $escaped = substr( $quoted, 1, -1 );
316 return $this->add_placeholder_escape( $escaped );
317 }
318
319 /**
320 * Prints SQL/DB error.
321 *
322 * This overrides wpdb::print_error() while closely mirroring its implementation.
323 *
324 * @global array $EZSQL_ERROR Stores error information of query and error string.
325 *
326 * @param string $str The error to display.
327 * @return void|false Void if the showing of errors is enabled, false if disabled.
328 */
329 public function print_error( $str = '' ) {
330 global $EZSQL_ERROR;
331
332 if ( ! $str ) {
333 $str = $this->last_error;
334 }
335
336 $EZSQL_ERROR[] = array(
337 'query' => $this->last_query,
338 'error_str' => $str,
339 );
340
341 if ( $this->suppress_errors ) {
342 return false;
343 }
344
345 $caller = $this->get_caller();
346 if ( $caller ) {
347 // Not translated, as this will only appear in the error log.
348 $error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller );
349 } else {
350 $error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query );
351 }
352
353 error_log( $error_str );
354
355 // Are we showing errors?
356 if ( ! $this->show_errors ) {
357 return false;
358 }
359
360 wp_load_translations_early();
361
362 // If there is an error then take note of it.
363 if ( is_multisite() ) {
364 $msg = sprintf(
365 "%s [%s]\n%s\n",
366 __( 'WordPress database error:' ),
367 $str,
368 $this->last_query
369 );
370
371 if ( defined( 'ERRORLOGFILE' ) ) {
372 error_log( $msg, 3, ERRORLOGFILE );
373 }
374 if ( defined( 'DIEONDBERROR' ) ) {
375 wp_die( $msg );
376 }
377 } else {
378 $str = htmlspecialchars( $str, ENT_QUOTES );
379 $query = htmlspecialchars( $this->last_query, ENT_QUOTES );
380
381 printf(
382 '<div id="error"><p class="wpdberror"><strong>%s</strong> [%s]<br /><code>%s</code></p></div>',
383 __( 'WordPress database error:' ),
384 $str,
385 $query
386 );
387 }
388 }
389
390 /**
391 * Method to flush cached data.
392 *
393 * This overrides wpdb::flush(). This is not necessarily overridden, because
394 * $result will never be resource.
395 *
396 * @see wpdb::flush
397 */
398 public function flush() {
399 $this->last_result = array();
400 $this->col_info = null;
401 $this->last_query = null;
402 $this->rows_affected = 0;
403 $this->num_rows = 0;
404 $this->last_error = '';
405 $this->result = null;
406 }
407
408 /**
409 * Method to do the database connection.
410 *
411 * This overrides wpdb::db_connect() to avoid using MySQL function.
412 *
413 * @see wpdb::db_connect()
414 *
415 * @param bool $allow_bail Not used.
416 * @return bool True on a successful connection, false on failure.
417 */
418 public function db_connect( $allow_bail = true ) {
419 if ( $this->dbh ) {
420 return $this->ready;
421 }
422
423 $this->last_error = '';
424 if ( isset( $GLOBALS['@pdo'] ) ) {
425 trigger_error(
426 'PDO injection via $GLOBALS[\'@pdo\'] is no longer supported. The existing PDO will be ignored and a new connection will be created.',
427 E_USER_WARNING
428 );
429 }
430
431 if ( ! isset( $this->charset ) ) {
432 $this->init_charset();
433 }
434
435 // Migrate the database file from a legacy path, if it exists.
436 if ( ! defined( 'DB_FILE' ) && ! file_exists( FQDB ) ) {
437 $old_db_path = FQDBDIR . '.ht.sqlite.php';
438
439 if ( file_exists( $old_db_path ) ) {
440 if ( ! rename( $old_db_path, FQDB ) ) {
441 wp_die( 'Failed to rename database file.', 'Error!' );
442 }
443
444 foreach ( array( '-wal', '-shm', '-journal' ) as $suffix ) {
445 if ( file_exists( $old_db_path . $suffix ) ) {
446 if ( ! rename( $old_db_path . $suffix, FQDB . $suffix ) ) {
447 wp_die( 'Failed to rename database file.', 'Error!' );
448 }
449 }
450 }
451 }
452 }
453
454 if ( null === $this->dbname || '' === $this->dbname ) {
455 $this->bail(
456 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.',
457 'db_connect_fail'
458 );
459 return false;
460 }
461
462 $this->ensure_database_directory( FQDB );
463
464 try {
465 $options = array(
466 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
467 );
468 $dbh = new WP_MySQL_On_SQLite(
469 sprintf(
470 'mysql-on-sqlite:path=%s;dbname=%s',
471 str_replace( ';', ';;', FQDB ),
472 str_replace( ';', ';;', $this->dbname )
473 ),
474 null,
475 null,
476 $options
477 );
478 $dbh->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
479 $this->dbh = $dbh;
480
481 /**
482 * Exposes the underlying PDO SQLite connection for backward compatibility.
483 *
484 * @deprecated 3.0.0 Use WP_SQLite_DB::get_driver() with
485 * WP_MySQL_On_SQLite::get_sqlite_pdo() instead.
486 */
487 $GLOBALS['@pdo'] = $dbh->get_sqlite_pdo();
488 } catch ( Throwable $e ) {
489 $this->last_error = $this->format_error_message( $e );
490 }
491 if ( $this->last_error ) {
492 return false;
493 }
494
495 $this->has_connected = true;
496 $this->set_charset( $this->dbh );
497
498 $this->ready = true;
499 $this->set_sql_mode();
500 return true;
501 }
502
503 /**
504 * Checks that the database connection is available.
505 *
506 * @param bool $allow_bail Not used.
507 *
508 * @return bool True when the connection is available, false otherwise.
509 */
510 public function check_connection( $allow_bail = true ) {
511 if ( $this->dbh ) {
512 return true;
513 }
514
515 return $this->db_connect( $allow_bail );
516 }
517
518 /**
519 * Prepares a SQL query for safe execution.
520 *
521 * See "wpdb::prepare()". This override only fixes a WPDB test issue.
522 *
523 * @param string $query Query statement with `sprintf()`-like placeholders.
524 * @param array|mixed $args The array of variables or the first variable to substitute.
525 * @param mixed ...$args Further variables to substitute when using individual arguments.
526 * @return string|void Sanitized query string, if there is a query to prepare.
527 */
528 public function prepare( $query, ...$args ) {
529 /*
530 * Sync "$allow_unsafe_unquoted_parameters" with the WPDB parent property.
531 * This is only needed because some WPDB tests are accessing the private
532 * property externally via PHP reflection. This should be fixed WP tests.
533 */
534 $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' );
535 if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) {
536 $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' );
537 $property->setAccessible( true );
538 $property->setValue( $this, $this->allow_unsafe_unquoted_parameters );
539 $property->setAccessible( false );
540 }
541
542 return parent::prepare( $query, ...$args );
543 }
544
545 /**
546 * Performs a database query.
547 *
548 * This overrides wpdb::query() while closely mirroring its implementation.
549 *
550 * @see wpdb::query()
551 *
552 * @param string $query Database query.
553 *
554 * @param string $query Database query.
555 * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows
556 * affected/selected for all other queries. Boolean false on error.
557 */
558 public function query( $query ) {
559 // Query Monitor integration:
560 $query_monitor_active = defined( 'SQLITE_QUERY_MONITOR_LOADED' ) && SQLITE_QUERY_MONITOR_LOADED;
561 if ( $query_monitor_active && $this->show_errors ) {
562 $this->hide_errors();
563 }
564
565 if ( ! $this->ready ) {
566 $this->check_current_query = true;
567 return false;
568 }
569
570 $query = apply_filters( 'query', $query );
571
572 if ( ! $query ) {
573 $this->insert_id = 0;
574 return false;
575 }
576
577 $this->flush();
578
579 // Log how the function was called.
580 $this->func_call = "\$db->query(\"$query\")";
581
582 /*
583 * Mirror wpdb's query text validation.
584 * TODO: Add full charset enforcement to MySQL on SQLite, where column
585 * types and SQL mode are known, so all callers are protected.
586 */
587 if ( $this->check_current_query && ! $this->check_ascii( $query ) ) {
588 $stripped_query = $this->strip_invalid_text_from_query( $query );
589 // Charset discovery can run queries, so clear their results.
590 $this->flush();
591 if ( $stripped_query !== $query ) {
592 $this->insert_id = 0;
593 $this->last_query = $query;
594 wp_load_translations_early();
595 $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' );
596 return false;
597 }
598 }
599 $this->check_current_query = true;
600
601 // Keep track of the last query for debug.
602 $this->last_query = $query;
603
604 // Save the query count after any charset discovery queries.
605 $last_query_count = count( $this->queries ?? array() );
606 $this->_do_query( $query );
607
608 if ( $this->last_error ) {
609 // Clear insert_id on a subsequent failed insert.
610 if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
611 $this->insert_id = 0;
612 }
613
614 $this->print_error();
615 return false;
616 }
617
618 if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) {
619 $return_val = true;
620 } elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) {
621 $this->rows_affected = $this->result->rowCount();
622
623 // Take note of the insert_id.
624 if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
625 $this->insert_id = (int) $this->dbh->lastInsertId();
626 }
627
628 // Return number of rows affected.
629 $return_val = $this->rows_affected;
630 } else {
631 $num_rows = 0;
632
633 if ( $this->result->columnCount() > 0 ) {
634 $this->last_result = $this->result->fetchAll();
635 $num_rows = count( $this->last_result );
636 }
637
638 // Log and return the number of rows selected.
639 $this->num_rows = $num_rows;
640 $return_val = $num_rows;
641 }
642
643 // Query monitor integration:
644 if ( $query_monitor_active && class_exists( 'QM_Backtrace' ) ) {
645 if ( did_action( 'qm/cease' ) ) {
646 $this->queries = array();
647 }
648
649 $i = $last_query_count;
650 if ( ! isset( $this->queries[ $i ] ) ) {
651 return $return_val;
652 }
653
654 $this->queries[ $i ]['trace'] = new QM_Backtrace();
655 if ( ! isset( $this->queries[ $i ][3] ) ) {
656 $this->queries[ $i ][3] = $this->time_start;
657 }
658
659 if ( $this->last_error && ! $this->suppress_errors ) {
660 $this->queries[ $i ]['result'] = new WP_Error( 'qmdb', $this->last_error );
661 } else {
662 $this->queries[ $i ]['result'] = (int) $return_val;
663 }
664
665 // Add SQLite query data.
666 $this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries();
667 }
668 return $return_val;
669 }
670
671 /**
672 * Internal function to perform the SQLite query call.
673 *
674 * This closely mirrors wpdb::_do_query().
675 *
676 * @see wpdb::_do_query()
677 *
678 * @param string $query The query to run.
679 */
680 private function _do_query( $query ) {
681 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
682 $this->timer_start();
683 }
684
685 try {
686 $this->result = $this->dbh->query( $query, PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
687 } catch ( Throwable $e ) {
688 $this->last_error = $this->format_error_message( $e );
689 }
690
691 ++$this->num_queries;
692
693 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
694 $this->log_query(
695 $query,
696 $this->timer_stop(),
697 $this->get_caller(),
698 $this->time_start,
699 array()
700 );
701 }
702 }
703
704 /**
705 * Method to set the class variable $col_info.
706 *
707 * This overrides wpdb::load_col_info(), which uses a mysql function.
708 *
709 * @see wpdb::load_col_info()
710 */
711 protected function load_col_info() {
712 if ( $this->col_info ) {
713 return;
714 }
715 $this->col_info = array();
716 if ( null === $this->result ) {
717 return;
718 }
719 for ( $i = 0; $i < $this->result->columnCount(); $i++ ) {
720 $column = $this->result->getColumnMeta( $i );
721 $this->col_info[] = (object) array(
722 'name' => $column['name'],
723 'orgname' => $column['mysqli:orgname'],
724 'table' => $column['table'],
725 'orgtable' => $column['mysqli:orgtable'],
726 'def' => '', // Unused, always ''.
727 'db' => $column['mysqli:db'],
728 'catalog' => 'def', // Unused, always 'def'.
729 'max_length' => 0, // As of PHP 8.1, this is always 0.
730 'length' => $column['len'],
731 'charsetnr' => $column['mysqli:charsetnr'],
732 'flags' => $column['mysqli:flags'],
733 'type' => $column['mysqli:type'],
734 'decimals' => $column['precision'],
735 );
736 }
737 }
738
739 /**
740 * Determines whether the database supports a given feature.
741 *
742 * The utf8mb4 check is handled here because older WordPress versions inspect
743 * the MySQL client library. All other capabilities use the parent logic.
744 *
745 * @see wpdb::has_cap()
746 *
747 * @param string $db_cap The feature to check for.
748 * @return bool True when the database feature is supported, false otherwise.
749 */
750 public function has_cap( $db_cap ) {
751 if ( 'utf8mb4' === strtolower( $db_cap ) ) {
752 return true;
753 }
754
755 return parent::has_cap( $db_cap );
756 }
757
758 /**
759 * Retrieves the emulated database server version number.
760 *
761 * This mirrors wpdb::db_version(), but must also be defined here because
762 * WordPress 5.4 and older fetch server information directly from the MySQL
763 * extension instead of delegating to wpdb::db_server_info().
764 *
765 * @see wpdb::db_version()
766 *
767 * @return string Version number on success, or an empty string while disconnected.
768 */
769 public function db_version() {
770 return preg_replace( '/[^0-9.].*/', '', $this->db_server_info() );
771 }
772
773 /**
774 * Returns the raw version string of the emulated MySQL server.
775 *
776 * @see wpdb::db_server_info()
777 *
778 * @return string Emulated MySQL server version, or an empty string while disconnected.
779 */
780 public function db_server_info() {
781 if ( ! $this->dbh ) {
782 return '';
783 }
784
785 return $this->dbh->getAttribute( PDO::ATTR_SERVER_VERSION ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
786 }
787
788 /**
789 * Make sure the SQLite database directory exists and is writable.
790 * Create .htaccess and index.php files to prevent direct access.
791 *
792 * @param string $database_path The path to the SQLite database file.
793 */
794 private function ensure_database_directory( string $database_path ) {
795 $dir = dirname( $database_path );
796
797 // Set the umask to 0000 to apply permissions exactly as specified.
798 // A non-zero umask affects new file and directory permissions.
799 $umask = umask( 0 );
800
801 // Ensure database directory.
802 if ( ! is_dir( $dir ) ) {
803 if ( ! @mkdir( $dir, 0700, true ) ) {
804 wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' );
805 }
806 }
807 if ( ! is_writable( $dir ) ) {
808 wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' );
809 }
810
811 // Ensure .htaccess file to prevent direct access.
812 $path = $dir . DIRECTORY_SEPARATOR . '.htaccess';
813 if ( ! is_file( $path ) ) {
814 $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX );
815 if ( false === $result ) {
816 wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
817 }
818 chmod( $path, 0600 );
819 }
820
821 // Ensure index.php file to prevent direct access.
822 $path = $dir . DIRECTORY_SEPARATOR . 'index.php';
823 if ( ! is_file( $path ) ) {
824 $result = file_put_contents( $path, '<?php // Silence is gold. ?>', LOCK_EX );
825 if ( false === $result ) {
826 wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
827 }
828 chmod( $path, 0600 );
829 }
830
831 // Restore the original umask value.
832 umask( $umask );
833 }
834
835
836 /**
837 * Format MySQL-on-SQLite driver error message.
838 *
839 * @return string
840 */
841 private function format_error_message( Throwable $e ) {
842 $output = '<div style="clear:both">&nbsp;</div>' . PHP_EOL;
843
844 // Queries.
845 if ( $e instanceof WP_MySQL_On_SQLite_Exception ) {
846 $driver = $e->get_driver();
847
848 $output .= '<div class="queries" style="clear:both;margin-bottom:2px;border:red dotted thin;">' . PHP_EOL;
849 $output .= '<p>MySQL query:</p>' . PHP_EOL;
850 $output .= '<p>' . $driver->get_last_mysql_query() . '</p>' . PHP_EOL;
851 $output .= '<p>Queries made or created this session were:</p>' . PHP_EOL;
852 $output .= '<ol>' . PHP_EOL;
853 foreach ( $driver->get_last_sqlite_queries() as $q ) {
854 $message = "Executing: {$q['sql']} | " . ( $q['params'] ? 'parameters: ' . implode( ', ', $q['params'] ) : '(no parameters)' );
855 $output .= '<li>' . htmlspecialchars( $message ) . '</li>' . PHP_EOL;
856 }
857 $output .= '</ol>' . PHP_EOL;
858 $output .= '</div>' . PHP_EOL;
859 }
860
861 // Message.
862 $output .= '<div style="clear:both;margin-bottom:2px;border:red dotted thin;" class="error_message" style="border-bottom:dotted blue thin;">' . PHP_EOL;
863 $output .= $e->getMessage() . PHP_EOL;
864 $output .= '</div>' . PHP_EOL;
865
866 // Backtrace.
867 $output .= '<p>Backtrace:</p>' . PHP_EOL;
868 $output .= '<pre>' . $e->getTraceAsString() . '</pre>' . PHP_EOL;
869 return $output;
870 }
871 }
872