From 054503aa4f500032f06bf55a52d429df4dd4dd2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 26 May 2026 17:09:03 +0100 Subject: [PATCH 1/6] Default SQLite connections to WAL Configure new SQLite connections to use WAL by default and apply synchronous=NORMAL when WAL is the effective journal mode. Keep the explicit SQLITE_JOURNAL_MODE override available for WordPress plugin connections, including initial installation. --- .../src/sqlite/class-wp-sqlite-connection.php | 49 +++++++- .../tests/WP_SQLite_Connection_Tests.php | 115 ++++++++++++++++++ .../wp-includes/sqlite/install-functions.php | 7 +- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 1509a1e64..d77027846 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -19,6 +19,16 @@ class WP_SQLite_Connection { */ const DEFAULT_SQLITE_TIMEOUT = 10; + /** + * The default SQLite journal mode. + */ + const DEFAULT_SQLITE_JOURNAL_MODE = 'WAL'; + + /** + * The default SQLite synchronous setting for WAL mode. + */ + const DEFAULT_SQLITE_WAL_SYNCHRONOUS = 'NORMAL'; + /** * The supported SQLite journal modes. * @@ -33,6 +43,18 @@ class WP_SQLite_Connection { 'OFF', ); + /** + * The supported SQLite synchronous settings. + * + * See: https://www.sqlite.org/pragma.html#pragma_synchronous + */ + const SQLITE_SYNCHRONOUS_SETTINGS = array( + 'OFF', + 'NORMAL', + 'FULL', + 'EXTRA', + ); + /** * The PDO connection for SQLite. * @@ -62,7 +84,9 @@ class WP_SQLite_Connection { * If not provided, a new PDO instance will be created. * @type int|null $timeout Optional. SQLite timeout in seconds. * The time to wait for a writable lock. - * @type string|null $journal_mode Optional. SQLite journal mode. + * @type string|null $journal_mode Optional. SQLite journal mode. Defaults to WAL. + * @type string|null $synchronous Optional. SQLite synchronous setting. Defaults to + * NORMAL when the effective journal mode is WAL. * } * * @throws InvalidArgumentException When some connection options are invalid. @@ -92,9 +116,28 @@ public function __construct( array $options ) { $this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout ); // Configure SQLite journal mode. - $journal_mode = $options['journal_mode'] ?? null; + $effective_journal_mode = null; + $journal_mode = $options['journal_mode'] ?? self::DEFAULT_SQLITE_JOURNAL_MODE; + if ( is_string( $journal_mode ) ) { + $journal_mode = strtoupper( $journal_mode ); + } if ( $journal_mode && in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) { - $this->query( 'PRAGMA journal_mode = ' . $journal_mode ); + $effective_journal_mode = strtoupper( + (string) $this->query( 'PRAGMA journal_mode = ' . $journal_mode )->fetchColumn() + ); + } + + // Configure SQLite synchronous setting. In WAL mode, default to NORMAL. + // Otherwise, use SQLite's default value. + $synchronous = $options['synchronous'] ?? null; + if ( null === $synchronous && 'WAL' === $effective_journal_mode ) { + $synchronous = self::DEFAULT_SQLITE_WAL_SYNCHRONOUS; + } + if ( is_string( $synchronous ) ) { + $synchronous = strtoupper( $synchronous ); + } + if ( $synchronous && in_array( $synchronous, self::SQLITE_SYNCHRONOUS_SETTINGS, true ) ) { + $this->query( 'PRAGMA synchronous = ' . $synchronous ); } } diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php new file mode 100644 index 000000000..6a075d884 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -0,0 +1,115 @@ +db_path = tempnam( sys_get_temp_dir(), 'wp_sqlite_' ); + unlink( $this->db_path ); + } + + public function tearDown(): void { + foreach ( array( + $this->db_path, + $this->db_path . '-wal', + $this->db_path . '-shm', + $this->db_path . '-journal', + ) as $path ) { + if ( is_string( $path ) && file_exists( $path ) ) { + unlink( $path ); + } + } + $this->db_path = null; + } + + public function testDefaultJournalModeUsesWal(): void { + $connection = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); + + $this->assertSame( 'wal', $this->get_journal_mode( $connection ) ); + $this->assertSame( '1', $this->get_synchronous( $connection ) ); + } + + public function testJournalModeCanBeOverridden(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'DELETE', + ) + ); + + $this->assertSame( 'delete', $this->get_journal_mode( $connection ) ); + } + + public function testSynchronousCanBeOverridden(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'synchronous' => 'FULL', + ) + ); + + $this->assertSame( '2', $this->get_synchronous( $connection ) ); + } + + public function testRollbackJournalModeKeepsDefaultSynchronous(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'DELETE', + ) + ); + + $this->assertSame( '2', $this->get_synchronous( $connection ) ); + } + + public function testInMemoryDatabaseKeepsDefaultSynchronous(): void { + $connection = new WP_SQLite_Connection( array( 'path' => ':memory:' ) ); + + $this->assertSame( 'memory', $this->get_journal_mode( $connection ) ); + $this->assertSame( '2', $this->get_synchronous( $connection ) ); + } + + public function testJournalModeAndSynchronousAreCaseInsensitive(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'delete', + 'synchronous' => 'extra', + ) + ); + + $this->assertSame( 'delete', $this->get_journal_mode( $connection ) ); + $this->assertSame( '3', $this->get_synchronous( $connection ) ); + } + + public function testInvalidJournalModeAndSynchronousAreIgnored(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'INVALID', + 'synchronous' => 'INVALID', + ) + ); + + $this->assertSame( 'delete', $this->get_journal_mode( $connection ) ); + $this->assertSame( '2', $this->get_synchronous( $connection ) ); + } + + private function get_journal_mode( WP_SQLite_Connection $connection ): string { + return strtolower( (string) $connection->query( 'PRAGMA journal_mode' )->fetchColumn() ); + } + + private function get_synchronous( WP_SQLite_Connection $connection ): string { + return (string) $connection->query( 'PRAGMA synchronous' )->fetchColumn(); + } +} diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index 5d73e39eb..e4788dbe9 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -35,7 +35,12 @@ function sqlite_make_db_sqlite() { } $translator = new WP_SQLite_Driver( - new WP_SQLite_Connection( array( 'pdo' => $pdo ) ), + new WP_SQLite_Connection( + array( + 'pdo' => $pdo, + 'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, + ) + ), $wpdb->dbname ); $query = null; From 5b4c913d6ca5969a67af58db547eeebb09e4acef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 12 Jun 2026 13:10:12 +0200 Subject: [PATCH 2/6] Fall back to the current journal mode when WAL is unavailable WAL can fail to engage in some environments, e.g., on network filesystems or when the WAL sidecar files cannot be created. In that case, setting the journal mode throws, which would newly fail connections that worked before WAL became the default. Keep the database's current journal mode when the WAL default fails, but let explicitly configured modes surface the error. Also include the SQLite connection setup in the installation error handling, so a connection failure dies gracefully during WordPress installation instead of causing a fatal error. --- .../src/sqlite/class-wp-sqlite-connection.php | 15 ++++- .../tests/WP_SQLite_Connection_Tests.php | 57 ++++++++++++++++++- .../wp-includes/sqlite/install-functions.php | 26 ++++----- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index d77027846..2908a3ceb 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -122,9 +122,18 @@ public function __construct( array $options ) { $journal_mode = strtoupper( $journal_mode ); } if ( $journal_mode && in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) { - $effective_journal_mode = strtoupper( - (string) $this->query( 'PRAGMA journal_mode = ' . $journal_mode )->fetchColumn() - ); + try { + $effective_journal_mode = strtoupper( + (string) $this->query( 'PRAGMA journal_mode = ' . $journal_mode )->fetchColumn() + ); + } catch ( PDOException $e ) { + // WAL may be unavailable in some environments, such as on network + // filesystems. When it is explicitly configured, surface the error. + // Otherwise, fall back to the default SQLite behavior. + if ( isset( $options['journal_mode'] ) ) { + throw $e; + } + } } // Configure SQLite synchronous setting. In WAL mode, default to NORMAL. diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php index 6a075d884..b114eaeaa 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -6,6 +6,13 @@ * Tests for the SQLite connection setup. */ class WP_SQLite_Connection_Tests extends TestCase { + /** + * Path to the temporary directory holding the SQLite database file. + * + * @var string|null + */ + private $db_dir; + /** * Path to the temporary SQLite database file used in file-based tests. * @@ -14,11 +21,14 @@ class WP_SQLite_Connection_Tests extends TestCase { private $db_path; public function setUp(): void { - $this->db_path = tempnam( sys_get_temp_dir(), 'wp_sqlite_' ); - unlink( $this->db_path ); + $this->db_dir = tempnam( sys_get_temp_dir(), 'wp_sqlite_' ); + unlink( $this->db_dir ); + mkdir( $this->db_dir ); + $this->db_path = $this->db_dir . '/database.sqlite'; } public function tearDown(): void { + chmod( $this->db_dir, 0755 ); // Restore permissions changed by read-only tests. foreach ( array( $this->db_path, $this->db_path . '-wal', @@ -29,6 +39,8 @@ public function tearDown(): void { unlink( $path ); } } + rmdir( $this->db_dir ); + $this->db_dir = null; $this->db_path = null; } @@ -105,6 +117,47 @@ public function testInvalidJournalModeAndSynchronousAreIgnored(): void { $this->assertSame( '2', $this->get_synchronous( $connection ) ); } + public function testDefaultJournalModeFallsBackWhenWalIsUnavailable(): void { + $this->make_database_directory_read_only(); + + $connection = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); + + $this->assertSame( 'delete', $this->get_journal_mode( $connection ) ); + $this->assertSame( '2', $this->get_synchronous( $connection ) ); + } + + public function testExplicitJournalModeSurfacesFailureWhenWalIsUnavailable(): void { + $this->make_database_directory_read_only(); + + $this->expectException( PDOException::class ); + new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'WAL', + ) + ); + } + + /** + * Create the database file first, and then make its directory read-only, + * so that the WAL sidecar files ("-wal", "-shm") cannot be created. + */ + private function make_database_directory_read_only(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'DELETE', + ) + ); + $connection->query( 'CREATE TABLE t ( id INTEGER )' ); + $connection = null; + + chmod( $this->db_dir, 0555 ); + if ( is_writable( $this->db_dir ) ) { + $this->markTestSkipped( 'The test requires a non-writable database directory.' ); + } + } + private function get_journal_mode( WP_SQLite_Connection $connection ): string { return strtolower( (string) $connection->query( 'PRAGMA journal_mode' )->fetchColumn() ); } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index e4788dbe9..ec73f2f04 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -25,25 +25,25 @@ function sqlite_make_db_sqlite() { $table_schemas = wp_get_db_schema(); $queries = explode( ';', $table_schemas ); try { - $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO - $pdo = new $pdo_class( 'sqlite:' . FQDB, null, null, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $pdo = new $pdo_class( 'sqlite:' . FQDB, null, null, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses + $translator = new WP_SQLite_Driver( + new WP_SQLite_Connection( + array( + 'pdo' => $pdo, + 'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, + ) + ), + $wpdb->dbname + ); } catch ( PDOException $err ) { $err_data = $err->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $message = 'Database connection error!
'; - $message .= sprintf( 'Error message is: %s', $err_data[2] ); + $message .= sprintf( 'Error message is: %s', $err_data[2] ?? $err->getMessage() ); wp_die( $message, 'Database Error!' ); } - $translator = new WP_SQLite_Driver( - new WP_SQLite_Connection( - array( - 'pdo' => $pdo, - 'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, - ) - ), - $wpdb->dbname - ); - $query = null; + $query = null; try { $translator->begin_transaction(); From 9bfb3ace093321deb26fb98ef97059cd3af0c2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 12 Jun 2026 13:11:32 +0200 Subject: [PATCH 3/6] Accept integer synchronous setting values PRAGMA synchronous accepts integers from 0 to 3 as equivalents of the keyword values, and constants like SQLITE_SYNCHRONOUS are likely to be defined as integers. Map integer values to the corresponding keywords instead of silently ignoring them. --- .../src/sqlite/class-wp-sqlite-connection.php | 27 ++++++++++--------- .../tests/WP_SQLite_Connection_Tests.php | 23 ++++++++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 2908a3ceb..fe7ef881e 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -46,6 +46,8 @@ class WP_SQLite_Connection { /** * The supported SQLite synchronous settings. * + * The list is indexed by the corresponding numeric setting values (0 to 3). + * * See: https://www.sqlite.org/pragma.html#pragma_synchronous */ const SQLITE_SYNCHRONOUS_SETTINGS = array( @@ -77,16 +79,16 @@ class WP_SQLite_Connection { * @param array $options { * An array of options. * - * @type string|null $path Optional. SQLite database path. - * For in-memory database, use ':memory:'. - * Must be set when PDO instance is not provided. - * @type PDO|null $pdo Optional. PDO instance with SQLite connection. - * If not provided, a new PDO instance will be created. - * @type int|null $timeout Optional. SQLite timeout in seconds. - * The time to wait for a writable lock. - * @type string|null $journal_mode Optional. SQLite journal mode. Defaults to WAL. - * @type string|null $synchronous Optional. SQLite synchronous setting. Defaults to - * NORMAL when the effective journal mode is WAL. + * @type string|null $path Optional. SQLite database path. + * For in-memory database, use ':memory:'. + * Must be set when PDO instance is not provided. + * @type PDO|null $pdo Optional. PDO instance with SQLite connection. + * If not provided, a new PDO instance will be created. + * @type int|null $timeout Optional. SQLite timeout in seconds. + * The time to wait for a writable lock. + * @type string|null $journal_mode Optional. SQLite journal mode. Defaults to WAL. + * @type string|int|null $synchronous Optional. SQLite synchronous setting. Defaults to + * NORMAL when the effective journal mode is WAL. * } * * @throws InvalidArgumentException When some connection options are invalid. @@ -141,8 +143,9 @@ public function __construct( array $options ) { $synchronous = $options['synchronous'] ?? null; if ( null === $synchronous && 'WAL' === $effective_journal_mode ) { $synchronous = self::DEFAULT_SQLITE_WAL_SYNCHRONOUS; - } - if ( is_string( $synchronous ) ) { + } elseif ( is_int( $synchronous ) && isset( self::SQLITE_SYNCHRONOUS_SETTINGS[ $synchronous ] ) ) { + $synchronous = self::SQLITE_SYNCHRONOUS_SETTINGS[ $synchronous ]; + } elseif ( is_string( $synchronous ) ) { $synchronous = strtoupper( $synchronous ); } if ( $synchronous && in_array( $synchronous, self::SQLITE_SYNCHRONOUS_SETTINGS, true ) ) { diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php index b114eaeaa..c5ef606d1 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -117,6 +117,29 @@ public function testInvalidJournalModeAndSynchronousAreIgnored(): void { $this->assertSame( '2', $this->get_synchronous( $connection ) ); } + public function testSynchronousAcceptsIntegerValues(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'synchronous' => 3, + ) + ); + + $this->assertSame( '3', $this->get_synchronous( $connection ) ); + } + + public function testSynchronousAcceptsIntegerZero(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'journal_mode' => 'DELETE', + 'synchronous' => 0, + ) + ); + + $this->assertSame( '0', $this->get_synchronous( $connection ) ); + } + public function testDefaultJournalModeFallsBackWhenWalIsUnavailable(): void { $this->make_database_directory_read_only(); From 1898fe05517286acff682e15665acd0b7d40ad88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 12 Jun 2026 13:13:18 +0200 Subject: [PATCH 4/6] Pass journal mode and synchronous options through the PDO API The WAL default applies to all WP_SQLite_Connection consumers, but only the WordPress plugin paths supported overriding it. Accept journal_mode and synchronous in the WP_PDO_MySQL_On_SQLite driver options so the PDO API consumers can configure them as well. --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 9 ++- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php index 7130fb631..4e9726342 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php @@ -669,11 +669,16 @@ public function __construct( $db_name = $args['dbname'] ?? 'sqlite_database'; // Create a new SQLite connection. + $connection_options = array( + 'journal_mode' => $options['journal_mode'] ?? null, + 'synchronous' => $options['synchronous'] ?? null, + ); if ( isset( $options['pdo'] ) ) { - $this->connection = new WP_SQLite_Connection( array( 'pdo' => $options['pdo'] ) ); + $connection_options['pdo'] = $options['pdo']; } else { - $this->connection = new WP_SQLite_Connection( array( 'path' => $path ) ); + $connection_options['path'] = $path; } + $this->connection = new WP_SQLite_Connection( $connection_options ); $this->mysql_version = $options['mysql_version'] ?? 80038; $this->main_db_name = $db_name; diff --git a/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 9ac8a3014..404481a34 100644 --- a/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -68,6 +68,54 @@ public function test_dsn_parsing(): void { $this->assertSame( 'w', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); } + public function test_journal_mode_defaults_to_wal(): void { + $path = tempnam( sys_get_temp_dir(), 'wp_sqlite_' ); + unlink( $path ); + + try { + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=' . $path . ';dbname=wp' ); + $connection = $driver->get_connection(); + $this->assertSame( + 'wal', + strtolower( (string) $connection->query( 'PRAGMA journal_mode' )->fetchColumn() ) + ); + $this->assertSame( + '1', + (string) $connection->query( 'PRAGMA synchronous' )->fetchColumn() + ); + } finally { + $this->remove_database_files( $path ); + } + } + + public function test_journal_mode_and_synchronous_driver_options(): void { + $path = tempnam( sys_get_temp_dir(), 'wp_sqlite_' ); + unlink( $path ); + + try { + $driver = new WP_PDO_MySQL_On_SQLite( + 'mysql-on-sqlite:path=' . $path . ';dbname=wp', + null, + null, + array( + 'journal_mode' => 'DELETE', + 'synchronous' => 'FULL', + ) + ); + $connection = $driver->get_connection(); + $this->assertSame( + 'delete', + strtolower( (string) $connection->query( 'PRAGMA journal_mode' )->fetchColumn() ) + ); + $this->assertSame( + '2', + (string) $connection->query( 'PRAGMA synchronous' )->fetchColumn() + ); + } finally { + $this->remove_database_files( $path ); + } + } + public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( PDOStatement::class, $result ); @@ -548,4 +596,12 @@ public function data_pdo_fetch_methods(): Generator { ), ); } + + private function remove_database_files( string $path ): void { + foreach ( array( $path, $path . '-wal', $path . '-shm', $path . '-journal' ) as $file ) { + if ( file_exists( $file ) ) { + unlink( $file ); + } + } + } } From a15ecdbdc5be13a1d7769c09be1dfbb5dc68b9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Sun, 14 Jun 2026 21:18:13 +0200 Subject: [PATCH 5/6] Inline journal/synchronous defaults and explain NORMAL Drop the single-use DEFAULT_SQLITE_JOURNAL_MODE and DEFAULT_SQLITE_WAL_SYNCHRONOUS constants in favor of inline literals, and replace the synchronous comment with one that explains why we default to NORMAL under WAL: SQLite's default is FULL (an fsync per commit), while NORMAL avoids that cost and stays corruption-safe in WAL mode, a guarantee that does not hold for rollback journal modes. --- .../src/sqlite/class-wp-sqlite-connection.php | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index fe7ef881e..e7a121b62 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -19,16 +19,6 @@ class WP_SQLite_Connection { */ const DEFAULT_SQLITE_TIMEOUT = 10; - /** - * The default SQLite journal mode. - */ - const DEFAULT_SQLITE_JOURNAL_MODE = 'WAL'; - - /** - * The default SQLite synchronous setting for WAL mode. - */ - const DEFAULT_SQLITE_WAL_SYNCHRONOUS = 'NORMAL'; - /** * The supported SQLite journal modes. * @@ -117,9 +107,9 @@ public function __construct( array $options ) { } $this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout ); - // Configure SQLite journal mode. + // Configure SQLite journal mode. Default to WAL for best throughput. $effective_journal_mode = null; - $journal_mode = $options['journal_mode'] ?? self::DEFAULT_SQLITE_JOURNAL_MODE; + $journal_mode = $options['journal_mode'] ?? 'WAL'; if ( is_string( $journal_mode ) ) { $journal_mode = strtoupper( $journal_mode ); } @@ -138,17 +128,38 @@ public function __construct( array $options ) { } } - // Configure SQLite synchronous setting. In WAL mode, default to NORMAL. - // Otherwise, use SQLite's default value. + /* + * Configure SQLite synchronous setting. Default to NORMAL for WAL mode. + * + * WAL improves read/write concurrency and "synchronous = NORMAL" avoids + * frequent sync to the main database, which could become a bottleneck. + * In WAL mode, NORMAL is safe and recommended. From the SQLite docs: + * + * The synchronous=NORMAL setting provides the best balance between + * performance and safety for most applications running in WAL mode. + * You lose durability across power lose with synchronous NORMAL in WAL + * mode, but that is not important for most applications. Transactions + * are still atomic, consistent, and isolated, which are the most + * important characteristics in most use cases. + * + * SQLite defaults to "synchronous = FULL" to avoid data corruption with + * other journal modes. With WAL, this is not necessary. + * + * See: https://sqlite.org/pragma.html#pragma_synchronous + */ $synchronous = $options['synchronous'] ?? null; - if ( null === $synchronous && 'WAL' === $effective_journal_mode ) { - $synchronous = self::DEFAULT_SQLITE_WAL_SYNCHRONOUS; - } elseif ( is_int( $synchronous ) && isset( self::SQLITE_SYNCHRONOUS_SETTINGS[ $synchronous ] ) ) { - $synchronous = self::SQLITE_SYNCHRONOUS_SETTINGS[ $synchronous ]; - } elseif ( is_string( $synchronous ) ) { - $synchronous = strtoupper( $synchronous ); + if ( isset( $synchronous ) ) { + // Validate and normalize explicitly provided synchronous value. + if ( is_int( $synchronous ) && isset( self::SQLITE_SYNCHRONOUS_SETTINGS[ $synchronous ] ) ) { + $synchronous = self::SQLITE_SYNCHRONOUS_SETTINGS[ $synchronous ]; + } elseif ( is_string( $synchronous ) ) { + $synchronous = strtoupper( $synchronous ); + } + } elseif ( 'WAL' === $effective_journal_mode ) { + // Default to NORMAL for WAL mode. + $synchronous = 'NORMAL'; } - if ( $synchronous && in_array( $synchronous, self::SQLITE_SYNCHRONOUS_SETTINGS, true ) ) { + if ( in_array( $synchronous, self::SQLITE_SYNCHRONOUS_SETTINGS, true ) ) { $this->query( 'PRAGMA synchronous = ' . $synchronous ); } } From 8de3d1a787d658ba565ffe5a4fa41707aba463ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 19 Jun 2026 16:36:38 +0200 Subject: [PATCH 6/6] Reject invalid journal mode and synchronous option values Previously, an invalid explicitly provided journal mode or synchronous value was silently ignored, leaving the connection on a different setting than requested. Throw an InvalidArgumentException instead, consistent with the existing validation of the "path" option, so misconfiguration surfaces rather than passing unnoticed. --- .../src/sqlite/class-wp-sqlite-connection.php | 32 ++++++++++++------- .../tests/WP_SQLite_Connection_Tests.php | 27 +++++++++++++--- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index e7a121b62..170ba01c8 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -113,18 +113,21 @@ public function __construct( array $options ) { if ( is_string( $journal_mode ) ) { $journal_mode = strtoupper( $journal_mode ); } - if ( $journal_mode && in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) { - try { - $effective_journal_mode = strtoupper( - (string) $this->query( 'PRAGMA journal_mode = ' . $journal_mode )->fetchColumn() - ); - } catch ( PDOException $e ) { - // WAL may be unavailable in some environments, such as on network - // filesystems. When it is explicitly configured, surface the error. - // Otherwise, fall back to the default SQLite behavior. - if ( isset( $options['journal_mode'] ) ) { - throw $e; - } + if ( ! in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) { + throw new InvalidArgumentException( + sprintf( 'Invalid SQLite journal mode: %s.', $options['journal_mode'] ) + ); + } + try { + $effective_journal_mode = strtoupper( + (string) $this->query( 'PRAGMA journal_mode = ' . $journal_mode )->fetchColumn() + ); + } catch ( PDOException $e ) { + // WAL may be unavailable in some environments, such as on network + // filesystems. When it is explicitly configured, surface the error. + // Otherwise, fall back to the default SQLite behavior. + if ( isset( $options['journal_mode'] ) ) { + throw $e; } } @@ -155,6 +158,11 @@ public function __construct( array $options ) { } elseif ( is_string( $synchronous ) ) { $synchronous = strtoupper( $synchronous ); } + if ( ! in_array( $synchronous, self::SQLITE_SYNCHRONOUS_SETTINGS, true ) ) { + throw new InvalidArgumentException( + sprintf( 'Invalid SQLite synchronous setting: %s.', $options['synchronous'] ) + ); + } } elseif ( 'WAL' === $effective_journal_mode ) { // Default to NORMAL for WAL mode. $synchronous = 'NORMAL'; diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php index c5ef606d1..d74fdb897 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -104,17 +104,34 @@ public function testJournalModeAndSynchronousAreCaseInsensitive(): void { $this->assertSame( '3', $this->get_synchronous( $connection ) ); } - public function testInvalidJournalModeAndSynchronousAreIgnored(): void { - $connection = new WP_SQLite_Connection( + public function testInvalidJournalModeThrows(): void { + $this->expectException( InvalidArgumentException::class ); + new WP_SQLite_Connection( array( 'path' => $this->db_path, 'journal_mode' => 'INVALID', - 'synchronous' => 'INVALID', ) ); + } - $this->assertSame( 'delete', $this->get_journal_mode( $connection ) ); - $this->assertSame( '2', $this->get_synchronous( $connection ) ); + public function testInvalidSynchronousThrows(): void { + $this->expectException( InvalidArgumentException::class ); + new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'synchronous' => 'INVALID', + ) + ); + } + + public function testOutOfRangeIntegerSynchronousThrows(): void { + $this->expectException( InvalidArgumentException::class ); + new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'synchronous' => 5, + ) + ); } public function testSynchronousAcceptsIntegerValues(): void {