diff --git a/features/bootstrap/SQLiteFeatureContext.php b/features/bootstrap/SQLiteFeatureContext.php index a83dbc9..d543dfa 100644 --- a/features/bootstrap/SQLiteFeatureContext.php +++ b/features/bootstrap/SQLiteFeatureContext.php @@ -71,8 +71,11 @@ public function theFileShouldExist( $filename ) { private function connectToDatabase() { if ( ! $this->db ) { - $run_dir = $this->variables['RUN_DIR']; - $db_file = $run_dir . '/wp-content/database/.ht.sqlite'; + $result = $this->proc( 'wp eval \'echo defined( "DB_PATH" ) ? DB_PATH : FQDB;\'' )->run_check(); + $db_file = trim( $result->stdout ); + if ( ! is_file( $db_file ) ) { + throw new Exception( 'The WordPress SQLite database does not exist.' ); + } $this->db = new PDO( 'sqlite:' . $db_file ); $this->db->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); } diff --git a/features/sqlite-import.feature b/features/sqlite-import.feature index ab4975e..9c7ae4b 100644 --- a/features/sqlite-import.feature +++ b/features/sqlite-import.feature @@ -45,10 +45,9 @@ Feature: WP-CLI SQLite Import Command And the SQLite database should contain the imported data @require-sqlite - Scenario: Import a file with escape sequences + Scenario: Import a file with MySQL backslash escape sequences Given a SQL dump file named "test_import.sql" with content: """ - SET sql_mode='NO_BACKSLASH_ESCAPES'; CREATE TABLE test_table (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT); INSERT INTO test_table (name) VALUES ('Test that escaping a backslash \\ works'); INSERT INTO test_table (name) VALUES ('Test that escaping multiple backslashes \\\\\\ works'); @@ -62,11 +61,11 @@ Feature: WP-CLI SQLite Import Command Success: Imported from 'test_import.sql'. """ And the SQLite database should contain a table named "test_table" - And the "test_table" should contain a row with name "Test that escaping a backslash \\ works" - And the "test_table" should contain a row with name "Test that escaping multiple backslashes \\\\\\ works" - And the "test_table" should contain a row with name "Test that escaping a character \a works" - And the "test_table" should contain a row with name "Test that escaping a backslash followed by a character \\a works" - And the "test_table" should contain a row with name "Test that escaping a backslash and a character \\\a works" + And the "test_table" should contain a row with name "Test that escaping a backslash \ works" + And the "test_table" should contain a row with name "Test that escaping multiple backslashes \\\ works" + And the "test_table" should contain a row with name "Test that escaping a character a works" + And the "test_table" should contain a row with name "Test that escaping a backslash followed by a character \a works" + And the "test_table" should contain a row with name "Test that escaping a backslash and a character \a works" @require-sqlite Scenario: Import a file with newlines in strings @@ -150,7 +149,7 @@ Feature: WP-CLI SQLite Import Command Success: Imported from 'test_import.sql'. """ - And the SQLite database should contain a table named "a'strange`identifier\name" + And the SQLite database should contain a table named "a'strange`identifier\\name" @require-sqlite Scenario: Import a file with whitespace and empty lines diff --git a/features/sqlite-storage.feature b/features/sqlite-storage.feature new file mode 100644 index 0000000..02d6bf5 --- /dev/null +++ b/features/sqlite-storage.feature @@ -0,0 +1,195 @@ +@require-sqlite +Feature: SQLite database storage + Commands use the same database as WordPress before its database drop-in loads. + + Background: + Given a WP installation + + Scenario: Use an existing legacy database without migrating storage + Given a SQL dump file named "test_import.sql" with content: + """ + CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO test_table VALUES (1, 'Imported data'); + """ + When I run `wp eval 'echo defined( "DB_PATH" ) ? DB_PATH : FQDB;'` + Then save STDOUT as {DATABASE_PATH} + When I run `cp "{DATABASE_PATH}" original.sqlite` + And I run `rm -r wp-content/database` + And I run `mkdir wp-content/database` + And I run `mv original.sqlite wp-content/database/.ht.sqlite` + And I run `wp sqlite export backup.sql` + Then the backup.sql file should contain: + """ + WP CLI Site + """ + When I run `wp sqlite import test_import.sql` + And I run `wp sqlite export -` + Then STDOUT should contain: + """ + Imported data + """ + And the wp-content/database/.ht.sqlite file should exist + And the wp-content/database/db-path.php file should not exist + + Scenario: Only import initializes fresh default storage + When I run `wp sqlite export backup.sql` + And I run `rm -r wp-content/database` + And I try `wp sqlite export missing.sql` + Then the return code should be 1 + And the missing.sql file should not exist + When I try `wp sqlite tables` + Then the return code should be 1 + And the wp-content/database directory should not exist + When I try `wp sqlite import missing.sql` + Then the return code should be 1 + And STDERR should contain: + """ + Unable to open file: missing.sql + """ + And the wp-content/database directory should not exist + When I run `mkdir wp-content/database` + And I run `wp sqlite import backup.sql` + Then STDOUT should contain: + """ + Success: Imported from 'backup.sql'. + """ + When I run `wp sqlite tables` + Then STDOUT should contain: + """ + wp_options + """ + When I run `wp eval 'echo get_option( "blogname" );'` + Then STDOUT should be: + """ + WP CLI Site + """ + + Scenario: Use DB_PATH without changing existing storage + When I run `wp eval 'echo defined( "DB_PATH" ) ? DB_PATH : FQDB;'` + Then save STDOUT as {DATABASE_PATH} + When I run `mkdir custom-database` + And I run `cp "{DATABASE_PATH}" 'custom-database/custom;database.sqlite'` + And I run `wp config set DB_PATH "dirname( __FILE__ ) . '/custom-database/custom;database.sqlite'" --raw` + And I run `wp sqlite export backup.sql` + And I run `wp sqlite import backup.sql` + Then the custom-database/db-path.php file should not exist + And the custom-database/.htaccess file should not exist + And the custom-database/.ht.sqlite.lock file should not exist + When I run `wp sqlite export -` + Then STDOUT should contain: + """ + WP CLI Site + """ + + Scenario: Only import creates a missing explicit database + Given a SQL dump file named "test_import.sql" with content: + """ + CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO test_table VALUES (1, 'Imported data'); + """ + When I run `wp config set DB_PATH '{RUN_DIR}/new.sqlite'` + And I try `wp sqlite export backup.sql` + Then the return code should be 1 + And STDERR should contain: + """ + The SQLite database does not exist. + """ + And the new.sqlite file should not exist + And the backup.sql file should not exist + When I try `wp sqlite tables` + Then the return code should be 1 + And the new.sqlite file should not exist + When I try `wp sqlite import missing.sql` + Then the return code should be 1 + And STDERR should contain: + """ + Unable to open file: missing.sql + """ + And the new.sqlite file should not exist + When I run `wp sqlite import test_import.sql` + And I run `wp sqlite tables` + Then STDOUT should contain: + """ + test_table + """ + When I run `wp sqlite export -` + Then STDOUT should contain: + """ + Imported data + """ + + Scenario: Use the legacy FQDB override + When I run `wp eval 'echo defined( "DB_PATH" ) ? DB_PATH : FQDB;'` + Then save STDOUT as {DB_PATH} + When I run `cp "{DB_PATH}" custom.sqlite` + And I run `wp config set FQDB '{RUN_DIR}/custom.sqlite'` + And I run `wp sqlite export backup.sql` + And I run `wp sqlite import backup.sql` + And I run `wp eval 'echo get_option( "blogname" );'` + Then STDOUT should be: + """ + WP CLI Site + """ + + Scenario: Reject an invalid DB_PATH without falling back to existing storage + Given a SQL dump file named "test_import.sql" with content: + """ + CREATE TABLE test_table (id INTEGER PRIMARY KEY); + """ + When I run `wp config set DB_PATH null --raw` + And I try `wp sqlite import test_import.sql` + Then the return code should be 1 + And STDERR should contain: + """ + Error: Could not open the SQLite database: + """ + When I try `wp sqlite export backup.sql` + Then the return code should be 1 + And the backup.sql file should not exist + When I try `wp sqlite tables` + Then the return code should be 1 + When I run `wp config delete DB_PATH` + And I run `wp sqlite tables` + Then STDOUT should not contain: + """ + test_table + """ + + Scenario: Reject an in-memory database + Given a SQL dump file named "test_import.sql" with content: + """ + CREATE TABLE test_table (id INTEGER PRIMARY KEY); + """ + When I run `wp config set DB_PATH ':memory:'` + And I try `wp sqlite import test_import.sql` + Then the return code should be 1 + And STDERR should contain: + """ + In-memory databases are not supported. + """ + When I try `wp sqlite export backup.sql` + Then the return code should be 1 + And STDERR should contain: + """ + In-memory databases are not supported. + """ + And the backup.sql file should not exist + When I try `wp sqlite tables` + Then the return code should be 1 + And STDERR should contain: + """ + In-memory databases are not supported. + """ + + Scenario: Report a database that cannot be opened + Given a SQL dump file named "test_import.sql" with content: + """ + CREATE TABLE test_table (id INTEGER PRIMARY KEY); + """ + When I run `wp config set DB_PATH '{RUN_DIR}/missing-directory/database.sqlite'` + And I try `wp sqlite import test_import.sql` + Then the return code should be 1 + And STDERR should contain: + """ + Error: Could not open the SQLite database: + """ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 6ddc54d..5a1df5c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -38,7 +38,7 @@ - + diff --git a/phpstan/stubs.php b/phpstan/stubs.php index 10dc55a..7d5d730 100644 --- a/phpstan/stubs.php +++ b/phpstan/stubs.php @@ -8,8 +8,6 @@ // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound // phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound -// phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations -// phpcs:disable PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations class WP_SQLite_Connection { /** @@ -36,3 +34,9 @@ public function __construct() {} public function query( string $sql ) {} public function get_pdo(): PDO {} } + +class WP_SQLite_Storage { + public static function with_secret_path( string $root ): self {} + public function initialize(): string {} + private function __construct() {} +} diff --git a/src/Import.php b/src/Import.php index e312611..aaf6286 100644 --- a/src/Import.php +++ b/src/Import.php @@ -20,7 +20,6 @@ class Import { public function __construct() { SQLiteDatabaseIntegrationLoader::load_plugin(); - $this->driver = SQLiteDriverFactory::create_driver(); } /** @@ -36,6 +35,13 @@ public function run( $sql_file_path, $args ) { $is_stdin = '-' === $sql_file_path; $import_file = $is_stdin ? 'php://stdin' : $sql_file_path; + $handle = fopen( $import_file, 'r' ); + if ( ! $handle ) { + WP_CLI::error( "Unable to open file: $import_file" ); + } + + // Create the driver after opening the input, so a missing file doesn't create a database. + $this->driver = SQLiteDriverFactory::create_driver( true ); /* * Set default SQL mode and other options as per the "mysqldump" command. @@ -61,7 +67,8 @@ public function run( $sql_file_path, $args ) { */ $this->driver->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' ); - $this->execute_statements( $import_file ); + $this->execute_statements( $handle ); + fclose( $handle ); /* * Re-enable foreign key constraints and verify integrity. @@ -83,13 +90,13 @@ public function run( $sql_file_path, $args ) { /** * Execute SQL statements from an SQL dump file. * - * @param $import_file + * @param resource $handle The SQL dump file handle. * * @return void * @throws Exception */ - protected function execute_statements( $import_file ) { - foreach ( $this->parse_statements( $import_file ) as $statement ) { + protected function execute_statements( $handle ) { + foreach ( $this->parse_statements( $handle ) as $statement ) { try { $this->driver->query( $statement ); } catch ( Exception $e ) { @@ -114,18 +121,11 @@ protected function execute_statements( $import_file ) { /** * Parse SQL statements from an SQL dump file. - * @param string $sql_file_path The path to the SQL dump file. + * @param resource $handle The SQL dump file handle. * * @return Generator A generator that yields SQL statements. */ - public function parse_statements( $sql_file_path ) { - - $handle = fopen( $sql_file_path, 'r' ); - - if ( ! $handle ) { - WP_CLI::error( "Unable to open file: $sql_file_path" ); - } - + public function parse_statements( $handle ) { $starting_quote = null; $in_comment = false; $buffer = ''; @@ -202,7 +202,5 @@ public function parse_statements( $sql_file_path ) { if ( ! empty( $buffer ) ) { yield $buffer; } - - fclose( $handle ); } } diff --git a/src/SQLiteDatabaseIntegrationLoader.php b/src/SQLiteDatabaseIntegrationLoader.php index f235a64..a639302 100644 --- a/src/SQLiteDatabaseIntegrationLoader.php +++ b/src/SQLiteDatabaseIntegrationLoader.php @@ -86,7 +86,16 @@ public static function load_plugin() { if ( $old_structure ) { require_once $plugin_directory . '/php-polyfills.php'; } - require_once $plugin_directory . '/constants.php'; + try { + require_once $plugin_directory . '/constants.php'; + } catch ( \Throwable $exception ) { + // Newer plugin releases reject invalid database settings. + WP_CLI::error( 'Could not open the SQLite database: ' . $exception->getMessage() ); + } + $storage_file = $plugin_directory . '/wp-includes/sqlite/class-wp-sqlite-storage.php'; + if ( file_exists( $storage_file ) ) { + require_once $storage_file; + } $mysql_on_sqlite_class_file = $plugin_directory . '/wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php'; $mysql_on_sqlite_loader_file = $plugin_directory . '/wp-includes/database/load.php'; diff --git a/src/SQLiteDriverFactory.php b/src/SQLiteDriverFactory.php index acb8129..7e8de01 100644 --- a/src/SQLiteDriverFactory.php +++ b/src/SQLiteDriverFactory.php @@ -3,24 +3,93 @@ namespace Automattic\WP_CLI\SQLite; use PDO; +use RuntimeException; +use WP_CLI; use WP_MySQL_On_SQLite; use WP_SQLite_Connection; use WP_SQLite_Driver; +use WP_SQLite_Storage; use WP_SQLite_Translator; class SQLiteDriverFactory { /** * Create an instance of the SQLite driver. * + * @param bool $create_database_if_missing Allow imports to create a database when missing. + * + * @return WP_MySQL_On_SQLite|WP_SQLite_Driver|WP_SQLite_Translator + */ + public static function create_driver( $create_database_if_missing = false ) { + try { + $database_path = self::get_database_path( $create_database_if_missing ); + } catch ( \Throwable $exception ) { + WP_CLI::error( 'Could not open the SQLite database: ' . $exception->getMessage() ); + } + if ( ! $create_database_if_missing && ! is_file( $database_path ) ) { + WP_CLI::error( 'The SQLite database does not exist.' ); + } + + try { + $driver = self::open_driver( $database_path ); + } catch ( \Throwable $exception ) { + WP_CLI::error( 'Could not open the SQLite database: ' . $exception->getMessage() ); + } + return $driver; + } + + /** + * Resolve configuration without initializing existing storage. + * + * @param bool $create_database_if_missing Allow initialization when no secret path is recorded. + * @return string + */ + private static function get_database_path( $create_database_if_missing ) { + if ( defined( 'DB_PATH' ) ) { + $database_path = DB_PATH; + } elseif ( defined( 'FQDB' ) ) { + // Older plugin releases and explicit legacy settings use FQDB. + $database_path = FQDB; + } else { + $path_file = rtrim( FQDBDIR, '/\\' ) . '/db-path.php'; + $legacy_path = rtrim( FQDBDIR, '/\\' ) . '/.ht.sqlite'; + if ( file_exists( $path_file ) ) { + // Use include so unreadable files can be handled on PHP 7. + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Failure is checked below. + $database_path = @include $path_file; + if ( false === $database_path ) { + throw new RuntimeException( 'Failed to read the SQLite database path file.' ); + } + } elseif ( is_file( $legacy_path ) ) { + $database_path = $legacy_path; + } elseif ( $create_database_if_missing && class_exists( WP_SQLite_Storage::class ) ) { + $database_path = WP_SQLite_Storage::with_secret_path( FQDBDIR )->initialize(); + } else { + throw new RuntimeException( 'No SQLite database path is configured.' ); + } + } + + if ( ! is_string( $database_path ) || '' === $database_path || false !== strpos( $database_path, "\0" ) ) { + throw new RuntimeException( 'The SQLite database path is invalid.' ); + } + if ( ':memory:' === $database_path ) { + throw new RuntimeException( 'In-memory databases are not supported.' ); + } + return $database_path; + } + + /** + * Open the SQLite driver for a database path. + * + * @param string $database_path The SQLite database path. * @return WP_MySQL_On_SQLite|WP_SQLite_Driver|WP_SQLite_Translator */ - public static function create_driver() { + private static function open_driver( $database_path ) { $db_name = defined( 'DB_NAME' ) && '' !== DB_NAME ? DB_NAME : 'database_name_here'; if ( class_exists( WP_MySQL_On_SQLite::class ) ) { $database_dsn = sprintf( 'mysql-on-sqlite:path=%s;dbname=%s', - str_replace( ';', ';;', FQDB ), + str_replace( ';', ';;', $database_path ), str_replace( ';', ';;', $db_name ) ); $driver = new WP_MySQL_On_SQLite( $database_dsn ); @@ -35,7 +104,7 @@ public static function create_driver() { $connection = new WP_SQLite_Connection( array( - 'path' => FQDB, + 'path' => $database_path, ) ); return new WP_SQLite_Driver( $connection, $db_name );