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

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