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

725 lines 20.7 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 require_once __DIR__ . '/../../wp-pdo-mysql-on-sqlite.php';
339 $this->ensure_database_directory( FQDB );
340
341 try {
342 $connection = new WP_SQLite_Connection(
343 array(
344 'pdo' => $pdo,
345 'path' => FQDB,
346 'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
347 )
348 );
349 $this->dbh = new WP_SQLite_Driver( $connection, $this->dbname );
350 $GLOBALS['@pdo'] = $this->dbh->get_connection()->get_pdo();
351 } catch ( Throwable $e ) {
352 $this->last_error = $this->format_error_message( $e );
353 }
354 } else {
355 $this->dbh = new WP_SQLite_Translator( $pdo );
356 $this->last_error = $this->dbh->get_error_message();
357 $GLOBALS['@pdo'] = $this->dbh->get_pdo();
358 }
359 if ( $this->last_error ) {
360 return false;
361 }
362 $this->ready = true;
363 $this->set_sql_mode();
364 }
365
366 /**
367 * Method to dummy out wpdb::check_connection()
368 *
369 * @param bool $allow_bail Not used.
370 *
371 * @return bool
372 */
373 public function check_connection( $allow_bail = true ) {
374 return true;
375 }
376
377 /**
378 * Prepares a SQL query for safe execution.
379 *
380 * See "wpdb::prepare()". This override only fixes a WPDB test issue.
381 *
382 * @param string $query Query statement with `sprintf()`-like placeholders.
383 * @param array|mixed $args The array of variables or the first variable to substitute.
384 * @param mixed ...$args Further variables to substitute when using individual arguments.
385 * @return string|void Sanitized query string, if there is a query to prepare.
386 */
387 public function prepare( $query, ...$args ) {
388 /*
389 * Sync "$allow_unsafe_unquoted_parameters" with the WPDB parent property.
390 * This is only needed because some WPDB tests are accessing the private
391 * property externally via PHP reflection. This should be fixed WP tests.
392 */
393 $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' );
394 if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) {
395 $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' );
396 $property->setAccessible( true );
397 $property->setValue( $this, $this->allow_unsafe_unquoted_parameters );
398 $property->setAccessible( false );
399 }
400
401 return parent::prepare( $query, ...$args );
402 }
403
404 /**
405 * Performs a database query.
406 *
407 * This overrides wpdb::query() while closely mirroring its implementation.
408 *
409 * @see wpdb::query()
410 *
411 * @param string $query Database query.
412 *
413 * @param string $query Database query.
414 * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows
415 * affected/selected for all other queries. Boolean false on error.
416 */
417 public function query( $query ) {
418 // Query Monitor integration:
419 $query_monitor_active = defined( 'SQLITE_QUERY_MONITOR_LOADED' ) && SQLITE_QUERY_MONITOR_LOADED;
420 if ( $query_monitor_active && $this->show_errors ) {
421 $this->hide_errors();
422 }
423
424 if ( ! $this->ready ) {
425 return false;
426 }
427
428 $query = apply_filters( 'query', $query );
429
430 if ( ! $query ) {
431 $this->insert_id = 0;
432 return false;
433 }
434
435 $this->flush();
436
437 // Log how the function was called.
438 $this->func_call = "\$db->query(\"$query\")";
439
440 // Keep track of the last query for debug.
441 $this->last_query = $query;
442
443 // Save the query count before running another query.
444 $last_query_count = count( $this->queries ?? array() );
445
446 /*
447 * @TODO: WPDB uses "$this->check_current_query" to check table/column
448 * charset and strip all invalid characters from the query.
449 * This is an involved process that we can bypass for SQLite,
450 * if we simply strip all invalid UTF-8 characters from the query.
451 *
452 * To do so, mb_convert_encoding can be used with an optional
453 * fallback to a htmlspecialchars method. E.g.:
454 * https://github.com/nette/utils/blob/be534713c227aeef57ce1883fc17bc9f9e29eca2/src/Utils/Strings.php#L42
455 */
456 $this->_do_query( $query );
457
458 if ( $this->last_error ) {
459 // Clear insert_id on a subsequent failed insert.
460 if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
461 $this->insert_id = 0;
462 }
463
464 $this->print_error();
465 return false;
466 }
467
468 if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) {
469 $return_val = true;
470 } elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) {
471 if ( $this->dbh instanceof WP_SQLite_Driver ) {
472 $this->rows_affected = $this->dbh->get_last_return_value();
473 } else {
474 $this->rows_affected = $this->dbh->get_affected_rows();
475 }
476
477 // Take note of the insert_id.
478 if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
479 $this->insert_id = $this->dbh->get_insert_id();
480 }
481
482 // Return number of rows affected.
483 $return_val = $this->rows_affected;
484 } else {
485 $num_rows = 0;
486
487 if ( is_array( $this->result ) ) {
488 $this->last_result = $this->result;
489 $num_rows = count( $this->result );
490 }
491
492 // Log and return the number of rows selected.
493 $this->num_rows = $num_rows;
494 $return_val = $num_rows;
495 }
496
497 // Query monitor integration:
498 if ( $query_monitor_active && class_exists( 'QM_Backtrace' ) ) {
499 if ( did_action( 'qm/cease' ) ) {
500 $this->queries = array();
501 }
502
503 $i = $last_query_count;
504 if ( ! isset( $this->queries[ $i ] ) ) {
505 return $return_val;
506 }
507
508 $this->queries[ $i ]['trace'] = new QM_Backtrace();
509 if ( ! isset( $this->queries[ $i ][3] ) ) {
510 $this->queries[ $i ][3] = $this->time_start;
511 }
512
513 if ( $this->last_error && ! $this->suppress_errors ) {
514 $this->queries[ $i ]['result'] = new WP_Error( 'qmdb', $this->last_error );
515 } else {
516 $this->queries[ $i ]['result'] = (int) $return_val;
517 }
518
519 // Add SQLite query data.
520 if ( $this->dbh instanceof WP_SQLite_Driver ) {
521 $this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries();
522 } else {
523 $this->queries[ $i ]['sqlite_queries'] = $this->dbh->executed_sqlite_queries;
524 }
525 }
526 return $return_val;
527 }
528
529 /**
530 * Internal function to perform the SQLite query call.
531 *
532 * This closely mirrors wpdb::_do_query().
533 *
534 * @see wpdb::_do_query()
535 *
536 * @param string $query The query to run.
537 */
538 private function _do_query( $query ) {
539 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
540 $this->timer_start();
541 }
542
543 try {
544 $this->result = $this->dbh->query( $query );
545 } catch ( Throwable $e ) {
546 $this->last_error = $this->format_error_message( $e );
547 }
548
549 if ( $this->dbh instanceof WP_SQLite_Translator ) {
550 $this->last_error = $this->dbh->get_error_message();
551 }
552
553 ++$this->num_queries;
554
555 if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
556 $this->log_query(
557 $query,
558 $this->timer_stop(),
559 $this->get_caller(),
560 $this->time_start,
561 array()
562 );
563 }
564 }
565
566 /**
567 * Method to set the class variable $col_info.
568 *
569 * This overrides wpdb::load_col_info(), which uses a mysql function.
570 *
571 * @see wpdb::load_col_info()
572 */
573 protected function load_col_info() {
574 if ( $this->col_info ) {
575 return;
576 }
577 if ( $this->dbh instanceof WP_SQLite_Driver ) {
578 $this->col_info = array();
579 foreach ( $this->dbh->get_last_column_meta() as $column ) {
580 $this->col_info[] = (object) array(
581 'name' => $column['name'],
582 'orgname' => $column['mysqli:orgname'],
583 'table' => $column['table'],
584 'orgtable' => $column['mysqli:orgtable'],
585 'def' => '', // Unused, always ''.
586 'db' => $column['mysqli:db'],
587 'catalog' => 'def', // Unused, always 'def'.
588 'max_length' => 0, // As of PHP 8.1, this is always 0.
589 'length' => $column['len'],
590 'charsetnr' => $column['mysqli:charsetnr'],
591 'flags' => $column['mysqli:flags'],
592 'type' => $column['mysqli:type'],
593 'decimals' => $column['precision'],
594 );
595 }
596 } else {
597 $this->col_info = $this->dbh->get_columns();
598 }
599 }
600
601 /**
602 * Method to return what the database can do.
603 *
604 * This overrides wpdb::has_cap() to avoid using MySQL functions.
605 * SQLite supports subqueries, but not support collation, group_concat and set_charset.
606 *
607 * @see wpdb::has_cap()
608 *
609 * @param string $db_cap The feature to check for. Accepts 'collation',
610 * 'group_concat', 'subqueries', 'set_charset',
611 * 'utf8mb4', or 'utf8mb4_520'.
612 *
613 * @return bool Whether the database feature is supported, false otherwise.
614 */
615 public function has_cap( $db_cap ) {
616 return 'subqueries' === strtolower( $db_cap );
617 }
618
619 /**
620 * Method to return database version number.
621 *
622 * This overrides wpdb::db_version() to avoid using MySQL function.
623 * It returns mysql version number, but it means nothing for SQLite.
624 * So it return the newest mysql version.
625 *
626 * @see wpdb::db_version()
627 */
628 public function db_version() {
629 return '8.0';
630 }
631
632 /**
633 * Returns the version of the SQLite engine.
634 *
635 * @return string SQLite engine version as a string.
636 */
637 public function db_server_info() {
638 return $this->dbh->get_sqlite_version();
639 }
640
641 /**
642 * Make sure the SQLite database directory exists and is writable.
643 * Create .htaccess and index.php files to prevent direct access.
644 *
645 * @param string $database_path The path to the SQLite database file.
646 */
647 private function ensure_database_directory( string $database_path ) {
648 $dir = dirname( $database_path );
649
650 // Set the umask to 0000 to apply permissions exactly as specified.
651 // A non-zero umask affects new file and directory permissions.
652 $umask = umask( 0 );
653
654 // Ensure database directory.
655 if ( ! is_dir( $dir ) ) {
656 if ( ! @mkdir( $dir, 0700, true ) ) {
657 wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' );
658 }
659 }
660 if ( ! is_writable( $dir ) ) {
661 wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' );
662 }
663
664 // Ensure .htaccess file to prevent direct access.
665 $path = $dir . DIRECTORY_SEPARATOR . '.htaccess';
666 if ( ! is_file( $path ) ) {
667 $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX );
668 if ( false === $result ) {
669 wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
670 }
671 chmod( $path, 0600 );
672 }
673
674 // Ensure index.php file to prevent direct access.
675 $path = $dir . DIRECTORY_SEPARATOR . 'index.php';
676 if ( ! is_file( $path ) ) {
677 $result = file_put_contents( $path, '<?php // Silence is gold. ?>', LOCK_EX );
678 if ( false === $result ) {
679 wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
680 }
681 chmod( $path, 0600 );
682 }
683
684 // Restore the original umask value.
685 umask( $umask );
686 }
687
688
689 /**
690 * Format SQLite driver error message.
691 *
692 * @return string
693 */
694 private function format_error_message( Throwable $e ) {
695 $output = '<div style="clear:both">&nbsp;</div>' . PHP_EOL;
696
697 // Queries.
698 if ( $e instanceof WP_SQLite_Driver_Exception ) {
699 $driver = $e->getDriver();
700
701 $output .= '<div class="queries" style="clear:both;margin-bottom:2px;border:red dotted thin;">' . PHP_EOL;
702 $output .= '<p>MySQL query:</p>' . PHP_EOL;
703 $output .= '<p>' . $driver->get_last_mysql_query() . '</p>' . PHP_EOL;
704 $output .= '<p>Queries made or created this session were:</p>' . PHP_EOL;
705 $output .= '<ol>' . PHP_EOL;
706 foreach ( $driver->get_last_sqlite_queries() as $q ) {
707 $message = "Executing: {$q['sql']} | " . ( $q['params'] ? 'parameters: ' . implode( ', ', $q['params'] ) : '(no parameters)' );
708 $output .= '<li>' . htmlspecialchars( $message ) . '</li>' . PHP_EOL;
709 }
710 $output .= '</ol>' . PHP_EOL;
711 $output .= '</div>' . PHP_EOL;
712 }
713
714 // Message.
715 $output .= '<div style="clear:both;margin-bottom:2px;border:red dotted thin;" class="error_message" style="border-bottom:dotted blue thin;">' . PHP_EOL;
716 $output .= $e->getMessage() . PHP_EOL;
717 $output .= '</div>' . PHP_EOL;
718
719 // Backtrace.
720 $output .= '<p>Backtrace:</p>' . PHP_EOL;
721 $output .= '<pre>' . $e->getTraceAsString() . '</pre>' . PHP_EOL;
722 return $output;
723 }
724 }
725