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/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 1509a1e64..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 @@ -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,20 @@ class WP_SQLite_Connection { 'OFF', ); + /** + * 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( + 'OFF', + 'NORMAL', + 'FULL', + 'EXTRA', + ); + /** * The PDO connection for SQLite. * @@ -55,14 +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. + * @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. @@ -92,9 +118,38 @@ 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 ); + 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. + // Otherwise, use SQLite's default value. + $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 ( $synchronous && in_array( $synchronous, self::SQLITE_SYNCHRONOUS_SETTINGS, true ) ) { + $this->query( 'PRAGMA synchronous = ' . $synchronous ); } } 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 ); + } + } + } } 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..c5ef606d1 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -0,0 +1,191 @@ +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', + $this->db_path . '-shm', + $this->db_path . '-journal', + ) as $path ) { + if ( is_string( $path ) && file_exists( $path ) ) { + unlink( $path ); + } + } + rmdir( $this->db_dir ); + $this->db_dir = null; + $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 ) ); + } + + 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(); + + $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() ); + } + + 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..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,20 +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 ) ), - $wpdb->dbname - ); - $query = null; + $query = null; try { $translator->begin_transaction();