PluginProbe
SQLite Database Integration / trunk
SQLite Database Integration vtrunk
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
← All changes | wp-includes/sqlite/class-wp-sqlite-db.php +623 -115 2.1.15trunk 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,23 +12,63 @@
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 /**
24 - * Constructor
21 + * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters.
25 22 *
26 - * Unlike wpdb, no credentials are needed.
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 27 */
28 - public function __construct() {
29 - parent::__construct( '', '', '', '' );
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, '' );
30 53 $this->charset = 'utf8mb4';
31 54 }
32 55
33 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 + /**
34 71 * Method to set character set for the database.
35 72 *
36 73 * This overrides wpdb::set_charset(), only to dummy out the MySQL function.
37 74 *
@@ -44,42 +81,202 @@
44 81 public function set_charset( $dbh, $charset = null, $collate = null ) {
45 82 }
46 83
47 84 /**
48 - * Method to get the character set for the database.
49 - * Hardcoded to utf8mb4 for now.
85 + * Retrieves the character set for the given column.
50 86 *
51 - * @param string $table The table name.
52 - * @param string $column The column name.
87 + * This overrides wpdb::get_col_charset() to enable the parent's implementation
88 + * for SQLite by temporarily setting the is_mysql flag.
53 89 *
54 - * @return string The character set.
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.
55 96 */
56 97 public function get_col_charset( $table, $column ) {
57 - // Hardcoded for now.
58 - return 'utf8mb4';
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 + }
59 113 }
60 114
61 115 /**
62 - * Method to dummy out wpdb::set_sql_mode()
116 + * Retrieves the maximum string length allowed in a given column.
63 117 *
64 - * @see wpdb::set_sql_mode()
118 + * This overrides wpdb::get_col_length() to enable the parent's implementation
119 + * for SQLite by temporarily setting the is_mysql flag.
65 120 *
66 - * @param array $modes Optional. A list of SQL modes to set.
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.
67 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 + */
68 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'" );
69 182 }
70 183
71 184 /**
72 185 * Closes the current database connection.
73 - * Noop in SQLite.
74 186 *
75 - * @return bool True to indicate the connection was successfully closed.
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.
76 193 */
77 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 +
78 232 return true;
79 233 }
80 234
81 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 + /**
82 279 * Method to select the database connection.
83 280 *
84 281 * This overrides wpdb::select(), only to dummy out the MySQL function.
85 282 *
@@ -98,53 +295,45 @@
98 295 * This overrides wpdb::_real_escape() to avoid using mysql_real_escape_string().
99 296 *
100 297 * @see wpdb::_real_escape()
101 298 *
102 - * @param string $str The string to escape.
299 + * @param string $data The string to escape.
103 300 *
104 301 * @return string escaped
302 + * @throws RuntimeException When the database connection is not initialized.
105 303 */
106 - public function _real_escape( $str ) {
107 - return addslashes( $str );
108 - }
304 + public function _real_escape( $data ) {
305 + if ( ! is_scalar( $data ) ) {
306 + return '';
307 + }
109 308
110 - /**
111 - * Method to dummy out wpdb::esc_like() function.
112 - *
113 - * WordPress 4.0.0 introduced esc_like() function that adds backslashes to %,
114 - * underscore and backslash, which is not interpreted as escape character
115 - * by SQLite. So we override it and dummy out this function.
116 - *
117 - * @param string $text The raw text to be escaped. The input typed by the user should have no
118 - * extra or deleted slashes.
119 - *
120 - * @return string Text in the form of a LIKE phrase. The output is not SQL safe. Call $wpdb::prepare()
121 - * or real_escape next.
122 - */
123 - public function esc_like( $text ) {
124 - return $text;
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 );
125 317 }
126 318
127 319 /**
128 - * Method to put out the error message.
320 + * Prints SQL/DB error.
129 321 *
130 - * This overrides wpdb::print_error(), for we can't use the parent class method.
322 + * This overrides wpdb::print_error() while closely mirroring its implementation.
131 323 *
132 - * @see wpdb::print_error()
133 - *
134 324 * @global array $EZSQL_ERROR Stores error information of query and error string.
135 325 *
136 326 * @param string $str The error to display.
137 - *
138 - * @return bool|void False if the showing of errors is disabled.
327 + * @return void|false Void if the showing of errors is enabled, false if disabled.
139 328 */
140 329 public function print_error( $str = '' ) {
141 330 global $EZSQL_ERROR;
142 331
143 332 if ( ! $str ) {
144 - $err = $this->dbh->get_error_message() ? $this->dbh->get_error_message() : '';
145 - $str = empty( $err ) ? '' : $err[2];
333 + $str = $this->last_error;
146 334 }
335 +
147 336 $EZSQL_ERROR[] = array(
148 337 'query' => $this->last_query,
149 338 'error_str' => $str,
150 339 );
@@ -152,28 +341,34 @@
152 341 if ( $this->suppress_errors ) {
153 342 return false;
154 343 }
155 344
156 - wp_load_translations_early();
157 -
158 345 $caller = $this->get_caller();
159 - $caller = $caller ? $caller : '(unknown)';
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 + }
160 352
161 - $error_str = sprintf(
162 - 'WordPress database error %1$s for query %2$s made by %3$s',
163 - $str,
164 - $this->last_query,
165 - $caller
166 - );
167 -
168 353 error_log( $error_str );
169 354
355 + // Are we showing errors?
170 356 if ( ! $this->show_errors ) {
171 357 return false;
172 358 }
173 359
360 + wp_load_translations_early();
361 +
362 + // If there is an error then take note of it.
174 363 if ( is_multisite() ) {
175 - $msg = "WordPress database error: [$str]\n{$this->last_query}\n";
364 + $msg = sprintf(
365 + "%s [%s]\n%s\n",
366 + __( 'WordPress database error:' ),
367 + $str,
368 + $this->last_query
369 + );
370 +
176 371 if ( defined( 'ERRORLOGFILE' ) ) {
177 372 error_log( $msg, 3, ERRORLOGFILE );
178 373 }
179 374 if ( defined( 'DIEONDBERROR' ) ) {
@@ -183,11 +378,12 @@
183 378 $str = htmlspecialchars( $str, ENT_QUOTES );
184 379 $query = htmlspecialchars( $this->last_query, ENT_QUOTES );
185 380
186 381 printf(
187 - '<div id="error"><p class="wpdberror">WordPress database error: [%1$s] %2$s</p></div>',
382 + '<div id="error"><p class="wpdberror"><strong>%s</strong> [%s]<br /><code>%s</code></p></div>',
383 + __( 'WordPress database error:' ),
188 384 $str,
189 - '<code>' . $query . '</code>'
385 + $query
190 386 );
191 387 }
192 388 }
193 389
@@ -216,96 +412,294 @@
216 412 *
217 413 * @see wpdb::db_connect()
218 414 *
219 415 * @param bool $allow_bail Not used.
220 - * @return void
416 + * @return bool True on a successful connection, false on failure.
221 417 */
222 418 public function db_connect( $allow_bail = true ) {
223 419 if ( $this->dbh ) {
224 - return;
420 + return $this->ready;
225 421 }
226 - $this->init_charset();
227 422
228 - $pdo = null;
423 + $this->last_error = '';
229 424 if ( isset( $GLOBALS['@pdo'] ) ) {
230 - $pdo = $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 + );
231 429 }
232 - $this->dbh = new WP_SQLite_Translator( $pdo );
233 - $this->last_error = $this->dbh->get_error_message();
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 + }
234 491 if ( $this->last_error ) {
235 492 return false;
236 493 }
237 - $GLOBALS['@pdo'] = $this->dbh->get_pdo();
238 - $this->ready = true;
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;
239 501 }
240 502
241 503 /**
242 - * Method to dummy out wpdb::check_connection()
504 + * Checks that the database connection is available.
243 505 *
244 506 * @param bool $allow_bail Not used.
245 507 *
246 - * @return bool
508 + * @return bool True when the connection is available, false otherwise.
247 509 */
248 510 public function check_connection( $allow_bail = true ) {
249 - return true;
511 + if ( $this->dbh ) {
512 + return true;
513 + }
514 +
515 + return $this->db_connect( $allow_bail );
250 516 }
251 517
252 518 /**
253 - * Method to execute the query.
519 + * Prepares a SQL query for safe execution.
254 520 *
255 - * This overrides wpdb::query(). In fact, this method does all the database
256 - * access jobs.
521 + * See "wpdb::prepare()". This override only fixes a WPDB test issue.
257 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 + *
258 550 * @see wpdb::query()
259 551 *
260 552 * @param string $query Database query.
261 553 *
262 - * @return int|false Number of rows affected/selected or false on error
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.
263 557 */
264 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 +
265 565 if ( ! $this->ready ) {
566 + $this->check_current_query = true;
266 567 return false;
267 568 }
268 569
269 570 $query = apply_filters( 'query', $query );
270 571
572 + if ( ! $query ) {
573 + $this->insert_id = 0;
574 + return false;
575 + }
576 +
271 577 $this->flush();
272 578
273 - $this->func_call = "\$db->query(\"$query\")";
274 - $this->last_query = $query;
579 + // Log how the function was called.
580 + $this->func_call = "\$db->query(\"$query\")";
275 581
276 - if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
277 - $this->timer_start();
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 + }
278 598 }
599 + $this->check_current_query = true;
279 600
280 - $this->result = $this->dbh->query( $query );
281 - ++$this->num_queries;
601 + // Keep track of the last query for debug.
602 + $this->last_query = $query;
282 603
283 - if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
284 - $this->queries[] = array( $query, $this->timer_stop(), $this->get_caller() );
285 - }
604 + // Save the query count after any charset discovery queries.
605 + $last_query_count = count( $this->queries ?? array() );
606 + $this->_do_query( $query );
286 607
287 - $this->last_error = $this->dbh->get_error_message();
288 608 if ( $this->last_error ) {
289 - $this->print_error( $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();
290 615 return false;
291 616 }
292 617
293 - if ( preg_match( '/^\\s*(set|create|alter|truncate|drop|optimize)\\s*/i', $query ) ) {
294 - return $this->dbh->get_return_value();
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;
295 641 }
296 642
297 - if ( preg_match( '/^\\s*(insert|delete|update|replace)\s/i', $query ) ) {
298 - $this->rows_affected = $this->dbh->get_affected_rows();
299 - if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
300 - $this->insert_id = $this->dbh->get_insert_id();
643 + // Query monitor integration:
644 + if ( $query_monitor_active && class_exists( 'QM_Backtrace' ) ) {
645 + if ( did_action( 'qm/cease' ) ) {
646 + $this->queries = array();
301 647 }
302 - return $this->rows_affected;
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();
303 667 }
668 + return $return_val;
669 + }
304 670
305 - $this->last_result = $this->dbh->get_query_results();
306 - $this->num_rows = $this->dbh->get_num_rows();
307 - return $this->num_rows;
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 + }
308 702 }
309 703
310 704 /**
311 705 * Method to set the class variable $col_info.
@@ -317,47 +711,161 @@
317 711 protected function load_col_info() {
318 712 if ( $this->col_info ) {
319 713 return;
320 714 }
321 - $this->col_info = $this->dbh->get_columns();
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 + }
322 737 }
323 738
324 739 /**
325 - * Method to return what the database can do.
740 + * Determines whether the database supports a given feature.
326 741 *
327 - * This overrides wpdb::has_cap() to avoid using MySQL functions.
328 - * SQLite supports subqueries, but not support collation, group_concat and set_charset.
742 + * The utf8mb4 check is handled here because older WordPress versions inspect
743 + * the MySQL client library. All other capabilities use the parent logic.
329 744 *
330 745 * @see wpdb::has_cap()
331 746 *
332 - * @param string $db_cap The feature to check for. Accepts 'collation',
333 - * 'group_concat', 'subqueries', 'set_charset',
334 - * 'utf8mb4', or 'utf8mb4_520'.
335 - *
336 - * @return bool Whether the database feature is supported, false otherwise.
747 + * @param string $db_cap The feature to check for.
748 + * @return bool True when the database feature is supported, false otherwise.
337 749 */
338 750 public function has_cap( $db_cap ) {
339 - return 'subqueries' === strtolower( $db_cap );
751 + if ( 'utf8mb4' === strtolower( $db_cap ) ) {
752 + return true;
753 + }
754 +
755 + return parent::has_cap( $db_cap );
340 756 }
341 757
342 758 /**
343 - * Method to return database version number.
759 + * Retrieves the emulated database server version number.
344 760 *
345 - * This overrides wpdb::db_version() to avoid using MySQL function.
346 - * It returns mysql version number, but it means nothing for SQLite.
347 - * So it return the newest mysql version.
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().
348 764 *
349 765 * @see wpdb::db_version()
766 + *
767 + * @return string Version number on success, or an empty string while disconnected.
350 768 */
351 769 public function db_version() {
352 - return '8.0';
770 + return preg_replace( '/[^0-9.].*/', '', $this->db_server_info() );
353 771 }
354 772
355 773 /**
356 - * Retrieves full database server information.
774 + * Returns the raw version string of the emulated MySQL server.
357 775 *
358 - * @return string|false Server info on success, false on failure.
776 + * @see wpdb::db_server_info()
777 + *
778 + * @return string Emulated MySQL server version, or an empty string while disconnected.
359 779 */
360 780 public function db_server_info() {
361 - return SQLite3::version()['versionString'];
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;
362 870 }
363 871 }