PluginProbe
SQLite Database Integration / 2.2.22
SQLite Database Integration v2.2.22
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 2.2.22, at wp-includes/sqlite/class-wp-sqlite-db.php

724 lines 20.6 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 * @package wp-sqlite-integration
6 * @since 1.0.0
7 */
8
9 /**
10 * This class extends wpdb and replaces it.
11 *
12 * It also rewrites some methods that use mysql specific functions.
13 */
14 class WP_SQLite_DB extends wpdb {
15
16 /**
17 * Database Handle
18 *
19 * @var WP_SQLite_Translator
20 */
21 protected $dbh;
22
23 /**
24 * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters.
25 *
26 * This property is mirroring "wpdb::$allow_unsafe_unquoted_parameters",
27 * because some tests are accessing it externally using PHP reflection.
28 *
29 * @var
30 */
31 private $allow_unsafe_unquoted_parameters = true;
32
33 /**
34 * Connects to the SQLite database.
35 *
36 * Unlike for MySQL, no credentials and host are needed.
37 *
38 * @param string $dbname Database name.
39 */
40 public function __construct( $dbname ) {
41 /**
42 * We need to initialize the "$wpdb" global early, so that the SQLite
43 * driver can configure the database. The call stack goes like this:
44 *
45 * 1. The "parent::__construct()" call executes "$this->db_connect()".
46 * 2. The database connection call initializes the SQLite driver.
47 * 3. The SQLite driver initializes and runs "WP_SQLite_Configurator".
48 * 4. The configurator uses "WP_SQLite_Information_Schema_Reconstructor",
49 * which requires "wp-admin/includes/schema.php" when in WordPress.
50 * 5. The "wp-admin/includes/schema.php" requires the "$wpdb" global,
51 * which creates a circular dependency.
52 */
53 $GLOBALS['wpdb'] = $this;
54
55 parent::__construct( '', '', $dbname, '' );
56 $this->charset = 'utf8mb4';
57 }
58
59 /**
60 * Method to set character set for the database.
61 *
62 * This overrides wpdb::set_charset(), only to dummy out the MySQL function.
63 *
64 * @see wpdb::set_charset()
65 *
66 * @param resource $dbh The resource given by mysql_connect.
67 * @param string $charset Optional. The character set. Default null.
68 * @param string $collate Optional. The collation. Default null.
69 */
70 public function set_charset( $dbh, $charset = null, $collate = null ) {
71 }
72
73 /**
74 * Method to get the character set for the database.
75 * Hardcoded to utf8mb4 for now.
76 *
77 * @param string $table The table name.
78 * @param string $column The column name.
79 *
80 * @return string The character set.
81 */
82 public function get_col_charset( $table, $column ) {
83 // Hardcoded for now.
84 return 'utf8mb4';
85 }
86
87 /**
88 * Changes the current SQL mode, and ensures its WordPress compatibility.
89 *
90 * If no modes are passed, it will ensure the current MySQL server modes are compatible.
91 *
92 * This overrides wpdb::set_sql_mode() while closely mirroring its implementation.
93 *
94 * @param array $modes Optional. A list of SQL modes to set. Default empty array.
95 */
96 public function set_sql_mode( $modes = array() ) {
97 if ( ! $this->dbh instanceof WP_SQLite_Driver ) {
98 return;
99 }
100
101 if ( empty( $modes ) ) {
102 $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' );
103 if ( ! isset( $result[0] ) ) {
104 return;
105 }
106
107 $modes_str = $result[0]->{'@@SESSION.sql_mode'};
108 if ( empty( $modes_str ) ) {
109 return;
110 }
111 $modes = explode( ',', $modes_str );
112 }
113
114 $modes = array_change_key_case( $modes, CASE_UPPER );
115
116 /**
117 * Filters the list of incompatible SQL modes to exclude.
118 *
119 * @since 3.9.0
120 *
121 * @param array $incompatible_modes An array of incompatible modes.
122 */
123 $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $this->incompatible_modes );
124
125 foreach ( $modes as $i => $mode ) {
126 if ( in_array( $mode, $incompatible_modes, true ) ) {
127 unset( $modes[ $i ] );
128 }
129 }
130 $modes_str = implode( ',', $modes );
131
132 $this->dbh->query( "SET SESSION sql_mode='$modes_str'" );
133 }
134
135 /**
136 * Closes the current database connection.
137 * Noop in SQLite.
138 *
139 * @return bool True to indicate the connection was successfully closed.
140 */
141 public function close() {
142 return true;
143 }
144
145 /**
146 * Method to select the database connection.
147 *
148 * This overrides wpdb::select(), only to dummy out the MySQL function.
149 *
150 * @see wpdb::select()
151 *
152 * @param string $db MySQL database name. Not used.
153 * @param resource|null $dbh Optional link identifier.
154 */
155 public function select( $db, $dbh = null ) {
156 $this->ready = true;
157 }
158
159 /**
160 * Method to escape characters.
161 *
162 * This overrides wpdb::_real_escape() to avoid using mysql_real_escape_string().
163 *
164 * @see wpdb::_real_escape()
165 *
166 * @param string $data The string to escape.
167 *
168 * @return string escaped
169 */
170 public function _real_escape( $data ) {
171 if ( ! is_scalar( $data ) ) {
172 return '';
173 }
174 $escaped = addslashes( $data );
175 return $this->add_placeholder_escape( $escaped );
176 }
177
178 /**
179 * Method to dummy out wpdb::esc_like() function.
180 *
181 * WordPress 4.0.0 introduced esc_like() function that adds backslashes to %,
182 * underscore and backslash, which is not interpreted as escape character
183 * by SQLite. So we override it and dummy out this function.
184 *
185 * @param string $text The raw text to be escaped. The input typed by the user should have no
186 * extra or deleted slashes.
187 *
188 * @return string Text in the form of a LIKE phrase. The output is not SQL safe. Call $wpdb::prepare()
189 * or real_escape next.
190 */
191 public function esc_like( $text ) {
192 // The new driver adds "ESCAPE '\\'" to every LIKE expression by default.
193 // We only need to overload this function to a no-op for the old driver.
194 if ( $this->dbh instanceof WP_SQLite_Driver ) {
195 return parent::esc_like( $text );
196 }
197 return $text;
198 }
199
200 /**
201 * Prints SQL/DB error.
202 *
203 * This overrides wpdb::print_error() while closely mirroring its implementation.
204 *
205 * @global array $EZSQL_ERROR Stores error information of query and error string.
206 *
207 * @param string $str The error to display.
208 * @return void|false Void if the showing of errors is enabled, false if disabled.
209 */
210 public function print_error( $str = '' ) {
211 global $EZSQL_ERROR;
212
213 if ( ! $str ) {
214 $str = $this->last_error;
215 }
216
217 $EZSQL_ERROR[] = array(
218 'query' => $this->last_query,
219 'error_str' => $str,
220 );
221
222 if ( $this->suppress_errors ) {
223 return false;
224 }
225
226 $caller = $this->get_caller();
227 if ( $caller ) {
228 // Not translated, as this will only appear in the error log.
229 $error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller );
230 } else {
231 $error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query );
232 }
233
234 error_log( $error_str );
235
236 // Are we showing errors?
237 if ( ! $this->show_errors ) {
238 return false;
239 }
240
241 wp_load_translations_early();
242
243 // If there is an error then take note of it.
244 if ( is_multisite() ) {
245 $msg = sprintf(
246 "%s [%s]\n%s\n",
247 __( 'WordPress database error:' ),
248 $str,
249 $this->last_query
250 );
251
252 if ( defined( 'ERRORLOGFILE' ) ) {
253 error_log( $msg, 3, ERRORLOGFILE );
254 }
255 if ( defined( 'DIEONDBERROR' ) ) {
256 wp_die( $msg );
257 }
258 } else {
259 $str = htmlspecialchars( $str, ENT_QUOTES );
260 $query = htmlspecialchars( $this->last_query, ENT_QUOTES );
261
262 printf(
263 '<div id="error"><p class="wpdberror"><strong>%s</strong> [%s]<br /><code>%s</code></p></div>',
264 __( 'WordPress database error:' ),
265 $str,
266 $query
267 );
268 }
269 }
270
271 /**
272 * Method to flush cached data.
273 *
274 * This overrides wpdb::flush(). This is not necessarily overridden, because
275 * $result will never be resource.
276 *
277 * @see wpdb::flush
278 */
279 public function flush() {
280 $this->last_result = array();
281 $this->col_info = null;
282 $this->last_query = null;
283 $this->rows_affected = 0;
284 $this->num_rows = 0;
285 $this->last_error = '';
286 $this->result = null;
287 }
288
289 /**
290 * Method to do the database connection.
291 *
292 * This overrides wpdb::db_connect() to avoid using MySQL function.
293 *
294 * @see wpdb::db_connect()
295 *
296 * @param bool $allow_bail Not used.
297 * @return void
298 */
299 public function db_connect( $allow_bail = true ) {
300 if ( $this->dbh ) {
301 return;
302 }
303 $this->init_charset();
304
305 $pdo = null;
306 if ( isset( $GLOBALS['@pdo'] ) ) {
307 $pdo = $GLOBALS['@pdo'];
308 }
309
310 // Migrate the database file from a legacy path, if it exists.
311 if ( ! defined( 'DB_FILE' ) && ! file_exists( FQDB ) ) {
312 $old_db_path = FQDBDIR . '.ht.sqlite.php';
313
314 if ( file_exists( $old_db_path ) ) {
315 if ( ! rename( $old_db_path, FQDB ) ) {
316 wp_die( 'Failed to rename database file.', 'Error!' );
317 }
318
319 foreach ( array( '-wal', '-shm', '-journal' ) as $suffix ) {
320 if ( file_exists( $old_db_path . $suffix ) ) {
321 if ( ! rename( $old_db_path . $suffix, FQDB . $suffix ) ) {
322 wp_die( 'Failed to rename database file.', 'Error!' );
323 }
324 }
325 }
326 }
327 }
328
329 if ( defined( 'WP_SQLITE_AST_DRIVER' ) && WP_SQLITE_AST_DRIVER ) {
330 if ( null === $this->dbname || '' === $this->dbname ) {
331 $this->bail(
332 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.',
333 'db_connect_fail'
334 );
335 return false;
336 }
337
338 $this->ensure_database_directory( FQDB );
339
340 try {
341 $connection = new WP_SQLite_Connection(
342 array(
343 'pdo' => $pdo,
344 'path' => FQDB,
345 'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
346 )
347 );
348 $this->dbh = new WP_SQLite_Driver( $connection, $this->dbname );
349 $GLOBALS['@pdo'] = $this->dbh->get_connection()->get_pdo();
350 } catch ( Throwable $e ) {
351 $this->last_error = $this->format_error_message( $e );
352 }
353 } else {
354 $this->dbh = new WP_SQLite_Translator( $pdo );
355 $this->last_error = $this->dbh->get_error_message();
356 $GLOBALS['@pdo'] = $this->dbh->get_pdo();
357 }
358 if ( $this->last_error ) {
359 return false;
360 }
361 $this->ready = true;
362 $this->set_sql_mode();
363 }
364
365 /**
366 * Method to dummy out wpdb::check_connection()
367 *
368 * @param bool $allow_bail Not used.
369 *
370 * @return bool
371 */
372 public function check_connection( $allow_bail = true ) {
373 return true;
374 }
375
376 /**
377 * Prepares a SQL query for safe execution.
378 *
379 * See "wpdb::prepare()". This override only fixes a WPDB test issue.
380 *
381 * @param string $query Query statement with `sprintf()`-like placeholders.
382 * @param array|mixed $args The array of variables or the first variable to substitute.
383 * @param mixed ...$args Further variables to substitute when using individual arguments.
384 * @return string|void Sanitized query string, if there is a query to prepare.
385 */
386 public function prepare( $query, ...$args ) {
387 /*
388 * Sync "$allow_unsafe_unquoted_parameters" with the WPDB parent property.
389 * This is only needed because some WPDB tests are accessing the private
390 * property externally via PHP reflection. This should be fixed WP tests.
391 */
392 $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' );
393 if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) {
394 $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' );
395 $property->setAccessible( true );
396 $property->setValue( $this, $this->allow_unsafe_unquoted_parameters );
397 $property->setAccessible( false );
398 }
399
400 return parent::prepare( $query, ...$args );
401 }
402
403 /**
404 * Performs a database query.
405 *
406 * This overrides wpdb::query() while closely mirroring its implementation.
407 *
408 * @see wpdb::query()
409 *
410 * @param string $query Database query.
411 *
412 * @param string $query Database query.
413 * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows
414 * affected/selected for all other queries. Boolean false on error.
415 */
416 public function query( $query ) {
417 // Query Monitor integration:
418 $query_monitor_active = defined( 'SQLITE_QUERY_MONITOR_LOADED' ) && SQLITE_QUERY_MONITOR_LOADED;
419 if ( $query_monitor_active && $this->show_errors ) {
420 $this->hide_errors();
421 }
422
423 if ( ! $this->ready ) {
424 return false;
425 }
426
427 $query = apply_filters( 'query', $query );
428
429 if ( ! $query ) {
430 $this->insert_id = 0;
431 return false;
432 }
433
434 $this->flush();
435
436 // Log how the function was called.
437 $this->func_call = "\$db->query(\"$query\")";
438
439 // Keep track of the last query for debug.
440 $this->last_query = $query;
441
442 // Save the query count before running another query.
443 $last_query_count = count( $this->queries ?? array() );
444
445 /*
446 * @TODO: WPDB uses "$this->check_current_query" to check table/column
447 * charset and strip all invalid characters from the query.
448 * This is an involved process that we can bypass for SQLite,
449 * if we simply strip all invalid UTF-8 characters from the query.
450 *
451 * To do so, mb_convert_encoding can be used with an optional
452 * fallback to a htmlspecialchars method. E.g.:
453 * https://github.com/nette/utils/blob/be534713c227aeef57ce1883fc17bc9f9e29eca2/src/Utils/Strings.php#L42
454 */
455 $this->_do_query( $query );
456
457 if ( $this->last_error ) {
458 // Clear insert_id on a subsequent failed insert.
459 if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
460 $this->insert_id = 0;
461 }
462
463 $this->print_error();
464 return false;
465 }
466
467 if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) {
468 $return_val = true;
469 } elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) {
470 if ( $this->dbh instanceof WP_SQLite_Driver ) {
471 $this->rows_affected = $this->dbh->get_last_return_value();
472 } else {
473 $this->rows_affected = $this->dbh->get_affected_rows();
474 }
475
476 // Take note of the insert_id.
477 if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
478 $this->insert_id = $this->dbh->get_insert_id();
479 }
480
481 // Return number of rows affected.
482 $return_val = $this->rows_affected;
483 } else {
484 $num_rows = 0;
485
486 if ( is_array( $this->result ) ) {
487 $this->last_result = $this->result;
488 $num_rows = count( $this->result );
489 }
490
491 // Log and return the number of rows selected.
492 $this->num_rows = $num_rows;
493 $return_val = $num_rows;
494 }
495
496 // Query monitor integration:
497 if ( $query_monitor_active && class_exists( 'QM_Backtrace' ) ) {
498 if ( did_action( 'qm/cease' ) ) {
499 $this->queries = array();
500 }
501
502 $i = $last_query_count;
503 if ( ! isset( $this->queries[ $i ] ) ) {
504 return $return_val;
505 }
506
507 $this->queries[ $i ]['trace'] = new QM_Backtrace();
508 if ( ! isset( $this->queries[ $i ][3] ) ) {
509 $this->queries[ $i ][3] = $this->time_start;
510 }
511
512 if ( $this->last_error && ! $this->suppress_errors ) {
513 $this->queries[ $i ]['result'] = new WP_Error( 'qmdb', $this->last_error );
514 } else {
515 $this->queries[ $i ]['result'] = (int) $return_val;
516 }
517
518 // Add SQLite query data.
519 if ( $this->dbh instanceof WP_SQLite_Driver ) {
520 $this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries();
521 } else {
522 $this->queries[ $i ]['sqlite_queries'] = $this->dbh->executed_sqlite_queries;
523 }
524 }
525 return $return_val;
526 }
527
528 /**
529 * Internal function to perform the SQLite query call.
530 *
531 * This closely mirrors wpdb::_do_query().
532 *
533 * @see wpdb::_do_query()
534 *
535 * @param string $query The query to run.
536 */
537 private function _do_query( $query ) {
538 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
539 $this->timer_start();
540 }
541
542 try {
543 $this->result = $this->dbh->query( $query );
544 } catch ( Throwable $e ) {
545 $this->last_error = $this->format_error_message( $e );
546 }
547
548 if ( $this->dbh instanceof WP_SQLite_Translator ) {
549 $this->last_error = $this->dbh->get_error_message();
550 }
551
552 ++$this->num_queries;
553
554 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
555 $this->log_query(
556 $query,
557 $this->timer_stop(),
558 $this->get_caller(),
559 $this->time_start,
560 array()
561 );
562 }
563 }
564
565 /**
566 * Method to set the class variable $col_info.
567 *
568 * This overrides wpdb::load_col_info(), which uses a mysql function.
569 *
570 * @see wpdb::load_col_info()
571 */
572 protected function load_col_info() {
573 if ( $this->col_info ) {
574 return;
575 }
576 if ( $this->dbh instanceof WP_SQLite_Driver ) {
577 $this->col_info = array();
578 foreach ( $this->dbh->get_last_column_meta() as $column ) {
579 $this->col_info[] = (object) array(
580 'name' => $column['name'],
581 'orgname' => $column['mysqli:orgname'],
582 'table' => $column['table'],
583 'orgtable' => $column['mysqli:orgtable'],
584 'def' => '', // Unused, always ''.
585 'db' => $column['mysqli:db'],
586 'catalog' => 'def', // Unused, always 'def'.
587 'max_length' => 0, // As of PHP 8.1, this is always 0.
588 'length' => $column['len'],
589 'charsetnr' => $column['mysqli:charsetnr'],
590 'flags' => $column['mysqli:flags'],
591 'type' => $column['mysqli:type'],
592 'decimals' => $column['precision'],
593 );
594 }
595 } else {
596 $this->col_info = $this->dbh->get_columns();
597 }
598 }
599
600 /**
601 * Method to return what the database can do.
602 *
603 * This overrides wpdb::has_cap() to avoid using MySQL functions.
604 * SQLite supports subqueries, but not support collation, group_concat and set_charset.
605 *
606 * @see wpdb::has_cap()
607 *
608 * @param string $db_cap The feature to check for. Accepts 'collation',
609 * 'group_concat', 'subqueries', 'set_charset',
610 * 'utf8mb4', or 'utf8mb4_520'.
611 *
612 * @return bool Whether the database feature is supported, false otherwise.
613 */
614 public function has_cap( $db_cap ) {
615 return 'subqueries' === strtolower( $db_cap );
616 }
617
618 /**
619 * Method to return database version number.
620 *
621 * This overrides wpdb::db_version() to avoid using MySQL function.
622 * It returns mysql version number, but it means nothing for SQLite.
623 * So it return the newest mysql version.
624 *
625 * @see wpdb::db_version()
626 */
627 public function db_version() {
628 return '8.0';
629 }
630
631 /**
632 * Returns the version of the SQLite engine.
633 *
634 * @return string SQLite engine version as a string.
635 */
636 public function db_server_info() {
637 return $this->dbh->get_sqlite_version();
638 }
639
640 /**
641 * Make sure the SQLite database directory exists and is writable.
642 * Create .htaccess and index.php files to prevent direct access.
643 *
644 * @param string $database_path The path to the SQLite database file.
645 */
646 private function ensure_database_directory( string $database_path ) {
647 $dir = dirname( $database_path );
648
649 // Set the umask to 0000 to apply permissions exactly as specified.
650 // A non-zero umask affects new file and directory permissions.
651 $umask = umask( 0 );
652
653 // Ensure database directory.
654 if ( ! is_dir( $dir ) ) {
655 if ( ! @mkdir( $dir, 0700, true ) ) {
656 wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' );
657 }
658 }
659 if ( ! is_writable( $dir ) ) {
660 wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' );
661 }
662
663 // Ensure .htaccess file to prevent direct access.
664 $path = $dir . DIRECTORY_SEPARATOR . '.htaccess';
665 if ( ! is_file( $path ) ) {
666 $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX );
667 if ( false === $result ) {
668 wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
669 }
670 chmod( $path, 0600 );
671 }
672
673 // Ensure index.php file to prevent direct access.
674 $path = $dir . DIRECTORY_SEPARATOR . 'index.php';
675 if ( ! is_file( $path ) ) {
676 $result = file_put_contents( $path, '<?php // Silence is gold. ?>', LOCK_EX );
677 if ( false === $result ) {
678 wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
679 }
680 chmod( $path, 0600 );
681 }
682
683 // Restore the original umask value.
684 umask( $umask );
685 }
686
687
688 /**
689 * Format SQLite driver error message.
690 *
691 * @return string
692 */
693 private function format_error_message( Throwable $e ) {
694 $output = '<div style="clear:both">&nbsp;</div>' . PHP_EOL;
695
696 // Queries.
697 if ( $e instanceof WP_SQLite_Driver_Exception ) {
698 $driver = $e->getDriver();
699
700 $output .= '<div class="queries" style="clear:both;margin-bottom:2px;border:red dotted thin;">' . PHP_EOL;
701 $output .= '<p>MySQL query:</p>' . PHP_EOL;
702 $output .= '<p>' . $driver->get_last_mysql_query() . '</p>' . PHP_EOL;
703 $output .= '<p>Queries made or created this session were:</p>' . PHP_EOL;
704 $output .= '<ol>' . PHP_EOL;
705 foreach ( $driver->get_last_sqlite_queries() as $q ) {
706 $message = "Executing: {$q['sql']} | " . ( $q['params'] ? 'parameters: ' . implode( ', ', $q['params'] ) : '(no parameters)' );
707 $output .= '<li>' . htmlspecialchars( $message ) . '</li>' . PHP_EOL;
708 }
709 $output .= '</ol>' . PHP_EOL;
710 $output .= '</div>' . PHP_EOL;
711 }
712
713 // Message.
714 $output .= '<div style="clear:both;margin-bottom:2px;border:red dotted thin;" class="error_message" style="border-bottom:dotted blue thin;">' . PHP_EOL;
715 $output .= $e->getMessage() . PHP_EOL;
716 $output .= '</div>' . PHP_EOL;
717
718 // Backtrace.
719 $output .= '<p>Backtrace:</p>' . PHP_EOL;
720 $output .= '<pre>' . $e->getTraceAsString() . '</pre>' . PHP_EOL;
721 return $output;
722 }
723 }
724